Files
flood/server/routes/api/auth.ts
Jesse Chan 306ff79182 API: auth: don't include token in JSON objects
Token is already sent by Set-Cookie. It is unneccessary and
insecure to include them in JSON response. Doing so also
introduce the token into Javascript VM which is not protected
as well as the httpOnly cookies.
2020-10-26 20:40:59 +08:00

358 lines
9.8 KiB
TypeScript

import express from 'express';
import jwt from 'jsonwebtoken';
import passport from 'passport';
import rateLimit from 'express-rate-limit';
import type {Response} from 'express';
import ajaxUtil from '../../util/ajaxUtil';
import {authAuthenticationSchema, authRegistrationSchema, authUpdateUserSchema} from '../../../shared/schema/api/auth';
import config from '../../../config';
import requireAdmin from '../../middleware/requireAdmin';
import services from '../../services';
import Users from '../../models/Users';
import type {
AuthAuthenticationOptions,
AuthAuthenticationResponse,
AuthRegistrationOptions,
AuthUpdateUserOptions,
AuthVerificationResponse,
} from '../../../shared/schema/api/auth';
import type {Credentials, UserInDatabase} from '../../../shared/schema/Auth';
const router = express.Router();
const failedLoginResponse = 'Failed login.';
// Limit each IP to 200 request every 5 minutes
// to prevent brute forcing password or denial-of-service
router.use(
'/',
rateLimit({
windowMs: 5 * 60 * 1000,
max: 200,
}),
);
export const getAuthToken = (username: string, res?: Response): string => {
const expirationSeconds = 60 * 60 * 24 * 7; // one week
const cookieExpiration = Date.now() + expirationSeconds * 1000;
// Create token if the password matched and no error was thrown.
const token = jwt.sign({username}, config.secret, {
expiresIn: expirationSeconds,
});
if (res != null) {
res.cookie('jwt', token, {expires: new Date(cookieExpiration), httpOnly: true, sameSite: 'strict'});
}
return token;
};
const sendAuthenticationResponse = (
res: Response,
credentials: Required<Pick<Credentials, 'username' | 'level'>>,
): void => {
const {username, level} = credentials;
getAuthToken(username, res);
const response: AuthAuthenticationResponse = {
success: true,
username,
level,
};
res.json(response);
};
const validationError = (res: Response, err: Error) => {
res.status(422).json({
message: 'Validation error.',
error: err,
});
};
router.use('/users', passport.authenticate('jwt', {session: false}), requireAdmin);
/**
* POST /api/auth/authenticate
* @summary Authenticates a user
* @tags Auth
* @security None
* @param {AuthAuthenticationOptions} request.body.required - options - application/json
* @return {object} 422 - request validation error - application/json
* @return {object} 401 - incorrect username or password - application/json
* @return {AuthAuthenticationResponse} 200 - success response - application/json
*/
router.post<unknown, unknown, AuthAuthenticationOptions>('/authenticate', (req, res) => {
if (config.disableUsersAndAuth) {
sendAuthenticationResponse(res, Users.getConfigUser());
return;
}
const parsedResult = authAuthenticationSchema.safeParse(req.body);
if (!parsedResult.success) {
validationError(res, parsedResult.error);
return;
}
const credentials = parsedResult.data;
Users.comparePassword(credentials, (isMatch, level, _err) => {
if (isMatch === true && level != null) {
sendAuthenticationResponse(res, {
...credentials,
level,
});
return;
}
// Incorrect username or password.
res.status(401).json({
message: failedLoginResponse,
});
});
});
// Allow unauthenticated registration if no users are currently registered.
router.use('/register', (req, res, next) => {
Users.initialUserGate({
handleInitialUser: () => {
next();
},
handleSubsequentUser: () => {
passport.authenticate('jwt', {session: false}, (err, user: UserInDatabase) => {
if (err || !user) {
res.status(401).send('Unauthorized');
return;
}
req.user = user;
// Only admin users can create users
requireAdmin(req, res, next);
})(req, res, next);
},
});
});
/**
* POST /api/auth/register
* @summary Registers a user
* @tags Auth
* @security None - initial request
* @security Administrator - subsequent requests
* @param {AuthRegistrationOptions} request.body.required - options - application/json
* @param {'true' | 'false'} cookie.query - whether to Set-Cookie if succeeded
* @return {string} 404 - registration is disabled
* @return {string} 403 - user is not authorized to create user
* @return {object} 422 - request validation error - application/json
* @return {{username: string}} 200 - success response if cookie=false - application/json
* @return {AuthAuthenticationResponse} 200 - success response - application/json
*/
router.post<unknown, unknown, AuthRegistrationOptions, {cookie: string}>('/register', (req, res) => {
// No user can be registered when disableUsersAndAuth is true
if (config.disableUsersAndAuth) {
// Return 404
res.status(404).send('Not found');
return;
}
const parsedResult = authRegistrationSchema.safeParse(req.body);
if (!parsedResult.success) {
validationError(res, parsedResult.error);
return;
}
const credentials = parsedResult.data;
// Attempt to save the user
Users.createUser(credentials, (user, error) => {
if (error || user == null) {
ajaxUtil.getResponseFn(res)({username: credentials.username}, error);
return;
}
services.bootstrapServicesForUser(user);
if (req.query.cookie === 'false') {
ajaxUtil.getResponseFn(res)({username: user.username});
return;
}
sendAuthenticationResponse(res, credentials);
});
});
// Allow unauthenticated verification if no users are currently registered.
router.use('/verify', (req, res, next) => {
// Unconditionally provide a token if auth is disabled
if (config.disableUsersAndAuth) {
const {username, level} = Users.getConfigUser();
getAuthToken(username, res);
const response: AuthVerificationResponse = {
initialUser: false,
username,
level,
token: `JWT ${token}`,
};
res.json(response);
return;
}
Users.initialUserGate({
handleInitialUser: () => {
const response: AuthVerificationResponse = {
initialUser: true,
};
res.json(response);
},
handleSubsequentUser: () => {
passport.authenticate('jwt', {session: false})(req, res, next);
},
});
});
/**
* GET /api/auth/verify
* @summary Verifies the connectivity and validity of session
* @tags Auth
* @security User
* @return {string} 401 - not authenticated or token expired
* @return {string} 500 - authenticated succeeded but user is unattached (this should NOT happen)
* @return {AuthVerificationResponse} 200 - success response - application/json
*/
router.get('/verify', (req, res) => {
if (req.user == null) {
res.status(500).send('Unattached user.');
return;
}
const response: AuthVerificationResponse = {
initialUser: false,
username: req.user.username,
level: req.user.level,
};
res.json(response);
});
// All subsequent routes are protected.
router.use('/', passport.authenticate('jwt', {session: false}));
/**
* GET /api/auth/logout
* @summary Clears the session cookie
* @tags Auth
* @security User
* @return {string} 401 - not authenticated or token expired
* @return {} 200 - success response
*/
router.get('/logout', (_req, res) => {
res.clearCookie('jwt').send();
});
// All subsequent routes need administrator access.
router.use('/', requireAdmin);
router.use('/users', (_req, res, next) => {
// No operation on user when disableUsersAndAuth is true
if (config.disableUsersAndAuth) {
// Return 404
res.status(404).send('Not found');
}
next();
});
/**
* GET /api/auth/users
* @summary Lists all users
* @tags Auth
* @security Administrator
* @return {string} 401 - not authenticated or token expired
* @return {string} 403 - user is not authorized to list users
* @return {Array<UserInDatabase>} 200 - success response - application/json
*/
router.get('/users', (_req, res) => {
Users.listUsers(ajaxUtil.getResponseFn(res));
});
/**
* DELETE /api/auth/users/{username}
* @summary Deletes a user
* @tags Auth
* @security Administrator
* @param {string} username.path - username of the user to be deleted
* @return {string} 401 - not authenticated or token expired
* @return {string} 403 - user is not authorized to delete user
* @return {{username: string}} 200 - success response - application/json
*/
router.delete('/users/:username', (req, res) => {
const callback = ajaxUtil.getResponseFn(res);
Users.removeUser(req.params.username, (id, err) => {
if (err || id == null) {
callback(null, err || new Error());
return;
}
services.destroyUserServices(id);
callback({username: req.params.username});
});
});
/**
* PATCH /api/auth/users/{username}
* @summary Updates a user
* @tags Auth
* @security Administrator
* @param {string} username.path - username of the user to be updated
* @param {AuthUpdateUserOptions} request.body.required - options - application/json
* @return {string} 401 - not authenticated or token expired
* @return {string} 403 - user is not authorized to update user
* @return {object} 422 - request validation error - application/json
* @return {} 200 - success response
*/
router.patch<{username: Credentials['username']}, unknown, AuthUpdateUserOptions>('/users/:username', (req, res) => {
const {username} = req.params;
const parsedResult = authUpdateUserSchema.safeParse(req.body);
if (!parsedResult.success) {
validationError(res, parsedResult.error);
return;
}
const patch = parsedResult.data;
Users.updateUser(username, patch, (newUsername, err) => {
if (err || newUsername == null) {
res.status(500).json({error: err});
return;
}
Users.lookupUser(newUsername, (errLookup, user) => {
if (errLookup) {
res.status(500).json({error: errLookup});
return;
}
if (user != null) {
services.destroyUserServices(user._id);
services.bootstrapServicesForUser(user);
}
res.send();
});
});
});
export default router;