diff --git a/bun.lock b/bun.lock index ac9521e..8fe3f30 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/package.json b/package.json index 1e72453..0ebdfa9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20251222191415_add_sessions/migration.sql b/prisma/migrations/20251222191415_add_sessions/migration.sql new file mode 100644 index 0000000..3b53f72 --- /dev/null +++ b/prisma/migrations/20251222191415_add_sessions/migration.sql @@ -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; diff --git a/prisma/migrations/20251222192915_add_skyward_password/migration.sql b/prisma/migrations/20251222192915_add_skyward_password/migration.sql new file mode 100644 index 0000000..3fc8b6a --- /dev/null +++ b/prisma/migrations/20251222192915_add_skyward_password/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 356061b..e2cc072 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,15 +6,30 @@ datasource db { provider = "postgresql" } -model User { +model Session { id String @id @default(uuid()) - username String @unique - password String + 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 // 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 { diff --git a/src/index.ts b/src/index.ts index 5980ac6..822a3a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) => { diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts new file mode 100644 index 0000000..e5bf596 --- /dev/null +++ b/src/routes/auth.routes.ts @@ -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; \ No newline at end of file diff --git a/src/routes/grades.routes.ts b/src/routes/grades.routes.ts index e4e6b54..e09ce5f 100644 --- a/src/routes/grades.routes.ts +++ b/src/routes/grades.routes.ts @@ -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; - - if (!username || !password) { - return res.status(400).json({ - success: false, - error: 'Username and password are required', - }); - } - + console.log(`[Manual Sync] User ${req.user.username} requested manual sync`); + + // 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, diff --git a/src/routes/stats.routes.ts b/src/routes/stats.routes.ts index ab35c5b..8aa9621 100644 --- a/src/routes/stats.routes.ts +++ b/src/routes/stats.routes.ts @@ -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, diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index 06bc0aa..0fdc033 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -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, diff --git a/src/services/grades.service.ts b/src/services/grades.service.ts index f6179e5..79ef7b8 100644 --- a/src/services/grades.service.ts +++ b/src/services/grades.service.ts @@ -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 }, diff --git a/src/services/sync.service.ts b/src/services/sync.service.ts index 389cd79..dee3ffc 100644 --- a/src/services/sync.service.ts +++ b/src/services/sync.service.ts @@ -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); diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts new file mode 100644 index 0000000..df27e01 --- /dev/null +++ b/src/utils/encryption.ts @@ -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; +} \ No newline at end of file diff --git a/src/utils/user-helpers.ts b/src/utils/user-helpers.ts new file mode 100644 index 0000000..322d847 --- /dev/null +++ b/src/utils/user-helpers.ts @@ -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) + }; +} \ No newline at end of file