Build Phase 2 judge vertical slice
This commit is contained in:
24
apps/api/src/auth/middleware.ts
Normal file
24
apps/api/src/auth/middleware.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
|
||||
import { failure } from "../http"
|
||||
import { getSessionUser, type AuthUser } from "./session"
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
user: AuthUser | null
|
||||
}
|
||||
}
|
||||
|
||||
export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
c.set("user", await getSessionUser(c))
|
||||
await next()
|
||||
}
|
||||
|
||||
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
const user = await getSessionUser(c)
|
||||
if (!user) {
|
||||
return failure(c, 401, "login-required", "Authentication required")
|
||||
}
|
||||
c.set("user", user)
|
||||
await next()
|
||||
}
|
||||
49
apps/api/src/auth/password.ts
Normal file
49
apps/api/src/auth/password.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { pbkdf2, timingSafeEqual } from "node:crypto"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
const pbkdf2Async = promisify(pbkdf2)
|
||||
|
||||
async function verifyDjangoPbkdf2(password: string, encoded: string) {
|
||||
const [algorithm, iterationsText, salt, digestText] = encoded.split("$")
|
||||
if (
|
||||
algorithm !== "pbkdf2_sha256" ||
|
||||
!iterationsText ||
|
||||
!salt ||
|
||||
!digestText
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const iterations = Number(iterationsText)
|
||||
const expected = Buffer.from(digestText, "base64")
|
||||
if (!Number.isSafeInteger(iterations) || iterations <= 0 || expected.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const actual = await pbkdf2Async(
|
||||
password,
|
||||
salt,
|
||||
iterations,
|
||||
expected.length,
|
||||
"sha256",
|
||||
)
|
||||
return timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, encoded: string) {
|
||||
if (encoded.startsWith("pbkdf2_sha256$")) {
|
||||
return {
|
||||
valid: await verifyDjangoPbkdf2(password, encoded),
|
||||
needsUpgrade: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (encoded.startsWith("$argon2")) {
|
||||
return {
|
||||
valid: await Bun.password.verify(password, encoded),
|
||||
needsUpgrade: false,
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false, needsUpgrade: false }
|
||||
}
|
||||
110
apps/api/src/auth/session.ts
Normal file
110
apps/api/src/auth/session.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { randomBytes } from "node:crypto"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { Context } from "hono"
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie"
|
||||
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { redis } from "../redis"
|
||||
|
||||
const SESSION_PREFIX = "session:"
|
||||
|
||||
interface StoredSession {
|
||||
userId: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string | null
|
||||
adminType: string
|
||||
problemPermission: string
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
function sessionKey(token: string) {
|
||||
return `${SESSION_PREFIX}${token}`
|
||||
}
|
||||
|
||||
export async function createSession(c: Context, userId: number) {
|
||||
const token = randomBytes(32).toString("base64url")
|
||||
const value: StoredSession = {
|
||||
userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
await redis.set(
|
||||
sessionKey(token),
|
||||
JSON.stringify(value),
|
||||
"EX",
|
||||
config.sessionTtlSeconds,
|
||||
)
|
||||
setCookie(c, config.sessionCookie, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure: config.secureCookies,
|
||||
path: "/",
|
||||
maxAge: config.sessionTtlSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
export async function destroySession(c: Context) {
|
||||
const token = getCookie(c, config.sessionCookie)
|
||||
if (token) await redis.del(sessionKey(token))
|
||||
deleteCookie(c, config.sessionCookie, { path: "/" })
|
||||
}
|
||||
|
||||
function readCookie(request: Request, name: string) {
|
||||
const header = request.headers.get("cookie")
|
||||
if (!header) return undefined
|
||||
for (const part of header.split(";")) {
|
||||
const [key, ...value] = part.trim().split("=")
|
||||
if (key === name) return decodeURIComponent(value.join("="))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getUserByToken(token: string | undefined): Promise<AuthUser | null> {
|
||||
if (!token) return null
|
||||
|
||||
const raw = await redis.get(sessionKey(token))
|
||||
if (!raw) return null
|
||||
|
||||
let session: StoredSession
|
||||
try {
|
||||
session = JSON.parse(raw) as StoredSession
|
||||
} catch {
|
||||
await redis.del(sessionKey(token))
|
||||
return null
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: schema.user.id,
|
||||
username: schema.user.username,
|
||||
email: schema.user.email,
|
||||
adminType: schema.user.adminType,
|
||||
problemPermission: schema.user.problemPermission,
|
||||
isDisabled: schema.user.isDisabled,
|
||||
})
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, session.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!user || user.isDisabled) {
|
||||
await redis.del(sessionKey(token))
|
||||
return null
|
||||
}
|
||||
|
||||
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||
return user
|
||||
}
|
||||
|
||||
export function getSessionUser(c: Context) {
|
||||
return getUserByToken(getCookie(c, config.sessionCookie))
|
||||
}
|
||||
|
||||
export function getRequestSessionUser(request: Request) {
|
||||
return getUserByToken(readCookie(request, config.sessionCookie))
|
||||
}
|
||||
Reference in New Issue
Block a user