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 (
+
+ );
+}
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");
+ });
+});