aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2024-07-28 00:08:12 +0900
committernsfisis <nsfisis@gmail.com>2024-07-28 00:45:19 +0900
commit519008c4bae3db046004e4bc2aaa23a2e66311c7 (patch)
tree4674b807c4e32efd9919e6ab90019e5b68a84d19
parent3ca36032c7aabbc7b8cee8f0f59d18d1264242ae (diff)
downloadphperkaigi-2025-albatross-519008c4bae3db046004e4bc2aaa23a2e66311c7.tar.gz
phperkaigi-2025-albatross-519008c4bae3db046004e4bc2aaa23a2e66311c7.tar.zst
phperkaigi-2025-albatross-519008c4bae3db046004e4bc2aaa23a2e66311c7.zip
frontend: jwt
-rw-r--r--frontend/app/routes/dashboard.tsx22
-rw-r--r--frontend/app/routes/login.tsx31
-rw-r--r--frontend/app/services/auth.server.ts141
-rw-r--r--frontend/app/services/session.server.ts13
-rw-r--r--frontend/package-lock.json40
-rw-r--r--frontend/package.json3
6 files changed, 250 insertions, 0 deletions
diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/routes/dashboard.tsx
new file mode 100644
index 0000000..be274eb
--- /dev/null
+++ b/frontend/app/routes/dashboard.tsx
@@ -0,0 +1,22 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { isAuthenticated } from "../services/auth.server";
+import { useLoaderData } from "@remix-run/react";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ return await isAuthenticated(request, {
+ failureRedirect: "/login",
+ });
+}
+
+export default function Dashboard() {
+ const user = useLoaderData<typeof loader>()!;
+
+ return (
+ <div>
+ <h1>
+ #{user.userId} {user.displayUsername} (@{user.username})
+ </h1>
+ {user.isAdmin && <p>Admin</p>}
+ </div>
+ );
+}
diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx
new file mode 100644
index 0000000..cf5be14
--- /dev/null
+++ b/frontend/app/routes/login.tsx
@@ -0,0 +1,31 @@
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
+import { Form } from "@remix-run/react";
+import { authenticator } from "../services/auth.server";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ return await authenticator.isAuthenticated(request, {
+ successRedirect: "/dashboard",
+ });
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ return await authenticator.authenticate("default", request, {
+ successRedirect: "/dashboard",
+ failureRedirect: "/login",
+ });
+}
+
+export default function Login() {
+ return (
+ <Form method="post">
+ <input type="username" name="username" required />
+ <input
+ type="password"
+ name="password"
+ autoComplete="current-password"
+ required
+ />
+ <button>Log In</button>
+ </Form>
+ );
+}
diff --git a/frontend/app/services/auth.server.ts b/frontend/app/services/auth.server.ts
new file mode 100644
index 0000000..144a7cd
--- /dev/null
+++ b/frontend/app/services/auth.server.ts
@@ -0,0 +1,141 @@
+import { Authenticator } from "remix-auth";
+import { FormStrategy } from "remix-auth-form";
+import { sessionStorage } from "./session.server";
+import { jwtDecode } from "jwt-decode";
+import type { Session } from "@remix-run/server-runtime";
+
+export const authenticator = new Authenticator<string>(sessionStorage);
+
+async function login(username: string, password: string): Promise<string> {
+ const res = await fetch(`http://api-server/api/login`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!res.ok) {
+ throw new Error("Invalid username or password");
+ }
+ const user = await res.json();
+ return user.token;
+}
+
+authenticator.use(
+ new FormStrategy(async ({ form }) => {
+ const username = String(form.get("username"));
+ const password = String(form.get("password"));
+ return await login(username, password);
+ }),
+ "default",
+);
+
+type JwtPayload = {
+ user_id: number;
+ username: string;
+ display_username: string;
+ icon_path: string | null;
+ is_admin: boolean;
+};
+
+export type User = {
+ userId: number;
+ username: string;
+ displayUsername: string;
+ iconPath: string | null;
+ isAdmin: boolean;
+};
+
+export async function isAuthenticated(
+ request: Request | Session,
+ options?: {
+ successRedirect?: never;
+ failureRedirect?: never;
+ headers?: never;
+ },
+): Promise<User | null>;
+export async function isAuthenticated(
+ request: Request | Session,
+ options: {
+ successRedirect: string;
+ failureRedirect?: never;
+ headers?: HeadersInit;
+ },
+): Promise<null>;
+export async function isAuthenticated(
+ request: Request | Session,
+ options: {
+ successRedirect?: never;
+ failureRedirect: string;
+ headers?: HeadersInit;
+ },
+): Promise<User>;
+export async function isAuthenticated(
+ request: Request | Session,
+ options: {
+ successRedirect: string;
+ failureRedirect: string;
+ headers?: HeadersInit;
+ },
+): Promise<null>;
+export async function isAuthenticated(
+ request: Request | Session,
+ options:
+ | {
+ successRedirect?: never;
+ failureRedirect?: never;
+ headers?: never;
+ }
+ | {
+ successRedirect: string;
+ failureRedirect?: never;
+ headers?: HeadersInit;
+ }
+ | {
+ successRedirect?: never;
+ failureRedirect: string;
+ headers?: HeadersInit;
+ }
+ | {
+ successRedirect: string;
+ failureRedirect: string;
+ headers?: HeadersInit;
+ } = {},
+): Promise<User | null> {
+ let jwt;
+
+ // This function's signature should be compatible with `authenticator.isAuthenticated` but TypeScript does not infer it correctly.
+ const { successRedirect, failureRedirect, headers } = options;
+ if (successRedirect && failureRedirect) {
+ jwt = await authenticator.isAuthenticated(request, {
+ successRedirect,
+ failureRedirect,
+ headers,
+ });
+ } else if (!successRedirect && failureRedirect) {
+ jwt = await authenticator.isAuthenticated(request, {
+ failureRedirect,
+ headers,
+ });
+ } else if (successRedirect && !failureRedirect) {
+ jwt = await authenticator.isAuthenticated(request, {
+ successRedirect,
+ headers,
+ });
+ } else {
+ jwt = await authenticator.isAuthenticated(request);
+ }
+
+ if (!jwt) {
+ return null;
+ }
+ // TODO: runtime type check
+ const payload = jwtDecode<JwtPayload>(jwt);
+ return {
+ userId: payload.user_id,
+ username: payload.username,
+ displayUsername: payload.display_username,
+ iconPath: payload.icon_path,
+ isAdmin: payload.is_admin,
+ };
+}
diff --git a/frontend/app/services/session.server.ts b/frontend/app/services/session.server.ts
new file mode 100644
index 0000000..2000853
--- /dev/null
+++ b/frontend/app/services/session.server.ts
@@ -0,0 +1,13 @@
+import { createCookieSessionStorage } from "@remix-run/node";
+
+export const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ name: "albatross_session",
+ sameSite: "lax",
+ path: "/",
+ httpOnly: true,
+ secrets: ["TODO"],
+ // secure: process.env.NODE_ENV === "production",
+ secure: false, // TODO
+ },
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 615214d..2186d40 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,9 +10,12 @@
"@remix-run/react": "^2.10.3",
"@remix-run/serve": "^2.10.3",
"isbot": "^5.1.13",
+ "jwt-decode": "^4.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-use-websocket": "^4.8.1",
+ "remix-auth": "^3.7.0",
+ "remix-auth-form": "^1.5.0",
"use-debounce": "^10.0.1"
},
"devDependencies": {
@@ -6268,6 +6271,14 @@
"node": ">=4.0"
}
},
+ "node_modules/jwt-decode": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
+ "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -8931,6 +8942,27 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remix-auth": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/remix-auth/-/remix-auth-3.7.0.tgz",
+ "integrity": "sha512-2QVjp2nJVaYxuFBecMQwzixCO7CLSssttLBU5eVlNcNlVeNMmY1g7OkmZ1Ogw9sBcoMXZ18J7xXSK0AISVFcfQ==",
+ "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.5.0",
+ "resolved": "https://registry.npmjs.org/remix-auth-form/-/remix-auth-form-1.5.0.tgz",
+ "integrity": "sha512-xWM7T41vi4ZsIxL3f8gz/D6g2mxrnYF7LnG+rG3VqwHh6l13xCoKLraxzWRdbKMVKKQCMISKZRXAeJh9/PQwBA==",
+ "peerDependencies": {
+ "@remix-run/server-runtime": "^1.0.0 || ^2.0.0",
+ "remix-auth": "^3.6.0"
+ }
+ },
"node_modules/require-like": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz",
@@ -10552,6 +10584,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/frontend/package.json b/frontend/package.json
index e215f0c..c3ac2f5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,9 +15,12 @@
"@remix-run/react": "^2.10.3",
"@remix-run/serve": "^2.10.3",
"isbot": "^5.1.13",
+ "jwt-decode": "^4.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-use-websocket": "^4.8.1",
+ "remix-auth": "^3.7.0",
+ "remix-auth-form": "^1.5.0",
"use-debounce": "^10.0.1"
},
"devDependencies": {