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)
}
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 <token-id>:<token-signature>
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<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">) {
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,
}

View File

@@ -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<Session> {
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 }

View File

@@ -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<HTMLFormElement>) {
event.preventDefault()
@@ -57,19 +63,27 @@ function Page() {
</h1>
</div>
<div className="container max-w-2xl">
<h2 className="font-bold">LOG IN TO YOUR ACCOUNT</h2>
{error ? (
<div className="border border-red-500 text-red-500 p-3 my-4">
<p className="text-red-500 font-bold">{error}</p>
</div>
) : null}
<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>
{cookieLoginMutation.status === "pending" ? (
<p>
Checking existing login <LoadingSpinner />
</p>
) : (
<>
<h2 className="font-bold">LOG IN TO YOUR ACCOUNT</h2>
{error ? (
<div className="border border-red-500 text-red-500 p-3 my-4">
<p className="text-red-500 font-bold">{error}</p>
</div>
) : null}
<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>
</main>
)

View File

@@ -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 }