implement session/auth token login

This commit is contained in:
2025-05-15 14:48:52 +01:00
parent 37cdf30159
commit f048dee6e2
5 changed files with 140 additions and 41 deletions

View File

@@ -0,0 +1,6 @@
import { db } from "~/database.ts"
function deleteAuthTokenById(id: string) {
const query = db.query<void, { id: string }>("DELETE FROM auth_tokens WHERE id = $id")
query.run({ id })
}

View File

@@ -29,27 +29,21 @@ function authenticated<Route extends string>(
throw new HttpError(401) throw new HttpError(401)
} }
const user = findUserById(session.userId)
if (!user) {
throw new HttpError(401)
}
const authTokenCookie = request.cookies.get("auth-token") const authTokenCookie = request.cookies.get("auth-token")
if (authTokenCookie) { if (authTokenCookie) {
const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id") 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 <token-id>:<token-signature> // biome-ignore lint/style/noNonNullAssertion: the cookie has already been verified by verifySession previously, therefore the cookie must be in the correct format <token-id>:<token-signature>
const tokenId = authTokenCookie.split(":")[0]! const tokenId = authTokenCookie.split(":")[0]!
deleteAuthTokenQuery.run({ id: tokenId }) 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) const extendedSession = extendSession(session)
saveSession(extendedSession, request.cookies) 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<User | null> {
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<void, { id: string }>("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">) { async function signUp(request: Bun.BunRequest<"/api/sign-up">) {
const body = await request.json().catch(() => { const body = await request.json().catch(() => {
throw new HttpError(500) 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">) { 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(() => { const body = await request.json().catch(() => {
throw new HttpError(500) throw new HttpError(401)
}) })
const loginRequest = LoginRequest(body) const loginRequest = LoginRequest(body)
@@ -127,7 +179,7 @@ async function login(request: Bun.BunRequest<"/api/login">) {
throw new HttpError(401) throw new HttpError(401)
} }
const user: User = { const user = {
id: foundUser.id, id: foundUser.id,
username: foundUser.username, username: foundUser.username,
} }

View File

@@ -1,13 +1,11 @@
import dayjs from "dayjs" import dayjs from "dayjs"
import uid from "uid-safe" import uid from "uid-safe"
import { db } from "~/database.ts" 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 { interface Session {
id: string id: string
signedId: string user: User
userId: string
durationMs: number
expiresAt: number expiresAt: number
} }
@@ -32,7 +30,7 @@ function signSessionId(sessionId: string): string {
return `${sessionId}.${hasher.digest("base64url")}` return `${sessionId}.${hasher.digest("base64url")}`
} }
async function createSessionForUser(user: User, cookies: Bun.CookieMap) { async function createSessionForUser(user: User, cookies: Bun.CookieMap): Promise<Session> {
const sessionId = await newSessionId() const sessionId = await newSessionId()
const signedSessionId = signSessionId(sessionId) const signedSessionId = signSessionId(sessionId)
const expiryDate = dayjs().add(30, "minutes").valueOf() 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) console.log("session created for user", user.id)
return {
user,
id: sessionId,
expiresAt: expiryDate,
}
} }
async function saveSession(session: Session, cookies: Bun.CookieMap) { 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, maxAge: SESSION_DURATION_MS,
path: "/api", path: "/api",
httpOnly: true, httpOnly: true,
@@ -82,28 +87,36 @@ function verifySession(cookie: Bun.CookieMap): Session | null {
return 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 }) const row = findSessionQuery.get({ sessionId: value })
if (!row) { if (!row) {
console.log("no row") console.log("no row")
return null return null
} }
const foundSession = row as { user_id: string; expires_at_unix_ms: number }
const now = dayjs().valueOf() 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") const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId")
deleteSessionQuery.run({ sessionId: value }) deleteSessionQuery.run({ sessionId: value })
console.log("session expired!") console.log("session expired!")
return null 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 { return {
id: value, user,
signedId: signedSessionId, id: row.session_id,
userId: foundSession.user_id, expiresAt: row.expires_at_unix_ms,
expiresAt: foundSession.expires_at_unix_ms,
durationMs: SESSION_DURATION_MS,
} }
} }
@@ -140,3 +153,4 @@ export {
extendSession, extendSession,
forgetAllSessions, forgetAllSessions,
} }
export type { Session }

View File

@@ -1,14 +1,20 @@
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { useState } from "react" import { useEffect, useState } from "react"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { FormField } from "~/components/form-field" import { FormField } from "~/components/form-field"
import { Button } from "~/components/button" import { Button } from "~/components/button"
import { useLogin } from "~/auth" import { useCookieLogin, useLogin } from "~/auth"
import { LoadingSpinner } from "~/components/loading-spinner"
function Page() { function Page() {
const [error, setError] = useState("") const [error, setError] = useState("")
const navigate = useNavigate() const navigate = useNavigate()
const loginMutation = useLogin() const loginMutation = useLogin()
const cookieLoginMutation = useCookieLogin()
useEffect(() => {
cookieLoginMutation.mutate()
}, [])
async function submitLoginForm(event: React.FormEvent<HTMLFormElement>) { async function submitLoginForm(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
@@ -57,19 +63,27 @@ function Page() {
</h1> </h1>
</div> </div>
<div className="container max-w-2xl"> <div className="container max-w-2xl">
<h2 className="font-bold">LOG IN TO YOUR ACCOUNT</h2> {cookieLoginMutation.status === "pending" ? (
{error ? ( <p>
<div className="border border-red-500 text-red-500 p-3 my-4"> Checking existing login <LoadingSpinner />
<p className="text-red-500 font-bold">{error}</p> </p>
</div> ) : (
) : null} <>
<form onSubmit={submitLoginForm}> <h2 className="font-bold">LOG IN TO YOUR ACCOUNT</h2>
<FormField type="text" name="username" label="USERNAME" className="mb-2" /> {error ? (
<FormField type="password" name="password" label="PASSWORD" className="mb-8" /> <div className="border border-red-500 text-red-500 p-3 my-4">
<Button className="w-full py-2 md:py-2" type="submit"> <p className="text-red-500 font-bold">{error}</p>
Log in </div>
</Button> ) : null}
</form> <form onSubmit={submitLoginForm}>
<FormField type="text" name="username" label="USERNAME" className="mb-2" />
<FormField type="password" name="password" label="PASSWORD" className="mb-8" />
<Button className="w-full py-2 md:py-2" type="submit">
Log in
</Button>
</form>
</>
)}
</div> </div>
</main> </main>
) )

View File

@@ -1,5 +1,18 @@
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { fetchApi } from "./api" 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() { function useLogin() {
return useMutation({ return useMutation({
@@ -33,4 +46,4 @@ function useSignUp() {
}) })
} }
export { useLogin, useLogOut, useSignUp } export { useCookieLogin, useLogin, useLogOut, useSignUp }