85 lines
3.8 KiB
JavaScript
85 lines
3.8 KiB
JavaScript
/**
|
|
* @file Verarbeitet Registrierung, Anmeldung, Abmeldung und die Abfrage der aktuellen Sitzung.
|
|
*/
|
|
import express from "express";
|
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
|
import bcrypt from "bcrypt";
|
|
import {validateBody} from "../middleware/validate.js";
|
|
import {newPasswordError} from "../services/passwordPolicy.js";
|
|
import {normalizeUsername, usernameError} from "../services/usernamePolicy.js";
|
|
|
|
const credentialsValidator = (body) => {
|
|
const username = normalizeUsername(body?.username);
|
|
const password = String(body?.password || "");
|
|
const inviteCode = String(body?.inviteCode || "").trim();
|
|
const usernameValidationError = usernameError(username);
|
|
if (usernameValidationError) return {error: usernameValidationError};
|
|
if (!password) return {error: "Password is required"};
|
|
if (password.length < 6) return {error: "Password must be at least 6 characters"};
|
|
return {value: {username, password, inviteCode}};
|
|
};
|
|
|
|
const DUMMY_PASSWORD_HASH = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
|
|
|
|
const regenerateSession = (req, fields) => new Promise((resolve, reject) => {
|
|
req.session.regenerate(error => {
|
|
if (error) return reject(error);
|
|
Object.assign(req.session, fields);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
export function createAuthRouter({repositories, authRateLimiters, config}) {
|
|
const router = express.Router();
|
|
|
|
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
|
|
const {username, password, inviteCode} = req.validatedBody;
|
|
const passwordError = newPasswordError(password);
|
|
if (passwordError) return res.status(400).json({error: passwordError, code: "PASSWORD_TOO_LONG"});
|
|
if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
|
|
return res.status(403).json({error: "Valid invite code is required"});
|
|
}
|
|
if (repositories.getUserCaseInsensitive(username)) {
|
|
return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"});
|
|
}
|
|
|
|
const hashed = await bcrypt.hash(password, 10);
|
|
try {
|
|
repositories.insertUser(username, hashed);
|
|
} catch (error) {
|
|
// The precheck can race while bcrypt yields; the database is authoritative.
|
|
if (["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE"].includes(error.code)) {
|
|
return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"});
|
|
}
|
|
throw error;
|
|
}
|
|
await regenerateSession(req, {user: username, sessionVersion: 0});
|
|
return res.json({message: "Registration successful"});
|
|
}));
|
|
|
|
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
|
|
const {username, password} = req.validatedBody;
|
|
const user = repositories.getUserCaseInsensitive(username);
|
|
const valid = await bcrypt.compare(password, user?.password || DUMMY_PASSWORD_HASH);
|
|
if (!user || !valid) {
|
|
// Keep the message generic and still perform bcrypt work for unknown users.
|
|
return res.status(user ? 403 : 400).json({error: "Invalid username or password"});
|
|
}
|
|
await regenerateSession(req, {user: user.username, sessionVersion: Number(user.session_version || 0)});
|
|
return res.json({message: "Login successful"});
|
|
}));
|
|
|
|
router.post("/logout", (req, res, next) => {
|
|
req.session.destroy(error => error ? next(error) : res.json({message: "Logged out"}));
|
|
});
|
|
|
|
router.get("/session", (req, res) => {
|
|
return res.json({
|
|
user: req.session.user || null,
|
|
inviteOnlyRegistration: Boolean(config.registrationInviteCode)
|
|
});
|
|
});
|
|
|
|
return router;
|
|
}
|