210 lines
6.0 KiB
TypeScript
210 lines
6.0 KiB
TypeScript
import { DEMO_USER, type 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 { hashPassword, verifyPassword } from "./password.ts"
|
|
import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts"
|
|
|
|
const SignUpRequest = type({
|
|
username: "string",
|
|
password: "string",
|
|
})
|
|
|
|
const LoginRequest = type({
|
|
username: "string",
|
|
password: "string",
|
|
})
|
|
|
|
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) {
|
|
console.log("session not found!")
|
|
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(session.user, request.cookies)
|
|
}
|
|
|
|
if (session.user.id !== DEMO_USER.id) {
|
|
const extendedSession = extendSession(session)
|
|
saveSession(extendedSession, request.cookies)
|
|
}
|
|
|
|
return handler(request, session.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")
|
|
|
|
const createAuthTokenQuery = db.query(
|
|
"INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)",
|
|
)
|
|
|
|
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,
|
|
path: "/api",
|
|
httpOnly: true,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function startBackgroundAuthTokenCleanup() {
|
|
const query = db.query("DELETE FROM auth_tokens WHERE expires_at_unix_ms < $time")
|
|
setInterval(() => {
|
|
query.run({ time: new Date().valueOf() })
|
|
}, 5000)
|
|
}
|
|
|
|
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, "BadRequestBody", signUpRequest.summary)
|
|
}
|
|
|
|
const { username, password } = signUpRequest
|
|
const hashedPassword = await hashPassword(password)
|
|
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">) {
|
|
// 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(401)
|
|
})
|
|
|
|
const loginRequest = LoginRequest(body)
|
|
if (loginRequest instanceof type.errors) {
|
|
throw new HttpError(400, "BadRequestBody")
|
|
}
|
|
|
|
const foundUser = findUserByUsername(loginRequest.username, {
|
|
password: true,
|
|
})
|
|
if (!foundUser) {
|
|
throw new HttpError(401)
|
|
}
|
|
|
|
const ok = await verifyPassword(loginRequest.password, foundUser.password).catch(() => {
|
|
throw new HttpError(401)
|
|
})
|
|
if (!ok) {
|
|
throw new HttpError(401)
|
|
}
|
|
|
|
const user = {
|
|
id: foundUser.id,
|
|
username: foundUser.username,
|
|
}
|
|
|
|
await createSessionForUser(user, request.cookies)
|
|
if (user.id !== DEMO_USER.id) {
|
|
rememberLoginForUser(user, request.cookies)
|
|
}
|
|
|
|
return Response.json(user, { status: 200 })
|
|
}
|
|
|
|
async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise<Response> {
|
|
const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId")
|
|
forgetAllSessions(request.cookies, user)
|
|
deleteAllAuthTokensQuery.run({ userId: user.id })
|
|
return new Response(undefined, { status: 204 })
|
|
}
|
|
|
|
export { authenticated, signUp, login, logout, startBackgroundAuthTokenCleanup }
|