2024-07-04 21:43:44 +02:00

938 lines
31 KiB
TypeScript

import { promises as dnsPromises } from 'dns';
import { Router } from 'express';
import type { NextFunction, Request, Response } from 'express';
import SQL from 'sql-template-strings';
import { ulid } from 'ulid';
import { buildDict, makeId, now, handleErrorAsync, obfuscateEmail } from '../../src/helpers.ts';
import jwt from '../../src/jwt.ts';
import mailer from '../../src/mailer.ts';
import { loadSuml } from '../loader.ts';
import avatar from '../avatar.ts';
import type { SocialProfilePayload } from '../social.ts';
import {
config as socialLoginConfig,
handlers as socialLoginHandlers,
lookup as socialLookup,
} from '../social.ts';
import { longtimeCookieSetting } from '../../src/cookieSettings.ts';
import { validateCaptcha } from '../captcha.ts';
import assert from 'assert';
import { addMfaInfo } from './mfa.ts';
import buildLocaleList from '../../src/buildLocaleList.ts';
import { lookupBanArchive } from '../ban.ts';
import copyImage from '../imageCopy.ts';
import { usernameRegex, usernameUnsafeRegex } from '../../src/username.ts';
import auditLog from '../audit.ts';
import { CacheObject } from '../../src/cache.ts';
import type { Config } from '../../locale/config.ts';
import type { Database } from '../db.ts';
import type { User } from '../../src/user.ts';
const config = loadSuml('config') as Config;
export interface UserRow {
id: string;
username: string;
email: string;
roles: string;
avatarSource: string | null;
bannedReason: string | null;
suspiciousChecked: number;
usernameNorm: string | null;
bannedTerms: string | null;
bannedBy: string | null;
lastActive: number | null;
banSnapshot: string | null;
inactiveWarning: number | null;
adminNotifications: number;
loginAttempts: string | null;
timesheets: string | null;
socialLookup: number;
}
export interface AuthenticatorRow {
id: string;
userId: string | null;
type: string;
payload: string;
validUntil: number | null;
}
interface Authenticator extends Omit<AuthenticatorRow, 'payload'> {
payload: any;
}
export const normalise = (s: string): string => s.trim().toLowerCase();
const isSpam = (email: string): boolean => {
const noDots = email.replace(/\./g, '');
return noDots === 'javierfranciscotmp@gmailcom' ||
noDots === 'leahmarykathryntmp@gmailcom' ||
noDots === 'janeesevictorjh@gmail.com' ||
email.includes('dogazu') ||
email.includes('narodowcy.net') ||
email.length > 128;
};
const replaceExtension = (username: string): string => username
.replace(/\.(txt|jpg|jpeg|png|pdf|gif|bmp|doc|docx|csv|js|css|html|mp3|mp4|wav)$/i, '_$1') // nuxt tries to serve those requests as files, not pages
.replace(/\.$/, '_') // trailing dots get ignored by auto-linkers on external pages
;
export const saveAuthenticator = async (
db: Database,
type: string,
user: Pick<UserRow, 'id' | 'username'> & Partial<Pick<UserRow, 'socialLookup'>>,
payload: any,
validForMinutes: number | null = null,
) => {
if (!user && await lookupBanArchive(db, type, payload)) {
throw new Error('banned');
}
const id = ulid();
await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES (
${id},
${user ? user.id : null},
${type},
${JSON.stringify(payload)},
${validForMinutes ? now() + validForMinutes * 60 : null}
)`);
if (user && user.socialLookup) {
await saveAuthenticatorSocialLookup(db, {
userId: user.id,
type,
payload,
});
}
await auditLog({ db, user }, 'auth/saved_authenticator', {
type,
payloadId: payload.id || undefined,
name: payload.name || undefined,
username: payload.username || undefined,
email: payload.email || undefined,
from: payload.from || undefined,
to: payload.to || undefined,
});
return id;
};
export const findAuthenticatorById = async (db: Database, id: string, type: string): Promise<Authenticator | undefined> => {
const authenticator = await db.get<AuthenticatorRow>(SQL`SELECT * FROM authenticators
WHERE id = ${id}
AND type = ${type}
AND (validUntil IS NULL OR validUntil > ${now()})
`);
if (authenticator === undefined) {
return undefined;
}
return {
...authenticator,
payload: JSON.parse(authenticator.payload),
};
};
export const findAuthenticatorsByUser = async (
db: Database,
user: Pick<User, 'id'>,
type: string | undefined = undefined,
): Promise<Authenticator[]> => {
let query = SQL`
SELECT * FROM authenticators
WHERE userId = ${user.id}
AND (validUntil IS NULL OR validUntil > ${now()})
`;
if (type) {
query = query.append(SQL`AND type = ${type}`);
}
const authenticators = await db.all<AuthenticatorRow>(query);
return authenticators.map((a) => ({
...a,
payload: JSON.parse(a.payload),
}));
};
const findLatestEmailAuthenticator = async (
db: Database,
email: string,
type: string,
): Promise<Authenticator | undefined> => {
const authenticator = await db.get<AuthenticatorRow>(SQL`SELECT * FROM authenticators
WHERE payload LIKE ${`%"email":"${email}"%`}
AND type = ${type}
AND (validUntil IS NULL OR validUntil > ${now()})
ORDER BY id DESC
`);
if (authenticator === undefined) {
return undefined;
}
return {
...authenticator,
payload: JSON.parse(authenticator.payload),
};
};
export const invalidateAuthenticator = async (db: Database, id: string) => {
await db.get(SQL`UPDATE authenticators
SET validUntil = ${now()}
WHERE id = ${id}
`);
};
export const invalidateAuthenticatorsOfType = async (db: Database, userId: string, type: string) => {
await db.get(SQL`UPDATE authenticators
SET validUntil = ${now()}
WHERE userId = ${userId}
AND type = ${type}
`);
};
const defaultUsername = async (db: Database, email: string) => {
const base = normalise(replaceExtension(email.substring(0, email.includes('@') ? email.indexOf('@') : email.length)
.padEnd(4, '0')
.substring(0, 14)
.replace(usernameUnsafeRegex, '_')));
const conflicts = (await db.all<Pick<User, 'usernameNorm'>>(SQL`SELECT usernameNorm FROM users WHERE usernameNorm LIKE ${`${normalise(base)}%`}`))
.map(({ usernameNorm }) => usernameNorm);
let c = 0;
while (true) {
const proposal = base + (c || '');
if (!conflicts.includes(proposal)) {
return proposal;
}
c++;
}
};
const fetchOrCreateUser = async (
db: Database,
user: Pick<UserRow, 'email'> & { name?: string, username?: string },
avatarSource = 'gravatar',
) => {
let dbUser: any = user.email
? await db.get(SQL`SELECT * FROM users WHERE email = ${normalise(user.email)}`)
: await db.get(SQL`SELECT * FROM users WHERE usernameNorm = ${normalise(user.username!)}`);
if (!dbUser) {
dbUser = {
id: ulid(),
username: await defaultUsername(db, user.name || user.email),
email: normalise(user.email),
roles: '',
avatarSource,
};
await db.get(SQL`INSERT INTO users(id, username, usernameNorm, email, roles, avatarSource, lastActive)
VALUES (${dbUser.id}, ${dbUser.username}, ${normalise(dbUser.username)}, ${dbUser.email}, ${dbUser.roles}, ${dbUser.avatarSource}, ${+new Date()})`);
}
dbUser.avatar = await avatar(db, dbUser);
return dbUser;
};
export const issueAuthentication = async (
db: Database,
user: Pick<UserRow, 'email'>,
fetch = true,
guardMfa = false,
extend: unknown = undefined,
) => {
let userAuthentication: any = fetch ? await fetchOrCreateUser(db, user) : user;
if (userAuthentication.mfa === undefined && userAuthentication.id) {
userAuthentication = await addMfaInfo(db, userAuthentication, guardMfa);
}
if (!userAuthentication.mfaRequired) {
userAuthentication.authenticated = true;
}
userAuthentication.avatar = await avatar(db, userAuthentication);
delete userAuthentication.suspiciousChecked;
delete userAuthentication.bannedBy;
delete userAuthentication.banSnapshot;
delete userAuthentication.timesheets;
if (extend) {
userAuthentication = {
...userAuthentication,
...extend,
};
}
return jwt.sign(userAuthentication satisfies User);
};
const validateHasMxRecords = async (domain: string): Promise<boolean> => {
const dns = new dnsPromises.Resolver();
try {
const addresses = await dns.resolveMx(domain);
return addresses.length > 0;
} catch (e) {
return false;
}
};
export const validateEmail = async (email: string): Promise<boolean> => {
email = normalise(String(email));
if (email.endsWith('.oauth')) {
return false;
}
const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (!re.test(email)) {
return false;
}
const domain = email.split('@')[1];
const domainParts = domain.split('.');
for (let i = 0; i < domainParts.length - 1; i++) {
const parentDomain = domainParts.slice(i).join('.');
if (await validateHasMxRecords(parentDomain)) {
return true;
}
}
return false;
};
export const deduplicateEmail = async (
db: Database,
email: string,
cbSuccess: () => Promise<void>,
cbFail: (() => Promise<void>) | undefined,
ttl = 5 * 60,
): Promise<void> => {
const count = (await db.get<{ c: number }>(SQL`SELECT COUNT(*) AS c FROM emails WHERE email = ${email} AND sentAt >= ${now() - ttl}`))!.c;
if (count > 0) {
console.error(`Duplicate email requests for ${email}`);
if (cbFail) {
await cbFail();
}
return;
}
await cbSuccess();
await db.get(SQL`INSERT INTO emails (email, sentAt) VALUES (${email}, ${now()});`);
};
export const deduplicateEmailPreset = async (
db: Database,
email: string,
template: string,
params = {},
ttl = 5 * 60,
) => {
await deduplicateEmail(
db,
email,
async () => {
mailer(email, template, params);
},
async () => {
},
ttl,
);
};
const reloadUser = async (req: Request, res: Response, next: NextFunction) => {
if (!req.url.startsWith('/user/') && req.method === 'GET') {
next();
return;
}
if (!req.user) {
next();
return;
}
const dbUser = await req.db.get<UserRow>(SQL`SELECT * FROM users WHERE id = ${req.user.id}`);
if (!dbUser) {
res.clearCookie('token');
next();
return;
}
const dbUserWithMfa = await addMfaInfo(req.db, dbUser);
await req.db.get(SQL`UPDATE users SET lastActive = ${+new Date()} WHERE id = ${req.user.id}`);
if (req.user.username !== dbUserWithMfa.username ||
req.user.email !== dbUserWithMfa.email ||
req.user.roles !== dbUserWithMfa.roles ||
req.user.avatarSource !== dbUserWithMfa.avatarSource ||
req.user.bannedReason !== dbUserWithMfa.bannedReason ||
req.user.mfa !== dbUserWithMfa.mfa
) {
const token = await issueAuthentication(req.db, dbUserWithMfa, false);
res.cookie('token', token, longtimeCookieSetting);
req.rawUser = jwt.validate(token) as User;
req.user = req.rawUser;
}
next();
};
const resetCards = async (db: Database, id: string): Promise<void> => {
await db.get(SQL`UPDATE profiles SET card = null, cardDark = null WHERE userId = ${id}`);
};
const MAX_LOGIN_ATTEMPTS_COUNT = 3;
const MAX_LOGIN_ATTEMPTS_BLOCK_MS = 60 * 60 * 1000;
export const fetchLoginAttempts = async (db: Database, userId: string) => {
const user = await db.get<Pick<UserRow, 'loginAttempts'>>(SQL`SELECT loginAttempts FROM users WHERE id = ${userId}`);
const chunks = (user?.loginAttempts || '0|0').split('|');
let attemptCount = parseInt(chunks[0]);
let firstAttemptAt: Date | null = new Date(parseInt(chunks[1]));
if (firstAttemptAt.getTime() < new Date().getTime() - MAX_LOGIN_ATTEMPTS_BLOCK_MS) {
attemptCount = 0;
firstAttemptAt = null;
}
return { attemptCount, firstAttemptAt, block: attemptCount >= MAX_LOGIN_ATTEMPTS_COUNT };
};
export const saveLoginAttempts = async (
db: Database,
userId: string,
attemptCount: number,
firstOneAt: Date,
): Promise<void> => {
const loginAttempts = `${attemptCount}|${firstOneAt.getTime()}`;
await db.get(SQL`UPDATE users SET loginAttempts = ${loginAttempts} WHERE id = ${userId}`);
};
const router = Router();
router.use(handleErrorAsync(reloadUser));
export const loadCurrentUser = async (req: Request, res: Response) => {
if (!req.user) {
if (req.query.no_cookie === undefined) {
res.clearCookie('token');
}
return res.json(null);
}
type UserRowOptionalTimesheets = (Omit<UserRow, 'timesheets'> & Partial<Pick<UserRow, 'timesheets'>>);
const dbUser: UserRowOptionalTimesheets | undefined = await req.db.get<UserRow>(SQL`SELECT * FROM users WHERE id = ${req.user.id}`);
if (!dbUser) {
res.clearCookie('token');
return res.json(null);
}
delete dbUser.timesheets;
const token = await issueAuthentication(req.db, dbUser, false);
if (req.query.no_cookie === undefined) {
res.cookie('token', token, longtimeCookieSetting);
}
req.rawUser = jwt.validate(token) as User;
req.user = req.rawUser;
return res.json({ ...req.user, token });
};
router.get('/user/current', handleErrorAsync(loadCurrentUser));
router.post('/user/init', handleErrorAsync(async (req, res) => {
if (req.body.usernameOrEmail && isSpam(req.body.usernameOrEmail || '')) {
req.socket.end();
return;
}
if (!await validateCaptcha(req.body.captchaToken)) {
return res.json({ error: 'captcha.invalid' });
}
let user: UserRow | undefined = undefined;
let usernameOrEmail = req.body.usernameOrEmail;
const isEmail = usernameOrEmail.indexOf('@') > -1;
let isTest = false;
if (process.env.NODE_ENV === 'development' && usernameOrEmail.endsWith('+')) {
isTest = true;
usernameOrEmail = usernameOrEmail.substring(0, usernameOrEmail.length - 1);
}
if (isEmail) {
user = await req.db.get<UserRow>(SQL`SELECT * FROM users WHERE email = ${normalise(usernameOrEmail)}`);
} else {
user = await req.db.get<UserRow>(SQL`SELECT * FROM users WHERE usernameNorm = ${normalise(usernameOrEmail)}`);
}
if (!user && !isEmail) {
return res.json({ error: 'user.login.userNotFound' });
}
if (user) {
const { block } = await fetchLoginAttempts(req.db, user.id);
if (block) {
await auditLog({ ...req, user }, 'auth/login_too_many_attempts');
return res.json({ error: 'user.tooManyAttempts' });
}
}
const payload = {
username: isEmail ? user ? user.username : null : usernameOrEmail,
email: isEmail ? normalise(usernameOrEmail) : user!.email,
code: isTest ? '999999' : makeId(6, '0123456789'),
};
if (!await validateEmail(payload.email)) {
await auditLog({ ...req, user }, 'auth/email_invalid', { email: payload.email });
return res.json({ error: 'user.account.changeEmail.invalid' });
}
if (!user && await lookupBanArchive(req.db, 'email', payload)) {
await auditLog({ ...req, user }, 'auth/blocked_archive_ban');
return res.status(401).json({ error: 'Unauthorised' });
}
let codeKey: string | null = null;
if (isTest) {
codeKey = await saveAuthenticator(req.db, 'email', user!, payload, 15);
} else {
await deduplicateEmail(
req.db,
payload.email,
async () => {
codeKey = await saveAuthenticator(req.db, 'email', user!, payload, 15);
mailer(payload.email, 'confirmCode', { code: payload.code });
await auditLog({ ...req, user }, 'auth/requested_email_code', { email: payload.email });
},
async () => {
const auth = await findLatestEmailAuthenticator(req.db, payload.email, 'email');
codeKey = auth ? auth.id : null;
await auditLog({ ...req, user }, 'auth/requested_email_code_duplicate', { email: payload.email });
},
);
}
return res.json({
token: jwt.sign(
{
...payload,
email: isEmail ? payload.email : null,
emailObfuscated: obfuscateEmail(payload.email),
code: null,
codeKey,
},
'15m',
),
});
}));
router.post('/user/validate', handleErrorAsync(async (req, res) => {
if (!req.rawUser || !req.rawUser.codeKey) {
return res.json({ error: 'user.tokenExpired' });
}
const authenticator = await findAuthenticatorById(req.db, req.rawUser.codeKey, 'email');
if (!authenticator) {
await auditLog(req, 'auth/validate_token_expired');
return res.json({ error: 'user.tokenExpired' });
}
const { attemptCount, firstAttemptAt, block } = await fetchLoginAttempts(req.db, authenticator.userId!);
if (block) {
await auditLog(req, 'auth/validate_too_many_attempts');
return res.json({ error: 'user.tooManyAttempts' });
}
if (authenticator.payload.code !== normalise(req.body.code)) {
await saveLoginAttempts(req.db, authenticator.userId!, attemptCount + 1, firstAttemptAt || new Date());
await auditLog(req, 'auth/validate_code_invalid');
return res.json({ error: 'user.code.invalid' });
}
await invalidateAuthenticator(req.db, authenticator.id);
await auditLog(req, 'auth/validate_code_valid');
return res.json({ token: await issueAuthentication(req.db, req.rawUser, true, true) });
}));
router.post('/user/change-username', handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorised' });
}
const newUsername = replaceExtension(req.body.username);
if (newUsername.length < 4 || newUsername.length > 16 || !newUsername.match(usernameRegex)) {
await auditLog(req, 'auth/change_username_invalid', { requested: newUsername });
return res.json({ error: 'user.account.changeUsername.invalid' });
}
const dbUser = await req.db.get<UserRow>(SQL`SELECT * FROM users WHERE usernameNorm = ${normalise(newUsername)}`);
if (dbUser && dbUser.id !== req.user.id) {
await auditLog(req, 'auth/change_username_taken', { requested: newUsername });
return res.json({ error: 'user.account.changeUsername.taken' });
}
await req.db.get(SQL`UPDATE users SET username = ${newUsername}, usernameNorm = ${normalise(newUsername)} WHERE id = ${req.user.id}`);
await resetCards(req.db, req.user.id);
await auditLog(req, 'auth/changed_username', { newUsername });
return res.json({ token: await issueAuthentication(req.db, req.user) });
}));
router.post('/user/change-email', handleErrorAsync(async (req, res) => {
if (!req.user || req.user.bannedReason || isSpam(req.body.email || '')) {
return res.status(401).json({ error: 'Unauthorised' });
}
if (!await validateEmail(normalise(req.body.email))) {
await auditLog(req, 'auth/email_invalid', { email: req.body.email });
return res.json({ error: 'user.account.changeEmail.invalid' });
}
const dbUser = await req.db.get(SQL`SELECT * FROM users WHERE lower(trim(email)) = ${normalise(req.body.email)}`);
if (dbUser) {
return res.json({ error: 'user.account.changeEmail.taken' });
}
if (!req.body.authId) {
if (!await validateCaptcha(req.body.captchaToken)) {
return res.json({ error: 'captcha.invalid' });
}
const payload = {
from: req.user.email,
to: normalise(req.body.email),
code: makeId(6, '0123456789'),
};
const authId = await saveAuthenticator(req.db, 'changeEmail', req.user, payload, 15);
mailer(payload.to, 'confirmCode', { code: payload.code });
await auditLog(req, 'auth/change_email_requested', {
from: req.user.email,
to: normalise(req.body.email),
});
return res.json({ authId });
}
const authenticator = await findAuthenticatorById(req.db, req.body.authId, 'changeEmail');
if (!authenticator) {
return res.json({ error: 'user.tokenExpired' });
}
if (authenticator.payload.code !== normalise(req.body.code)) {
return res.json({ error: 'user.code.invalid' });
}
await invalidateAuthenticator(req.db, authenticator.id);
await req.db.get(SQL`UPDATE users SET email = ${authenticator.payload.to} WHERE id = ${req.user.id}`);
req.user.email = authenticator.payload.to!;
await auditLog(req, 'auth/changed_email', {
from: authenticator.payload.from,
to: authenticator.payload.to,
});
return res.json({ token: await issueAuthentication(req.db, req.user) });
}));
router.post('/user/delete', handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get('PRAGMA foreign_keys = ON');
await req.db.get(SQL`DELETE FROM users WHERE id = ${req.user.id}`);
await auditLog(req, 'auth/removed_own_account');
return res.json(true);
}));
router.post('/user/data-erasure/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('*') && !req.isGranted('community')) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get('PRAGMA foreign_keys = ON');
await req.db.get(SQL`DELETE FROM users WHERE id = ${req.params.id}`);
await auditLog(req, 'auth/removed_account_by_request', { userId: req.params.id });
return res.json(true);
}));
router.post('/user/:id/set-roles', handleErrorAsync(async (req, res) => {
if (!req.isGranted('*')) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get(SQL`UPDATE users SET roles = ${req.body.roles} WHERE id = ${req.params.id}`);
await auditLog(req, 'auth/granted_roles', { userId: req.params.id, roles: req.body.roles });
return res.json('ok');
}));
// happens on home
router.get('/user/social-redirect/:provider/:locale', handleErrorAsync(async (req, res) => {
assert(req.locales.hasOwnProperty(req.params.locale));
req.session.socialRedirect = req.params.locale;
if (req.query.token) {
res.cookie('token', req.query.token, longtimeCookieSetting);
}
const searchParams: Record<string, string> = {};
if (typeof req.query.instance === 'string') {
searchParams.instance = req.query.instance;
}
return res.redirect(`/api/connect/${req.params.provider}?${new URLSearchParams(searchParams)}`);
}));
// happens on home
router.get('/user/social/:provider', handleErrorAsync(async (req, res) => {
if (!req.session.grant || !req.session.grant.response ||
!req.session.grant.response.access_token && !req.session.grant.response.jwt ||
!socialLoginHandlers[req.params.provider]) {
console.error('Social login failed, session incomplete.', req.params.provider, req.session, req.session.grant?.response);
return res.status(400).json({ error: 'Something went wrong… Please try again.' });
}
const payload: SocialProfilePayload = socialLoginHandlers[req.params.provider](req.session.grant.response);
if (payload.id === undefined) {
console.error('Social login failed, payload has no id.', req.params.provider, payload);
return res.status(400).json({ error: 'Something went wrong… Please try again.' });
}
const auth = await req.db.get<AuthenticatorRow>(SQL`
SELECT * FROM authenticators
WHERE type = ${req.params.provider}
AND payload LIKE ${`{"id":"${payload.id}"%`}
AND (validUntil IS NULL OR validUntil > ${now()})
`);
const user = auth
? await req.db.get<UserRow>(SQL`
SELECT * FROM users
WHERE id = ${auth.userId}
`)
: req.user;
const dbUser = await fetchOrCreateUser(req.db, user || {
email: payload.email || `${payload.id}@${req.params.provider}.oauth`,
name: payload.name,
}, req.params.provider);
const token = await issueAuthentication(req.db, dbUser, false, true);
// invalidate older authenticators of the same type
await invalidateAuthenticatorsOfType(req.db, dbUser.id, req.params.provider);
if (!payload.avatarCopy && payload.avatar) {
payload.avatarCopy = await copyImage(`images-copy/${req.params.provider}`, payload.avatar);
}
await saveAuthenticator(req.db, req.params.provider, dbUser, payload);
const buildRedirectUrl = () => {
if (!req.session.socialRedirect) {
return `/${config.user.route}`;
}
const host = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
? ''
: buildLocaleList(config.locale, true)[req.session.socialRedirect].url;
delete req.session.socialRedirect;
return `${host}/api/user/social-redirect-callback/${encodeURIComponent(token)}`;
};
return res.cookie('token', token, longtimeCookieSetting).redirect(buildRedirectUrl());
}));
// happens on locale
router.get('/user/social-redirect-callback/:token', handleErrorAsync(async (req, res) => {
res.cookie('token', req.params.token, longtimeCookieSetting).redirect(`/${config.user.route}`);
}));
router.get('/user/social-connections', handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorised' });
}
const authenticators = await req.db.all<Pick<AuthenticatorRow, 'type' | 'payload'>>(SQL`
SELECT type, payload FROM authenticators
WHERE type IN (`.append(Object.keys(socialLoginConfig).map((k) => `'${k}'`)
.join(',')).append(SQL`)
AND userId = ${req.user.id}
AND (validUntil IS NULL OR validUntil > ${now()})
`));
return res.json(buildDict(function* () {
for (const auth of authenticators) {
yield [auth.type, JSON.parse(auth.payload)];
}
}));
}));
router.post('/user/social-connection/:provider/disconnect', handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorised' });
}
const auth = await req.db.get<Pick<AuthenticatorRow, 'id'>>(SQL`
SELECT id FROM authenticators
WHERE type = ${req.params.provider}
AND userId = ${req.user.id}
AND (validUntil IS NULL OR validUntil > ${now()})
`);
await invalidateAuthenticator(req.db, auth!.id);
await req.db.get(SQL`
DELETE FROM social_lookup
WHERE userId = ${req.user.id}
AND provider = ${req.params.provider}
`);
await auditLog(req, 'auth/disconnected', { type: req.params.provider });
return res.json('ok');
}));
router.post('/user/set-avatar', handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get(SQL`
UPDATE users
SET avatarSource = ${req.body.source || null}
WHERE id = ${req.user.id}
`);
await resetCards(req.db, req.user.id);
await new CacheObject('banner', `@${req.user.username}.png`, 24 * 60).invalidate();
await auditLog(req, 'auth/changed_avatar', { source: req.body.source });
return res.json({ token: await issueAuthentication(req.db, req.user) });
}));
router.get('/user/init-universal/:token', handleErrorAsync(async (req, res) => {
res.header('Access-Control-Allow-Origin', '*');
if (req.user) {
return res.json('Already logged in');
}
res.cookie('token', req.params.token, longtimeCookieSetting);
return res.json('Token saved');
}));
router.get('/user/logout-universal', handleErrorAsync(async (req, res) => {
res.header('Access-Control-Allow-Origin', '*');
res.clearCookie('token');
return res.json('Token removed');
}));
const canImpersonate = (req: Request) => {
return req.isGranted('*') || req.isGranted('impersonate') ||
(req.isGranted('users') || req.isGranted('community')) && ['example@pronouns.page'].includes(req.params.email)
;
};
router.get('/admin/impersonate/:email', handleErrorAsync(async (req, res) => {
if (!canImpersonate(req)) {
return res.status(401).json({ error: 'Unauthorised' });
}
let email = req.params.email;
if (!email.includes('@')) {
email = (await req.db.get<Pick<UserRow, 'email'>>(SQL`SELECT email FROM users WHERE usernameNorm = ${normalise(email)}`))!.email;
}
await auditLog(req, 'auth/impersonated', { email });
return res.json({ token: await issueAuthentication(req.db, { email }) });
}));
const saveAuthenticatorSocialLookup = async (db: Database, authenticator: Pick<Authenticator, 'userId' | 'type' | 'payload'>) => {
if (socialLookup[authenticator.type] === undefined) {
return;
}
for (const field of socialLookup[authenticator.type]) {
const identifier = authenticator.payload[field];
if (identifier === undefined) {
continue;
}
await db.get(SQL`INSERT INTO social_lookup (userId, provider, identifier) VALUES (
${authenticator.userId},
${authenticator.type},
${identifier}
)`);
}
};
router.post('/user/set-social-lookup', handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get(SQL`
UPDATE users
SET socialLookup = ${req.body.socialLookup ? 1 : 0}
WHERE id = ${req.user.id}
`);
if (req.body.socialLookup) {
for (const authenticator of await findAuthenticatorsByUser(req.db, req.user)) {
await saveAuthenticatorSocialLookup(req.db, authenticator);
}
await auditLog(req, 'auth/enabled_social_lookup');
} else {
await req.db.get(SQL`
DELETE FROM social_lookup
WHERE userId = ${req.user.id}
`);
await auditLog(req, 'auth/disabled_social_lookup');
}
return res.json({ token: await issueAuthentication(req.db, req.user) });
}));
router.get('/user/social-lookup/:provider/:identifier', handleErrorAsync(async (req, res) => {
const identifier = req.params.identifier.trim();
const fields = socialLookup[req.params.provider];
if (fields === undefined || !identifier) {
return res.status(400).json({ error: 'Unsupported provider' });
}
const row = await req.db.get<{ username: string }>(SQL`
SELECT u.username
FROM social_lookup l
LEFT JOIN users u on l.userId = u.id
WHERE l.provider = ${req.params.provider}
AND l.identifier = ${req.params.identifier}
`);
return res.json(row ? row.username : null);
}));
export default router;