implement session/auth token login
This commit is contained in:
6
packages/server/src/auth/auth-token.ts
Normal file
6
packages/server/src/auth/auth-token.ts
Normal 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 })
|
||||
}
|
@@ -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,
|
||||
}
|
||||
|
@@ -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 }
|
||||
|
@@ -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>
|
||||
)
|
||||
|
@@ -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 }
|
||||
|
Reference in New Issue
Block a user