diff options
| author | nsfisis <nsfisis@gmail.com> | 2024-07-28 00:08:12 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2024-07-28 00:45:19 +0900 |
| commit | 519008c4bae3db046004e4bc2aaa23a2e66311c7 (patch) | |
| tree | 4674b807c4e32efd9919e6ab90019e5b68a84d19 | |
| parent | 3ca36032c7aabbc7b8cee8f0f59d18d1264242ae (diff) | |
| download | phperkaigi-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.tsx | 22 | ||||
| -rw-r--r-- | frontend/app/routes/login.tsx | 31 | ||||
| -rw-r--r-- | frontend/app/services/auth.server.ts | 141 | ||||
| -rw-r--r-- | frontend/app/services/session.server.ts | 13 | ||||
| -rw-r--r-- | frontend/package-lock.json | 40 | ||||
| -rw-r--r-- | frontend/package.json | 3 |
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": { |
