/** * Signs users in, from the client's `/login` page. A session is a token the * client keeps; signing out forgets it, with no request. * * - `POST /api/sessions` — public. Answers the user and their tokens; * rate-limited per account. * * @module */ import { Router } from "@oak/oak"; import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { AuthResponse } from "../../shared/user.ts"; import { isLoginUserRequest } from "../validators/user.ts"; import { toSelfUser } from "../model/user.ts"; import { issueTokens } from "../lib/jwt.ts"; import { hashPassword, verifyPassword } from "../lib/password.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; import { getUserByUsername } from "../services/user-service.ts"; // Hashed once, at startup, for the sign-ins of usernames that don't exist. const UNKNOWN_USER_HASH: Promise = hashPassword(crypto.randomUUID()); /** Allows a burst of 10 login attempts per account, then one every 30s. */ const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ const router: Router = new Router({ prefix: "/api/sessions" }); router.post("/", async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isLoginUserRequest(body)) { throw new APIException(400, "Invalid request"); } // Per account, so guessing one user's password is slow however many // connections it comes from. if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) { throw new APIException( 429, "Too many login attempts — try again in a minute", ); } let user; try { user = getUserByUsername(body.username); } catch { user = undefined; } // An unknown username gets the same answer as a wrong password, after the // same work, so neither the message nor the timing tells which exist. const valid = await verifyPassword( body.password, user?.passwordHash ?? await UNKNOWN_USER_HASH, ); if (!user || !valid) { throw new APIException(401, "Invalid username or password"); } const responseBody: APIResponse = { success: true, data: { ...await issueTokens(user), user: toSelfUser(user) }, }; ctx.response.body = responseBody; }); export default router;