working full end-to-end with encryption

This commit is contained in:
2025-12-22 13:56:11 -06:00
parent 24dc4090c0
commit acea54814b
14 changed files with 530 additions and 119 deletions

View File

@@ -7,6 +7,11 @@
"dependencies": {
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^5.2.1",
"node-cron": "^4.2.1",
@@ -73,12 +78,16 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cookie-parser": ["@types/cookie-parser@1.4.10", "", { "peerDependencies": { "@types/express": "*" } }, "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
@@ -105,8 +114,14 @@
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
@@ -125,6 +140,8 @@
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
@@ -135,7 +152,9 @@
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cookie-parser": ["cookie-parser@1.4.7", "", { "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.6" } }, "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw=="],
"cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="],
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
@@ -149,6 +168,8 @@
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
@@ -173,6 +194,8 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
@@ -185,8 +208,12 @@
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
@@ -211,6 +238,8 @@
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
@@ -259,10 +288,14 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
"node-cron": ["node-cron@4.2.1", "", {}, "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -319,6 +352,8 @@
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
@@ -409,8 +444,14 @@
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@6.8.2", "", {}, "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg=="],
"express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
}
}

View File

@@ -17,6 +17,11 @@
"dependencies": {
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^5.2.1",
"node-cron": "^4.2.1",

View File

@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_token_idx" ON "Session"("token");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `skywardPassword` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "skywardPassword" TEXT NOT NULL;

View File

@@ -6,15 +6,30 @@ datasource db {
provider = "postgresql"
}
model Session {
id String @id @default(uuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([token])
}
model User {
id String @id @default(uuid())
username String @unique
password String
password String // Bcrypt hash for login auth
skywardPassword String // Encrypted password for Skyward API calls
createdAt DateTime @default(now())
classes Class[]
fetches Fetch[]
finalGrades FinalGrade[]
sessions Session[]
}
model Class {

View File

@@ -1,19 +1,28 @@
import express from 'express';
import cors from 'cors';
import { disconnectDB } from './db';
import cookieParser from 'cookie-parser'; // ADD THIS
import { startSyncScheduler, stopSyncScheduler } from './jobs/sync-scheduler';
// Import routes
import usersRoutes from './routes/users.routes';
import gradesRoutes from './routes/grades.routes';
import statsRoutes from './routes/stats.routes';
import authRoutes from './routes/auth.routes'; // ADD THIS
const app = express();
const PORT = process.env.PORT || 4000;
// Middleware
app.use(cors());
app.use(cors({
origin: 'http://localhost:5173',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(cookieParser()); // ADD THIS
// Health check
app.get('/health', (req, res) => {
@@ -28,6 +37,8 @@ app.get('/health', (req, res) => {
app.use('/api/users', usersRoutes);
app.use('/api', gradesRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/auth', authRoutes); // ADD THIS
// 404 handler
app.use((req, res) => {

231
src/routes/auth.routes.ts Normal file
View File

@@ -0,0 +1,231 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { prisma } from '../db';
import axios from 'axios';
import crypto from 'crypto';
import { encrypt, decrypt } from '../utils/encryption';
const router = express.Router();
const SALT_ROUNDS = 12;
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
// Middleware to check if user is authenticated
export async function requireAuth(req: any, res: any, next: any) {
const sessionToken = req.cookies?.sessionToken;
if (!sessionToken) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const session = await prisma.session.findUnique({
where: { token: sessionToken },
include: { user: true }
});
if (!session || session.expiresAt < new Date()) {
return res.status(401).json({ error: 'Session expired' });
}
req.user = session.user;
next();
} catch (error) {
return res.status(500).json({ error: 'Authentication error' });
}
}
// Register new user
router.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const normalizedUsername = username.toLowerCase().trim();
try {
const existingUser = await prisma.user.findFirst({
where: {
username: {
equals: normalizedUsername,
mode: 'insensitive'
}
}
});
if (existingUser) {
return res.status(409).json({ error: 'Username already exists' });
}
// Verify credentials with Skyward via port 3000 API
console.log('Verifying Skyward credentials...');
try {
const authCheck = await axios.post('http://localhost:3000/check-auth', {
username,
password
}, {
timeout: 30000
});
if (!authCheck.data.success) {
return res.status(401).json({
error: 'Invalid Skyward credentials. Please verify your username and password.'
});
}
console.log('Skyward credentials verified successfully');
} catch (authError: any) {
if (authError.response?.status === 401) {
return res.status(401).json({
error: 'Invalid Skyward credentials. Please verify your username and password.'
});
}
console.error('Skyward auth check failed:', authError.message);
return res.status(500).json({
error: 'Unable to verify credentials with Skyward. Please try again.'
});
}
// Hash password for login authentication
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
// Encrypt password for Skyward API calls
const encryptedSkywardPassword = encrypt(password);
const user = await prisma.user.create({
data: {
username: normalizedUsername,
password: hashedPassword,
skywardPassword: encryptedSkywardPassword
}
});
console.log('User created successfully');
const sessionToken = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_DURATION);
await prisma.session.create({
data: {
token: sessionToken,
userId: user.id,
expiresAt
}
});
res.cookie('sessionToken', sessionToken, {
httpOnly: true,
secure: false, // set to true in production
sameSite: 'lax',
maxAge: SESSION_DURATION
});
return res.json({
success: true,
user: {
id: user.id,
username: user.username
}
});
} catch (error) {
console.error('Registration error:', error);
return res.status(500).json({ error: 'Registration failed' });
}
});
// Login existing user
router.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
const normalizedUsername = username.toLowerCase().trim();
try {
const user = await prisma.user.findFirst({
where: {
username: {
equals: normalizedUsername,
mode: 'insensitive'
}
}
});
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const sessionToken = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_DURATION);
await prisma.session.create({
data: {
token: sessionToken,
userId: user.id,
expiresAt
}
});
res.cookie('sessionToken', sessionToken, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: SESSION_DURATION
});
return res.json({
success: true,
user: {
id: user.id,
username: user.username
}
});
} catch (error) {
console.error('Login error:', error);
return res.status(500).json({ error: 'Login failed' });
}
});
// Logout
router.post('/logout', async (req, res) => {
const sessionToken = req.cookies?.sessionToken;
if (sessionToken) {
try {
await prisma.session.delete({
where: { token: sessionToken }
});
} catch (error) {
// Session might not exist
}
}
res.clearCookie('sessionToken');
return res.json({ success: true });
});
// Check auth status
router.get('/me', requireAuth, async (req: any, res) => {
return res.json({
user: {
id: req.user.id,
username: req.user.username
}
});
});
export default router;

View File

@@ -2,13 +2,24 @@ import { Router } from 'express';
import { getGradesForUser, getFetchHistory, getFinalGrades } from '../services/grades.service';
import { syncUserGrades } from '../services/sync.service';
import { prisma } from '../db';
import { requireAuth } from './auth.routes';
import { getSkywardCredentials } from '../utils/user-helpers';
const router = Router();
// Get all grades for a user
router.get('/users/:username/grades', async (req, res) => {
// Get all grades for a user (protected)
router.get('/users/:username/grades', requireAuth, async (req: any, res) => {
try {
const { username } = req.params;
// Ensure user can only access their own grades
if (req.user.username !== username) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
const user = await getGradesForUser(username);
res.json({
@@ -28,10 +39,19 @@ router.get('/users/:username/grades', async (req, res) => {
}
});
// Get classes for a user
router.get('/users/:username/classes', async (req, res) => {
// Get classes for a user (protected)
router.get('/users/:username/classes', requireAuth, async (req: any, res) => {
try {
const { username } = req.params;
// Ensure user can only access their own classes
if (req.user.username !== username) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
const user = await prisma.user.findUnique({
where: { username },
include: {
@@ -69,11 +89,19 @@ router.get('/users/:username/classes', async (req, res) => {
}
});
// Get assignments for a specific class
router.get('/users/:username/classes/:category/assignments', async (req, res) => {
// Get assignments for a specific class (protected)
router.get('/users/:username/classes/:category/assignments', requireAuth, async (req: any, res) => {
try {
const { username, category } = req.params;
// Ensure user can only access their own assignments
if (req.user.username !== username) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
const user = await prisma.user.findUnique({
where: { username },
});
@@ -85,7 +113,6 @@ router.get('/users/:username/classes/:category/assignments', async (req, res) =>
});
}
// Find all classes with this category (there might be multiple)
const classes = await prisma.class.findMany({
where: {
userId: user.id,
@@ -105,7 +132,6 @@ router.get('/users/:username/classes/:category/assignments', async (req, res) =>
});
}
// Return all classes with this category
res.json({
success: true,
category,
@@ -126,10 +152,19 @@ router.get('/users/:username/classes/:category/assignments', async (req, res) =>
}
});
// Get fetch history for a user
router.get('/users/:username/history', async (req, res) => {
// Get fetch history for a user (protected)
router.get('/users/:username/history', requireAuth, async (req: any, res) => {
try {
const { username } = req.params;
// Ensure user can only access their own history
if (req.user.username !== username) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
const history = await getFetchHistory(username);
res.json({
@@ -147,10 +182,19 @@ router.get('/users/:username/history', async (req, res) => {
}
});
// Get final grades for a user
router.get('/users/:username/final-grades', async (req, res) => {
// Get final grades for a user (protected)
router.get('/users/:username/final-grades', requireAuth, async (req: any, res) => {
try {
const { username } = req.params;
// Ensure user can only access their own grades
if (req.user.username !== username) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
const finalGrades = await getFinalGrades(username);
res.json({
@@ -168,23 +212,21 @@ router.get('/users/:username/final-grades', async (req, res) => {
}
});
// Manual sync for one user
router.post('/sync/manual', async (req, res) => {
// Manual sync for authenticated user (protected)
router.post('/sync/manual', requireAuth, async (req: any, res) => {
try {
const { username, password } = req.body;
console.log(`[Manual Sync] User ${req.user.username} requested manual sync`);
if (!username || !password) {
return res.status(400).json({
success: false,
error: 'Username and password are required',
});
}
// Get decrypted Skyward credentials
const { username, password } = await getSkywardCredentials(req.user.id);
// Sync grades using the decrypted password
const result = await syncUserGrades(username, password);
res.json(result);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Manual Sync] Error:`, errorMsg);
res.status(500).json({
success: false,
error: errorMsg,

View File

@@ -1,10 +1,11 @@
import { Router } from 'express';
import { getOverallStats, getUserStats, getClassStats } from '../services/stats.service';
import { requireAuth } from './auth.routes';
const router = Router();
// Overall system stats
router.get('/overall', async (req, res) => {
// Overall system stats (protected)
router.get('/overall', requireAuth, async (req, res) => {
try {
const stats = await getOverallStats();
res.json({
@@ -20,10 +21,19 @@ router.get('/overall', async (req, res) => {
}
});
// User-specific stats
router.get('/users/:username', async (req, res) => {
// User-specific stats (protected)
router.get('/users/:username', requireAuth, async (req: any, res) => {
try {
const { username } = req.params;
// Ensure user can only access their own stats
if (req.user.username !== username) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
const stats = await getUserStats(username);
res.json({
success: true,
@@ -38,10 +48,19 @@ router.get('/users/:username', async (req, res) => {
}
});
// Class-specific stats
router.get('/users/:username/classes/:category', async (req, res) => {
// Class-specific stats (protected)
router.get('/users/:username/classes/:category', requireAuth, async (req: any, res) => {
try {
const { username, category } = req.params;
// Ensure user can only access their own stats
if (req.user.username !== username) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
const stats = await getClassStats(username, category);
res.json({
success: true,

View File

@@ -1,58 +1,15 @@
import { Router } from 'express';
import { prisma } from '../db';
import { getAllUsers, deleteUser } from '../services/grades.service';
import { requireAuth } from './auth.routes';
const router = Router();
// Register a new user
router.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
// NOTE: User registration is now handled by /api/auth/register
// This old endpoint is kept for backward compatibility but should be removed
if (!username || !password) {
return res.status(400).json({
success: false,
error: 'Username and password are required',
});
}
// Check if user exists
const existing = await prisma.user.findUnique({
where: { username },
});
if (existing) {
return res.status(409).json({
success: false,
error: 'Username already exists',
});
}
// Create user
const user = await prisma.user.create({
data: { username, password },
select: {
id: true,
username: true,
createdAt: true,
},
});
res.json({
success: true,
user,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
res.status(500).json({
success: false,
error: errorMsg,
});
}
});
// Get all users
router.get('/', async (req, res) => {
// Get all users (protected, admin only - you can add admin check later)
router.get('/', requireAuth, async (req, res) => {
try {
const users = await getAllUsers();
res.json({
@@ -69,10 +26,19 @@ router.get('/', async (req, res) => {
}
});
// Delete user
router.delete('/:username', async (req, res) => {
// Delete user (protected)
router.delete('/:username', requireAuth, async (req: any, res) => {
try {
const { username } = req.params;
// Ensure user can only delete their own account
if (req.user.username !== username) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
const result = await deleteUser(username);
res.json({
success: true,

View File

@@ -17,24 +17,15 @@ interface GradeData {
export async function syncGradesToDatabase(
username: string,
password: string,
gradesData: GradeData[]
) {
// Step 1: Find or create user
let user = await prisma.user.findUnique({
// Find user (must already exist from registration)
const user = await prisma.user.findUnique({
where: { username },
});
if (!user) {
user = await prisma.user.create({
data: { username, password },
});
} else {
// Update password in case it changed
user = await prisma.user.update({
where: { username },
data: { password },
});
throw new Error('User not found. Please register first.');
}
let classesProcessed = 0;
@@ -42,7 +33,7 @@ export async function syncGradesToDatabase(
let assignmentsUpdated = 0;
let finalGradesUpdated = 0;
// Step 2: Process each class
// Process each class
for (const classData of gradesData) {
// Find or create class
let classRecord = await prisma.class.findUnique({
@@ -80,7 +71,7 @@ export async function syncGradesToDatabase(
classesProcessed++;
// Step 2.5: Save/Update Final Grade if present
// Save/Update Final Grade if present
if (classData.overallGrade) {
const existingFinalGrade = await prisma.finalGrade.findUnique({
where: {
@@ -164,7 +155,7 @@ export async function syncGradesToDatabase(
console.log(`[DB] Class ${classData.className} (${classData.category}): ${assignmentsAdded} new, ${assignmentsUpdated} updated`);
}
// Step 3: Log this fetch
// Log this fetch
await prisma.fetch.create({
data: {
userId: user.id,
@@ -194,7 +185,7 @@ export async function getGradesForUser(username: string) {
assignments: {
orderBy: { createdAt: 'desc' },
},
finalGrades: true, // Include final grades
finalGrades: true,
},
orderBy: { category: 'asc' },
},
@@ -250,7 +241,6 @@ export async function getFetchHistory(username: string) {
});
}
// New function to get final grades
export async function getFinalGrades(username: string) {
const user = await prisma.user.findUnique({
where: { username },

View File

@@ -1,5 +1,7 @@
import { fetchGradesFromScraper } from './scraper.service';
import { syncGradesToDatabase, getAllUsers } from './grades.service';
import { getSkywardCredentials } from '../utils/user-helpers';
import { prisma } from '../db';
interface SyncResult {
username: string;
@@ -20,8 +22,8 @@ export async function syncUserGrades(
// Step 1: Fetch from scraper
const gradesData = await fetchGradesFromScraper(username, password);
// Step 2: Save to database
const result = await syncGradesToDatabase(username, password, gradesData);
// Step 2: Save to database (no longer needs password param)
const result = await syncGradesToDatabase(username, gradesData);
console.log(`[Sync] ✓ Completed for ${username}: ${result.classesProcessed} classes, ${result.assignmentsAdded} new, ${result.assignmentsUpdated} updated`);
@@ -56,8 +58,13 @@ export async function syncMultipleUsers(): Promise<{
console.log(`\n[Sync Job] Starting batch sync (max ${maxConcurrent} concurrent)`);
// Get all users with their credentials
const users = await getAllUsers();
// Get all users with IDs
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
},
});
if (users.length === 0) {
console.log('[Sync Job] No users to sync');
@@ -70,25 +77,30 @@ export async function syncMultipleUsers(): Promise<{
};
}
// We need to get passwords, so fetch full user records
const { prisma } = await import('../db');
const usersWithPasswords = await prisma.user.findMany({
select: {
username: true,
password: true,
},
});
const results: SyncResult[] = [];
// Process in batches
for (let i = 0; i < usersWithPasswords.length; i += maxConcurrent) {
const batch = usersWithPasswords.slice(i, i + maxConcurrent);
for (let i = 0; i < users.length; i += maxConcurrent) {
const batch = users.slice(i, i + maxConcurrent);
console.log(`[Sync Job] Processing batch ${Math.floor(i / maxConcurrent) + 1}: ${batch.map(u => u.username).join(', ')}`);
const batchPromises = batch.map(user =>
syncUserGrades(user.username, user.password)
);
const batchPromises = batch.map(async (user) => {
try {
// Get decrypted credentials for each user
const { username, password } = await getSkywardCredentials(user.id);
// Sync with decrypted password
return await syncUserGrades(username, password);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Sync Job] Failed to get credentials for ${user.username}:`, errorMsg);
return {
username: user.username,
success: false,
error: errorMsg,
};
}
});
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);

31
src/utils/encryption.ts Normal file
View File

@@ -0,0 +1,31 @@
import crypto from 'crypto';
// IMPORTANT: Add this to your .env file
// Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
const IV_LENGTH = 16;
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '';
export function encrypt(text: string): string {
const key = Buffer.from(ENCRYPTION_KEY.slice(0, 32), 'utf8');
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
export function decrypt(text: string): string {
const key = Buffer.from(ENCRYPTION_KEY.slice(0, 32), 'utf8');
const parts = text.split(':');
const iv = Buffer.from(parts.shift()!, 'hex');
const encryptedText = parts.join(':');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}

18
src/utils/user-helpers.ts Normal file
View File

@@ -0,0 +1,18 @@
import { prisma } from '../db';
import { decrypt } from './encryption';
export async function getSkywardCredentials(userId: string): Promise<{ username: string; password: string }> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { username: true, skywardPassword: true }
});
if (!user) {
throw new Error('User not found');
}
return {
username: user.username,
password: decrypt(user.skywardPassword)
};
}