134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
/**
|
|
* Authentication handlers — login, register, and token refresh.
|
|
* Mirrors `apps/interfaces/api/src/handlers/auth.rs`.
|
|
*
|
|
* Endpoints:
|
|
* - POST /auth/login — authenticate, return JWT pair
|
|
* - POST /auth/register — create account, return JWT pair
|
|
* - POST /auth/refresh — exchange refresh token for a new pair
|
|
*
|
|
* All three are rate-limited (20 attempts / 10 min per client).
|
|
*/
|
|
import type { ApiState, RateLimiter } from "../state.ts";
|
|
import { ApiError } from "../error.ts";
|
|
import { loadUsers, saveUsers } from "../state.ts";
|
|
import type { AuthResponse, LoginRequest, RegisterRequest, RefreshRequest } from "../dto.ts";
|
|
|
|
/** Login/register brute-force protection: 20 attempts per 10-minute window. */
|
|
const AUTH_RATE_LIMIT_MAX = 20;
|
|
const AUTH_RATE_LIMIT_WINDOW_SECS = 600;
|
|
|
|
/** Extract a coarse client identity (X-Forwarded-For first hop or "unknown"). */
|
|
function clientId(headers: Headers): string {
|
|
const xff = headers.get("x-forwarded-for");
|
|
if (xff) {
|
|
const first = xff.split(",")[0]?.trim();
|
|
if (first) return first;
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
/** Enforce the auth rate limit; throws TooManyRequests when exceeded. */
|
|
function enforceRateLimit(limiter: RateLimiter, headers: Headers): void {
|
|
if (!limiter.check(clientId(headers), AUTH_RATE_LIMIT_MAX, AUTH_RATE_LIMIT_WINDOW_SECS)) {
|
|
throw ApiError.tooManyRequests("Too many requests, try again later");
|
|
}
|
|
}
|
|
|
|
function requireFields(req: LoginRequest & RegisterRequest, reg: boolean): void {
|
|
if (req.username === "") {
|
|
throw ApiError.badRequest("Username is required");
|
|
}
|
|
if (req.password === "") {
|
|
throw ApiError.badRequest("Password is required");
|
|
}
|
|
if (reg && req.password.length < 6) {
|
|
throw ApiError.badRequest("Password must be at least 6 characters");
|
|
}
|
|
}
|
|
|
|
/** Issue an AuthResponse for the given subject. */
|
|
function authResponse(state: ApiState, sub: string): AuthResponse {
|
|
const [access, refresh] = state.token_service.generateTokens(sub);
|
|
return {
|
|
access_token: access,
|
|
refresh_token: refresh,
|
|
token_type: "Bearer",
|
|
expires_in: 3600, // access token expiry in seconds
|
|
};
|
|
}
|
|
|
|
/** POST /auth/login — authenticate and issue a JWT pair. */
|
|
export async function loginHandler(
|
|
state: ApiState,
|
|
headers: Headers,
|
|
body: LoginRequest,
|
|
): Promise<AuthResponse> {
|
|
enforceRateLimit(state.auth_rate_limiter, headers);
|
|
requireFields(body, false);
|
|
|
|
const users = loadUsers(state.store_base_dir);
|
|
if (!users) {
|
|
throw ApiError.unauthorized("Invalid username or password");
|
|
}
|
|
const storedHash = users[body.username];
|
|
if (!storedHash) {
|
|
throw ApiError.unauthorized("Invalid username or password");
|
|
}
|
|
|
|
const valid = await state.password_service.verify(body.password, storedHash);
|
|
if (!valid) {
|
|
throw ApiError.unauthorized("Invalid username or password");
|
|
}
|
|
|
|
return authResponse(state, body.username);
|
|
}
|
|
|
|
/** POST /auth/register — create a new user account and issue a JWT pair. */
|
|
export async function registerHandler(
|
|
state: ApiState,
|
|
headers: Headers,
|
|
body: RegisterRequest,
|
|
): Promise<AuthResponse> {
|
|
enforceRateLimit(state.auth_rate_limiter, headers);
|
|
requireFields(body, true);
|
|
|
|
// Hash first (async), then serialize the read-modify-write of users.json.
|
|
const hash = await state.password_service.hash(body.password);
|
|
|
|
const unlock = state.users_lock.lock();
|
|
try {
|
|
const users = loadUsers(state.store_base_dir) ?? {};
|
|
if (body.username in users) {
|
|
throw ApiError.conflict("Username already exists");
|
|
}
|
|
users[body.username] = hash;
|
|
saveUsers(state.store_base_dir, users);
|
|
} finally {
|
|
unlock();
|
|
}
|
|
|
|
return authResponse(state, body.username);
|
|
}
|
|
|
|
/** POST /auth/refresh — exchange a refresh token for a fresh pair. */
|
|
export async function refreshHandler(
|
|
state: ApiState,
|
|
headers: Headers,
|
|
body: RefreshRequest,
|
|
): Promise<AuthResponse> {
|
|
enforceRateLimit(state.auth_rate_limiter, headers);
|
|
if (body.refresh_token === "") {
|
|
throw ApiError.badRequest("Refresh token is required");
|
|
}
|
|
|
|
let sub: string;
|
|
try {
|
|
sub = state.token_service.verifyRefreshToken(body.refresh_token);
|
|
} catch {
|
|
throw ApiError.unauthorized("Invalid or expired refresh token");
|
|
}
|
|
|
|
return authResponse(state, sub);
|
|
}
|