diff options
| author | nsfisis <nsfisis@gmail.com> | 2024-08-04 20:33:37 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2024-08-04 20:33:37 +0900 |
| commit | 0f0324b396f3eab53606c8f770d26337dd0e291a (patch) | |
| tree | 0a2afd4701535b11c81fb3908d8c241eaaeb7d21 | |
| parent | d87507918f33b289ac4fc4dece8a54fa3aa34923 (diff) | |
| download | phperkaigi-2025-albatross-0f0324b396f3eab53606c8f770d26337dd0e291a.tar.gz phperkaigi-2025-albatross-0f0324b396f3eab53606c8f770d26337dd0e291a.tar.zst phperkaigi-2025-albatross-0f0324b396f3eab53606c8f770d26337dd0e291a.zip | |
feat: authenticate users in admin pages
| -rw-r--r-- | backend/admin/handlers.go | 21 | ||||
| -rw-r--r-- | backend/main.go | 8 | ||||
| -rw-r--r-- | frontend/app/.server/auth.ts | 35 | ||||
| -rw-r--r-- | frontend/app/.server/cookie.ts | 41 | ||||
| -rw-r--r-- | frontend/app/.server/session.ts | 16 | ||||
| -rw-r--r-- | frontend/package-lock.json | 1 | ||||
| -rw-r--r-- | frontend/package.json | 1 |
7 files changed, 110 insertions, 13 deletions
diff --git a/backend/admin/handlers.go b/backend/admin/handlers.go index f81856c..14523e6 100644 --- a/backend/admin/handlers.go +++ b/backend/admin/handlers.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" + "github.com/nsfisis/iosdc-japan-2024-albatross/backend/auth" "github.com/nsfisis/iosdc-japan-2024-albatross/backend/db" ) @@ -31,8 +32,28 @@ func NewAdminHandler(q *db.Queries, hubs GameHubsInterface) *AdminHandler { } } +func newAdminMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + jwt, err := c.Cookie("albatross_token") + if err != nil { + return c.Redirect(http.StatusSeeOther, "/login") + } + claims, err := auth.ParseJWT(jwt.Value) + if err != nil { + return c.Redirect(http.StatusSeeOther, "/login") + } + if !claims.IsAdmin { + return echo.NewHTTPError(http.StatusForbidden) + } + return next(c) + } + } +} + func (h *AdminHandler) RegisterHandlers(g *echo.Group) { g.Use(newAssetsMiddleware()) + g.Use(newAdminMiddleware()) g.GET("/dashboard", h.getDashboard) g.GET("/users", h.getUsers) diff --git a/backend/main.go b/backend/main.go index 2d38ee5..e2e4bbd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -83,9 +83,11 @@ func main() { adminGroup := e.Group("/admin") adminHandler.RegisterHandlers(adminGroup) - // For local dev: - // This is never used in production because the reverse proxy sends /logout - // to the app server. + // For local dev: This is never used in production because the reverse + // proxy sends /login and /logout to the app server. + e.GET("/login", func(c echo.Context) error { + return c.Redirect(http.StatusPermanentRedirect, "http://localhost:5173/login") + }) e.POST("/logout", func(c echo.Context) error { return c.Redirect(http.StatusPermanentRedirect, "http://localhost:5173/logout") }) diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts index d5ffe0f..2c9d23c 100644 --- a/frontend/app/.server/auth.ts +++ b/frontend/app/.server/auth.ts @@ -1,10 +1,12 @@ +import { redirect } from "@remix-run/node"; import type { Session } from "@remix-run/server-runtime"; import { jwtDecode } from "jwt-decode"; import { Authenticator } from "remix-auth"; import { FormStrategy } from "remix-auth-form"; import { apiPostLogin } from "./api/client"; import { components } from "./api/schema"; -import { sessionStorage } from "./session"; +import { createUnstructuredCookie } from "./cookie"; +import { cookieOptions, sessionStorage } from "./session"; const authenticator = new Authenticator<string>(sessionStorage); @@ -19,15 +21,40 @@ authenticator.use( export type User = components["schemas"]["User"]; +// This cookie is used to directly store the JWT for the API server. +// Remix's createCookie() returns "structured" cookies, which cannot be reused directly by non-Remix servers. +const tokenCookie = createUnstructuredCookie("albatross_token", cookieOptions); + export async function login(request: Request): Promise<never> { - return await authenticator.authenticate("default", request, { - successRedirect: "/dashboard", + const jwt = await authenticator.authenticate("default", request, { failureRedirect: "/login", }); + + const session = await sessionStorage.getSession( + request.headers.get("cookie"), + ); + session.set(authenticator.sessionKey, jwt); + + throw redirect("/dashboard", { + headers: [ + ["Set-Cookie", await sessionStorage.commitSession(session)], + ["Set-Cookie", await tokenCookie.serialize(jwt)], + ], + }); } export async function logout(request: Request | Session): Promise<never> { - return await authenticator.logout(request, { redirectTo: "/" }); + try { + return await authenticator.logout(request, { redirectTo: "/" }); + } catch (response) { + if (response instanceof Response) { + response.headers.append( + "Set-Cookie", + await tokenCookie.serialize("", { maxAge: 0, expires: new Date(0) }), + ); + } + throw response; + } } export async function ensureUserLoggedIn( diff --git a/frontend/app/.server/cookie.ts b/frontend/app/.server/cookie.ts new file mode 100644 index 0000000..cccbe78 --- /dev/null +++ b/frontend/app/.server/cookie.ts @@ -0,0 +1,41 @@ +import { Cookie, CookieOptions } from "@remix-run/server-runtime"; +import { parse, serialize } from "cookie"; + +// Remix's createCookie() returns "structured" cookies, which are cookies that hold a JSON-encoded object. +// This is not suitable for interoperation with other systems that expect a simple string value. +// This function creates an "unstructured" cookie, a simple plain text. +export function createUnstructuredCookie( + name: string, + cookieOptions?: CookieOptions, +): Cookie { + const { secrets = [], ...options } = { + path: "/", + sameSite: "lax" as const, + ...cookieOptions, + }; + + return { + get name() { + return name; + }, + get isSigned() { + return secrets.length > 0; + }, + get expires() { + return typeof options.maxAge !== "undefined" + ? new Date(Date.now() + options.maxAge * 1000) + : options.expires; + }, + async parse(cookieHeader, parseOptions) { + if (!cookieHeader) return null; + const cookies = parse(cookieHeader, { ...options, ...parseOptions }); + return name in cookies ? cookies[name] : null; + }, + async serialize(value, serializeOptions) { + return serialize(name, value, { + ...options, + ...serializeOptions, + }); + }, + }; +} diff --git a/frontend/app/.server/session.ts b/frontend/app/.server/session.ts index 79810f4..102bcd2 100644 --- a/frontend/app/.server/session.ts +++ b/frontend/app/.server/session.ts @@ -1,13 +1,17 @@ import { createCookieSessionStorage } from "@remix-run/node"; +export const cookieOptions = { + sameSite: "lax" as const, + path: "/", + httpOnly: true, + // secure: process.env.NODE_ENV === "production", + secure: false, // TODO + secrets: ["TODO"], +}; + export const sessionStorage = createCookieSessionStorage({ cookie: { name: "albatross_session", - sameSite: "lax", - path: "/", - httpOnly: true, - secrets: ["TODO"], - // secure: process.env.NODE_ENV === "production", - secure: false, // TODO + ...cookieOptions, }, }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a49235c..0e6f7bf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "@remix-run/node": "^2.10.3", "@remix-run/react": "^2.10.3", "@remix-run/serve": "^2.10.3", + "cookie": "^0.6.0", "isbot": "^5.1.13", "jwt-decode": "^4.0.0", "openapi-fetch": "^0.10.2", diff --git a/frontend/package.json b/frontend/package.json index 30d385a..e4eefac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@remix-run/node": "^2.10.3", "@remix-run/react": "^2.10.3", "@remix-run/serve": "^2.10.3", + "cookie": "^0.6.0", "isbot": "^5.1.13", "jwt-decode": "^4.0.0", "openapi-fetch": "^0.10.2", |
