diff --git a/app/components/profile.tsx b/app/components/profile.tsx new file mode 100644 index 0000000..bed4a7a --- /dev/null +++ b/app/components/profile.tsx @@ -0,0 +1,13 @@ +import { Link } from "@remix-run/react"; + +export function ProfileMenu() { + // For now we just always display a login link: this is terrible UX, but simple. + // TODO show here if the user is logged in or not. + return +} + +function GoToLogin() { + return ( + Log in + ); +} diff --git a/app/root.tsx b/app/root.tsx index bdbee41..51994bf 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -13,6 +13,8 @@ import { } from "@remix-run/react"; import { SpeedInsights } from "@vercel/speed-insights/remix" +import { ProfileMenu } from "~/components/profile"; + import faviconSvg from "~/images/favicon.svg"; import logo from "~/images/icon.svg"; @@ -53,6 +55,7 @@ export default function App() {
  • Your Schedule
  • All Events
  • Settings
  • +
  • diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..41770b3 --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,80 @@ +import { Form, useActionData } from "@remix-run/react"; +import { authenticator } from "~/scripts/auth.server"; +import { json } from "@remix-run/node"; + +import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + { title: "Log in | BVS" }, + { name: "description", content: "Log in to your account for personalized content." }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + // If the user is already authenticated redirect to home + return await authenticator.isAuthenticated(request, { + successRedirect: getNextUrl(request), + }); +} + +function getNextUrl(request: Request) { + try { + const url = new URL(request.url).searchParams.get("next"); + if (typeof url !== "string") { + return "/"; + } + try { + return new URL(url).pathname; // No cross-origin redirects happening here! + } catch { + return url; + } + } catch (e) { + return "/"; + } +} + +export async function action({ request }: ActionFunctionArgs) { + try { + return await authenticator.authenticate("usernamePassword", request, { + successRedirect: getNextUrl(request), + throwOnError: true + }); + } catch (e) { + switch ((e as Error).message) { + case "badUsername": + return json({ formError: { message: "User not found" } }); + break; + default: + return json({ + formError: { + message: `Unknown authentication error "${(e as Error).message}"` + } + }); + } + } +} + +export default function Login() { + const actionData = useActionData(); + return ( +
    +
    +
    + +
    {actionData?.formError && {actionData.formError.message}}
    +
    + ); +} diff --git a/app/routes/my-schedule.tsx b/app/routes/my-schedule.tsx index d4d6872..6e79c80 100644 --- a/app/routes/my-schedule.tsx +++ b/app/routes/my-schedule.tsx @@ -1,4 +1,6 @@ -import type { MetaFunction } from "@remix-run/node"; +import { authenticator } from "~/scripts/auth.server"; + +import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; export const meta: MetaFunction = () => { return [ @@ -7,6 +9,13 @@ export const meta: MetaFunction = () => { ]; }; +export async function loader({ request }: LoaderFunctionArgs) { + // If the user is already authenticated redirect to home + return await authenticator.isAuthenticated(request, { + failureRedirect: "/login?next=/my-schedule", + }); +} + export default function MySchedule() { return (

    My Schedule

    diff --git a/app/scripts/accounts.server.ts b/app/scripts/accounts.server.ts new file mode 100644 index 0000000..67e4e3a --- /dev/null +++ b/app/scripts/accounts.server.ts @@ -0,0 +1,9 @@ +import { prisma } from "~/scripts/prisma.server"; + +export async function getUserByUsername(username: string) { + return await prisma.user.findUnique({ where: { username } }); +} + +export async function usernameExists(username: string) { + return await getUserByUsername(username) !== null; +} diff --git a/app/scripts/auth.server.ts b/app/scripts/auth.server.ts new file mode 100644 index 0000000..04d56c8 --- /dev/null +++ b/app/scripts/auth.server.ts @@ -0,0 +1,39 @@ +import { Authenticator } from "remix-auth"; +import { FormStrategy } from "remix-auth-form"; +import { sessionStorage } from "~/scripts/session.server"; +import { usernamePasswordLogin } from "~/scripts/password.server"; +import { prisma } from "~/scripts/prisma.server"; + +import type { Session } from "~/scripts/session.server"; +import type { CredType } from "@prisma/client"; + +export const authenticator = new Authenticator(sessionStorage); + +// Tell the Authenticator to use the form strategy +authenticator.use( + new FormStrategy(async ({ form }) => { + let username = form.get("username"); + let password = form.get("password"); + if (typeof username !== "string" || typeof password !== "string") { + throw new TypeError("badFormData"); + } + const session = await usernamePasswordLogin( + username, + password, + await getCredsByType(username, "USERNAME_PASSWORD") + ); + return session; + }), + "usernamePassword" +); + +async function getCredsByType(username: string, type: CredType) { + return await prisma.cred.findMany({ + where: { + user: { + username + }, + type + } + }); +} diff --git a/app/scripts/password.server.ts b/app/scripts/password.server.ts new file mode 100644 index 0000000..e27c122 --- /dev/null +++ b/app/scripts/password.server.ts @@ -0,0 +1,31 @@ +import bcrypt from "bcryptjs"; +import { getUserByUsername } from "~/scripts/accounts.server"; + +import type { Cred } from "@prisma/client"; +import type { Session } from "~/scripts/session.server"; + +export async function usernamePasswordLogin( + username: string, + password: string, + passwordCreds: Cred[] +) { + const user = await getUserByUsername(username); + if (user == null) { + throw new Error("badUsername"); + } + if (passwordCreds.length == 0) { + throw new Error("noPasswords"); + } + if (password.length == 0) { + throw new Error("missingPassword"); + } + + for (const cred of passwordCreds) { + if (await bcrypt.compare(password, (cred.cred as { hash: string }).hash)) { + let session: Session = { user }; + return session; + } + } + + throw new Error("badPassword"); +} diff --git a/app/scripts/session.server.ts b/app/scripts/session.server.ts new file mode 100644 index 0000000..bbe3121 --- /dev/null +++ b/app/scripts/session.server.ts @@ -0,0 +1,60 @@ +import { prisma } from "~/scripts/prisma.server"; +import { createSessionStorage } from "@remix-run/node"; +import type { User } from "@prisma/client"; + +export type Session = { + user: User +}; + +function databaseSessionStorage(cookie: any) { + return createSessionStorage({ + cookie, + async createData(data) { + const id = `${Date.now()}_${Math.floor(Math.random() * 2 ** 31)}`; + await prisma.session.create({ + data: { + id, + data + } + }); + return id; + }, + async readData(id) { + return await prisma.session.findUnique({ + where: { id } + }) + }, + async updateData(id, data) { + await prisma.session.update({ + where: { id }, + data: { + data + } + }); + }, + async deleteData(id) { + await prisma.session.delete({ + where: { id } + }); + }, + }); +} + +const sessionSecret = process.env.NODE_ENV === "production" ? process.env.SESSION_SECRET : "dev"; +if (!sessionSecret) { + throw new Error("SESSION_SECRET must be set in prod"); +} + +export const sessionStorage = databaseSessionStorage({ + cookie: { + name: "BVS_session", + secure: process.env.NODE_ENV === "production", + secrets: [sessionSecret], + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, + httpOnly: true, + }, +}); + +export const { getSession, commitSession, destroySession } = sessionStorage; diff --git a/app/styles/global-wide.css b/app/styles/global-wide.css index 5e5ba4c..d0043b1 100644 --- a/app/styles/global-wide.css +++ b/app/styles/global-wide.css @@ -24,10 +24,11 @@ header.bvs-header > nav a { padding: 0.8rem var(--edge-padding); text-decoration: none; font-weight: bold; + border-bottom: 0.2rem solid transparent; } header.bvs-header > nav a.active { - border-bottom: 0.2rem solid; + border-bottom-color: white; } footer.bvs-footer { diff --git a/package-lock.json b/package-lock.json index 592daa9..2375aa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@remix-run/react": "^2.9.1", "@vercel/remix": "^2.9.1", "@vercel/speed-insights": "^1.0.10", + "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cross-env": "^7.0.3", "express": "^4.19.2", @@ -21,12 +22,15 @@ "morgan": "^1.10.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "remix-auth": "^3.6.0", + "remix-auth-form": "^1.4.0", "source-map-support": "^0.5.21" }, "devDependencies": { "@playwright/test": "^1.44.0", "@remix-run/dev": "^2.9.1", "@remix-run/eslint-config": "^2.9.1", + "@types/bcryptjs": "^2.4.6", "@types/compression": "^1.7.2", "@types/express": "^4.17.17", "@types/morgan": "^1.9.4", @@ -2340,6 +2344,12 @@ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -3849,6 +3859,11 @@ "node": ">= 0.8" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -10028,6 +10043,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remix-auth": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/remix-auth/-/remix-auth-3.6.0.tgz", + "integrity": "sha512-mxlzLYi+/GKQSaXIqIw15dxAT1wm+93REAeDIft2unrKDYnjaGhhpapyPhdbALln86wt9lNAk21znfRss3fG7Q==", + "dependencies": { + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@remix-run/react": "^1.0.0 || ^2.0.0", + "@remix-run/server-runtime": "^1.0.0 || ^2.0.0" + } + }, + "node_modules/remix-auth-form": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/remix-auth-form/-/remix-auth-form-1.4.0.tgz", + "integrity": "sha512-PirsVtv2AbJ7Lg+OjE+rjlW9AnkNYmqfmNIqTg0Mh1wur22ls5hxf2icVXVCRRhpcpV+FyoDxh03LtIyRj646A==", + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0 || ^2.0.0", + "remix-auth": "^3.6.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -11532,6 +11568,14 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/uvu": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", diff --git a/package.json b/package.json index d555ee3..30200e7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@remix-run/react": "^2.9.1", "@vercel/remix": "^2.9.1", "@vercel/speed-insights": "^1.0.10", + "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cross-env": "^7.0.3", "express": "^4.19.2", @@ -39,12 +40,15 @@ "morgan": "^1.10.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "remix-auth": "^3.6.0", + "remix-auth-form": "^1.4.0", "source-map-support": "^0.5.21" }, "devDependencies": { "@playwright/test": "^1.44.0", "@remix-run/dev": "^2.9.1", "@remix-run/eslint-config": "^2.9.1", + "@types/bcryptjs": "^2.4.6", "@types/compression": "^1.7.2", "@types/express": "^4.17.17", "@types/morgan": "^1.9.4", diff --git a/prisma/migrations/20240321005217_auth/migration.sql b/prisma/migrations/20240321005217_auth/migration.sql new file mode 100644 index 0000000..45806cd --- /dev/null +++ b/prisma/migrations/20240321005217_auth/migration.sql @@ -0,0 +1,24 @@ +-- CreateEnum +CREATE TYPE "CredType" AS ENUM ('USERNAME_PASSWORD'); + +-- CreateTable +CREATE TABLE "Cred" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "type" "CredType" NOT NULL, + "cred" JSONB NOT NULL, + "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Cred_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "data" JSONB NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Cred" ADD CONSTRAINT "Cred_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a153a9..28af809 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,7 @@ model User { managingRoles Role[] @relation("roleManager") permissionSets UserPermissionSet[] positions Position[] + Cred Cred[] } enum UserPermissionSet { @@ -31,6 +32,25 @@ enum UserPermissionSet { CREATE_EVENT } +model Cred { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + type CredType + cred Json + created DateTime @default(now()) +} + +enum CredType { + USERNAME_PASSWORD +} + +/// A session on the website +model Session { + id String @id + data Json +} + model Role { id Int @id @default(autoincrement()) name String @unique /// For URLs etc. diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts new file mode 100644 index 0000000..a796000 --- /dev/null +++ b/tests/auth.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; + +test.describe("Auth system", () => { + test('has actual tests', async ({ page }) => { + await expect(false).toBeTruthy(); + }); + + test("redirects unauthenticated requests to My Schedule", async ({ page }) => { + await page.goto("/my-schedule"); + await expect(page).toHaveURL("/login?next=/my-schedule"); + }); +});