From f048dee6e2c45965636026439a5d8771ba2c06e0 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Thu, 15 May 2025 14:48:52 +0100 Subject: [PATCH] implement session/auth token login --- packages/server/src/auth/auth-token.ts | 6 +++ packages/server/src/auth/auth.ts | 74 ++++++++++++++++++++++---- packages/server/src/auth/session.ts | 42 ++++++++++----- packages/web/src/app/login.tsx | 44 +++++++++------ packages/web/src/auth.ts | 15 +++++- 5 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 packages/server/src/auth/auth-token.ts diff --git a/packages/server/src/auth/auth-token.ts b/packages/server/src/auth/auth-token.ts new file mode 100644 index 0000000..7e02e30 --- /dev/null +++ b/packages/server/src/auth/auth-token.ts @@ -0,0 +1,6 @@ +import { db } from "~/database.ts" + +function deleteAuthTokenById(id: string) { + const query = db.query("DELETE FROM auth_tokens WHERE id = $id") + query.run({ id }) +} diff --git a/packages/server/src/auth/auth.ts b/packages/server/src/auth/auth.ts index be5c407..0f75298 100644 --- a/packages/server/src/auth/auth.ts +++ b/packages/server/src/auth/auth.ts @@ -29,27 +29,21 @@ function authenticated( throw new HttpError(401) } - const user = findUserById(session.userId) - if (!user) { - throw new HttpError(401) - } - const authTokenCookie = request.cookies.get("auth-token") if (authTokenCookie) { const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id") - // biome-ignore lint/style/noNonNullAssertion: the cookie has already been verified by verifySession previously, therefore the cookie must be in the correct format : const tokenId = authTokenCookie.split(":")[0]! deleteAuthTokenQuery.run({ id: tokenId }) - rememberLoginForUser(user, request.cookies) + rememberLoginForUser(session.user, request.cookies) } - if (user.id !== DEMO_USER.id) { + if (session.user.id !== DEMO_USER.id) { const extendedSession = extendSession(session) saveSession(extendedSession, request.cookies) } - return handler(request, user) + return handler(request, session.user) }) } @@ -83,6 +77,48 @@ function rememberLoginForUser(user: User, cookies: Bun.CookieMap) { }) } +async function verifyAuthToken(cookies: Bun.CookieMap): Promise { + const authTokenCookie = cookies.get("auth-token") + if (!authTokenCookie) { + return null + } + + const [tokenId, providedTokenEncoded] = authTokenCookie.split(":") + if (!tokenId || !providedTokenEncoded) { + return null + } + + const findAuthTokenQuery = db.query< + { id: string; token: string; user_id: string; expires_at_unix_ms: number }, + { id: string } + >("SELECT id, token, user_id, expires_at_unix_ms FROM auth_tokens WHERE id = $id") + const result = findAuthTokenQuery.get({ id: tokenId }) + if (!result) { + return null + } + + const providedToken = Buffer.from(providedTokenEncoded, "base64url") + + const hasher = new Bun.CryptoHasher("sha256") + hasher.update(providedToken) + const hashedProvidedToken = hasher.digest() + + const storedToken = Buffer.from(result.token, "base64url") + + if (!crypto.timingSafeEqual(hashedProvidedToken, storedToken)) { + return null + } + + const user = findUserById(result.user_id) + if (!user) { + const query = db.query("DELETE FROM auth_tokens WHERE id = $id") + query.run({ id: result.id }) + return null + } + + return user +} + async function signUp(request: Bun.BunRequest<"/api/sign-up">) { const body = await request.json().catch(() => { throw new HttpError(500) @@ -104,8 +140,24 @@ async function signUp(request: Bun.BunRequest<"/api/sign-up">) { } async function login(request: Bun.BunRequest<"/api/login">) { + // first, check if there is an existing session + const session = verifySession(request.cookies) + if (session) { + extendSession(session) + return Response.json(session, { status: 200 }) + } + + // then, check if there is a valid auth token + { + const foundUser = await verifyAuthToken(request.cookies) + if (foundUser) { + await createSessionForUser(foundUser, request.cookies) + return Response.json(foundUser, { status: 200 }) + } + } + const body = await request.json().catch(() => { - throw new HttpError(500) + throw new HttpError(401) }) const loginRequest = LoginRequest(body) @@ -127,7 +179,7 @@ async function login(request: Bun.BunRequest<"/api/login">) { throw new HttpError(401) } - const user: User = { + const user = { id: foundUser.id, username: foundUser.username, } diff --git a/packages/server/src/auth/session.ts b/packages/server/src/auth/session.ts index 0d5f287..7043ca7 100644 --- a/packages/server/src/auth/session.ts +++ b/packages/server/src/auth/session.ts @@ -1,13 +1,11 @@ import dayjs from "dayjs" import uid from "uid-safe" import { db } from "~/database.ts" -import { type User, DEMO_USER } from "~/user/user.ts" +import { type User, DEMO_USER, findUserById } from "~/user/user.ts" interface Session { id: string - signedId: string - userId: string - durationMs: number + user: User expiresAt: number } @@ -32,7 +30,7 @@ function signSessionId(sessionId: string): string { return `${sessionId}.${hasher.digest("base64url")}` } -async function createSessionForUser(user: User, cookies: Bun.CookieMap) { +async function createSessionForUser(user: User, cookies: Bun.CookieMap): Promise { const sessionId = await newSessionId() const signedSessionId = signSessionId(sessionId) const expiryDate = dayjs().add(30, "minutes").valueOf() @@ -53,10 +51,17 @@ async function createSessionForUser(user: User, cookies: Bun.CookieMap) { }) console.log("session created for user", user.id) + + return { + user, + id: sessionId, + expiresAt: expiryDate, + } } async function saveSession(session: Session, cookies: Bun.CookieMap) { - cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, { + const signed = signSessionId(session.id) + cookies.set(SESSION_ID_COOKIE_NAME, signed, { maxAge: SESSION_DURATION_MS, path: "/api", httpOnly: true, @@ -82,28 +87,36 @@ function verifySession(cookie: Bun.CookieMap): Session | null { return null } - const findSessionQuery = db.query("SELECT user_id, expires_at_unix_ms FROM sessions WHERE session_id = $sessionId") + const findSessionQuery = db.query< + { session_id: string; user_id: string; expires_at_unix_ms: number }, + { sessionId: string } + >("SELECT session_id, user_id, expires_at_unix_ms FROM sessions WHERE session_id = $sessionId") const row = findSessionQuery.get({ sessionId: value }) if (!row) { console.log("no row") return null } - const foundSession = row as { user_id: string; expires_at_unix_ms: number } const now = dayjs().valueOf() - if (now > foundSession.expires_at_unix_ms) { + if (now > row.expires_at_unix_ms) { const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId") deleteSessionQuery.run({ sessionId: value }) console.log("session expired!") return null } + const user = findUserById(row.user_id) + if (!user) { + const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId") + deleteSessionQuery.run({ sessionId: value }) + console.log("user no longer exists!") + return null + } + return { - id: value, - signedId: signedSessionId, - userId: foundSession.user_id, - expiresAt: foundSession.expires_at_unix_ms, - durationMs: SESSION_DURATION_MS, + user, + id: row.session_id, + expiresAt: row.expires_at_unix_ms, } } @@ -140,3 +153,4 @@ export { extendSession, forgetAllSessions, } +export type { Session } diff --git a/packages/web/src/app/login.tsx b/packages/web/src/app/login.tsx index 91207a2..e43966a 100644 --- a/packages/web/src/app/login.tsx +++ b/packages/web/src/app/login.tsx @@ -1,14 +1,20 @@ import { useNavigate } from "@tanstack/react-router" -import { useState } from "react" +import { useEffect, useState } from "react" import { createFileRoute } from "@tanstack/react-router" import { FormField } from "~/components/form-field" import { Button } from "~/components/button" -import { useLogin } from "~/auth" +import { useCookieLogin, useLogin } from "~/auth" +import { LoadingSpinner } from "~/components/loading-spinner" function Page() { const [error, setError] = useState("") const navigate = useNavigate() const loginMutation = useLogin() + const cookieLoginMutation = useCookieLogin() + + useEffect(() => { + cookieLoginMutation.mutate() + }, []) async function submitLoginForm(event: React.FormEvent) { event.preventDefault() @@ -57,19 +63,27 @@ function Page() {
-

LOG IN TO YOUR ACCOUNT

- {error ? ( -
-

{error}

-
- ) : null} -
- - - - + {cookieLoginMutation.status === "pending" ? ( +

+ Checking existing login +

+ ) : ( + <> +

LOG IN TO YOUR ACCOUNT

+ {error ? ( +
+

{error}

+
+ ) : null} +
+ + + + + + )}
) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index acb5c5f..ed8b537 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -1,5 +1,18 @@ import { useMutation } from "@tanstack/react-query" import { fetchApi } from "./api" +import { useNavigate } from "@tanstack/react-router" + +function useCookieLogin() { + const navigate = useNavigate() + + return useMutation({ + mutationFn: async () => fetchApi("/login", { method: "POST" }), + onSuccess: () => { + navigate({ to: "/bookmarks", replace: true }) + }, + retry: false, + }) +} function useLogin() { return useMutation({ @@ -33,4 +46,4 @@ function useSignUp() { }) } -export { useLogin, useLogOut, useSignUp } +export { useCookieLogin, useLogin, useLogOut, useSignUp }