switch to monorepo structure
This commit is contained in:
148
packages/server/src/auth/auth.ts
Normal file
148
packages/server/src/auth/auth.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { type User, DEMO_USER } from "@markone/core/user"
|
||||
import { type } from "arktype"
|
||||
import dayjs from "dayjs"
|
||||
import { ulid } from "ulid"
|
||||
import { db } from "~/database.ts"
|
||||
import { HttpError } from "~/error.ts"
|
||||
import { httpHandler } from "~/http-handler.ts"
|
||||
import { createUser, findUserById, findUserByUsername } from "~/user/user.ts"
|
||||
import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts"
|
||||
|
||||
const SignUpRequest = type({
|
||||
username: "string",
|
||||
password: "string",
|
||||
})
|
||||
|
||||
const LoginRequest = type({
|
||||
username: "string",
|
||||
password: "string",
|
||||
})
|
||||
|
||||
const createAuthTokenQuery = db.query(
|
||||
"INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)",
|
||||
)
|
||||
|
||||
const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id")
|
||||
|
||||
const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId")
|
||||
|
||||
function authenticated<Route extends string>(
|
||||
handler: (request: Bun.BunRequest<Route>, user: User) => Promise<Response>,
|
||||
) {
|
||||
return httpHandler<Route>((request) => {
|
||||
const session = verifySession(request.cookies)
|
||||
if (!session) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId)
|
||||
if (!user) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
const authTokenCookie = request.cookies.get("auth-token")
|
||||
if (authTokenCookie) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
if (user.id !== DEMO_USER.id) {
|
||||
const extendedSession = extendSession(session)
|
||||
saveSession(extendedSession, request.cookies)
|
||||
}
|
||||
|
||||
return handler(request, user)
|
||||
})
|
||||
}
|
||||
|
||||
function rememberLoginForUser(user: User, cookies: Bun.CookieMap) {
|
||||
const tokenId = ulid()
|
||||
|
||||
const authToken = Buffer.alloc(32)
|
||||
crypto.getRandomValues(authToken)
|
||||
|
||||
const hasher = new Bun.CryptoHasher("sha256")
|
||||
hasher.update(authToken)
|
||||
const hashedToken = hasher.digest("base64url")
|
||||
|
||||
const expiryDate = dayjs().add(1, "month")
|
||||
|
||||
createAuthTokenQuery.run({
|
||||
id: tokenId,
|
||||
token: hashedToken,
|
||||
userId: user.id,
|
||||
expiresAt: expiryDate.valueOf(),
|
||||
})
|
||||
|
||||
cookies.set("auth-token", `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
httpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function signUp(request: Bun.BunRequest<"/api/sign-up">) {
|
||||
const body = await request.json().catch(() => {
|
||||
throw new HttpError(500)
|
||||
})
|
||||
|
||||
const signUpRequest = SignUpRequest(body)
|
||||
if (signUpRequest instanceof type.errors) {
|
||||
throw new HttpError(400, signUpRequest.summary)
|
||||
}
|
||||
|
||||
const { username, password } = signUpRequest
|
||||
const hashedPassword = await Bun.password.hash(password, "argon2id")
|
||||
const user = createUser(username, hashedPassword)
|
||||
|
||||
await createSessionForUser(user, request.cookies)
|
||||
rememberLoginForUser(user, request.cookies)
|
||||
|
||||
return Response.json(user, { status: 200 })
|
||||
}
|
||||
|
||||
async function login(request: Bun.BunRequest<"/api/login">) {
|
||||
const body = await request.json().catch(() => {
|
||||
throw new HttpError(500)
|
||||
})
|
||||
|
||||
const loginRequest = LoginRequest(body)
|
||||
if (loginRequest instanceof type.errors) {
|
||||
throw new HttpError(400, loginRequest.summary)
|
||||
}
|
||||
|
||||
const foundUser = findUserByUsername(loginRequest.username, {
|
||||
password: true,
|
||||
})
|
||||
if (!foundUser) {
|
||||
throw new HttpError(400)
|
||||
}
|
||||
|
||||
const ok = await Bun.password.verify(loginRequest.password, foundUser.password, "argon2id").catch(() => {
|
||||
throw new HttpError(401)
|
||||
})
|
||||
if (!ok) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
const user: User = {
|
||||
id: foundUser.id,
|
||||
username: foundUser.username,
|
||||
}
|
||||
|
||||
if (user.id === DEMO_USER.id) {
|
||||
await createSessionForUser(user, request.cookies)
|
||||
rememberLoginForUser(user, request.cookies)
|
||||
}
|
||||
|
||||
return Response.json(user, { status: 200 })
|
||||
}
|
||||
|
||||
async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise<Response> {
|
||||
forgetAllSessions(user)
|
||||
deleteAllAuthTokensQuery.run({ userId: user.id })
|
||||
return new Response(undefined, { status: 200 })
|
||||
}
|
||||
|
||||
export { authenticated, signUp, login, logout }
|
135
packages/server/src/auth/session.ts
Normal file
135
packages/server/src/auth/session.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import dayjs from "dayjs"
|
||||
import uid from "uid-safe"
|
||||
import { db } from "~/database.ts"
|
||||
import { type User, DEMO_USER } from "~/user/user.ts"
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
signedId: string
|
||||
userId: string
|
||||
durationMs: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const SESSION_ID_BYTE_LENGTH = 24
|
||||
const SESSION_ID_COOKIE_NAME = "session-id"
|
||||
const SESSION_DURATION_MS = 30 * 60 * 1000
|
||||
|
||||
const findSessionQuery = db.query("SELECT user_id, expires_at_unix FROM sessions WHERE session_id = $sessionId")
|
||||
|
||||
const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId")
|
||||
const forgetAllSessionsQuery = db.query("DELETE FROM sessions WHERE user_id = $userId")
|
||||
const deleteExpiredSessionsQuery = db.query("DELETE FROM sessions WHERE expires_at_unix_ms < $time")
|
||||
|
||||
const saveSessionQuery = db.query(
|
||||
"INSERT INTO sessions(session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)",
|
||||
)
|
||||
|
||||
const extendSessionQuery = db.query(
|
||||
"UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id",
|
||||
)
|
||||
|
||||
function startBackgroundSessionCleanup() {
|
||||
setInterval(() => {
|
||||
deleteExpiredSessionsQuery.run({ time: dayjs().valueOf() })
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function newSessionId(): Promise<string> {
|
||||
return await uid(SESSION_ID_BYTE_LENGTH)
|
||||
}
|
||||
|
||||
function signSessionId(sessionId: string): string {
|
||||
const hasher = new Bun.CryptoHasher("sha256", Bun.env.SESSION_SECRET)
|
||||
hasher.update(sessionId)
|
||||
return `${sessionId}.${hasher.digest("base64url")}`
|
||||
}
|
||||
|
||||
async function createSessionForUser(user: User, cookies: Bun.CookieMap) {
|
||||
const sessionId = await newSessionId()
|
||||
const signedSessionId = signSessionId(sessionId)
|
||||
|
||||
const expiryDate = dayjs().add(30, "minutes").valueOf()
|
||||
|
||||
saveSessionQuery.run({
|
||||
sessionId,
|
||||
userId: user.id,
|
||||
expiresAt: expiryDate,
|
||||
})
|
||||
|
||||
cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, {
|
||||
maxAge: user.id === DEMO_USER.id ? undefined : SESSION_DURATION_MS,
|
||||
httpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function saveSession(session: Session, cookies: Bun.CookieMap) {
|
||||
cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, {
|
||||
maxAge: SESSION_DURATION_MS,
|
||||
httpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
function verifySession(cookie: Bun.CookieMap): Session | null {
|
||||
const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME)
|
||||
if (!signedSessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = signedSessionId.slice(0, signedSessionId.lastIndexOf("."))
|
||||
const expected = signSessionId(value)
|
||||
|
||||
const a = Buffer.from(signedSessionId)
|
||||
const b = Buffer.from(expected)
|
||||
|
||||
const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
if (!isEqual) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = findSessionQuery.get({ sessionId: value })
|
||||
if (!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) {
|
||||
deleteSessionQuery.run({ sessionId: value })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: value,
|
||||
signedId: signedSessionId,
|
||||
userId: foundSession.user_id,
|
||||
expiresAt: foundSession.expires_at_unix_ms,
|
||||
durationMs: SESSION_DURATION_MS,
|
||||
}
|
||||
}
|
||||
|
||||
function extendSession(session: Session): Session {
|
||||
const newExpiryDate = dayjs().add(30, "minutes").valueOf()
|
||||
extendSessionQuery.run({
|
||||
sessionId: session.id,
|
||||
newExpiryDate,
|
||||
})
|
||||
return {
|
||||
...session,
|
||||
expiresAt: newExpiryDate,
|
||||
}
|
||||
}
|
||||
|
||||
function forgetAllSessions(user: User) {
|
||||
forgetAllSessionsQuery.run({ userId: user.id })
|
||||
}
|
||||
|
||||
export {
|
||||
startBackgroundSessionCleanup,
|
||||
newSessionId,
|
||||
createSessionForUser,
|
||||
verifySession,
|
||||
saveSession,
|
||||
extendSession,
|
||||
forgetAllSessions,
|
||||
}
|
Reference in New Issue
Block a user