75 lines
2.4 KiB
TypeScript
75 lines
2.4 KiB
TypeScript
/**
|
|
* 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<string> = 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<AuthResponse> = {
|
|
success: true,
|
|
data: { ...await issueTokens(user), user: toSelfUser(user) },
|
|
};
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
export default router;
|