import assert from 'assert'; 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 type { Config } from '../../locale/config.ts'; import buildLocaleList from '../../src/buildLocaleList.ts'; import { longtimeCookieSetting } from '../../src/cookieSettings.ts'; import { buildDict, makeId, now, handleErrorAsync, obfuscateEmail, newDate, } from '../../src/helpers.ts'; import type { User } from '../../src/user.ts'; import { usernameRegex, usernameUnsafeRegex } from '../../src/username.ts'; import { auditLog } from '../audit.ts'; import avatar from '../avatar.ts'; import { lookupBanArchive } from '../ban.ts'; import { validateCaptcha } from '../captcha.ts'; import type { Database } from '../db.ts'; import { env } from '../env.ts'; import copyImage from '../imageCopy.ts'; import jwt from '../jwt.ts'; import { loadSuml } from '../loader.ts'; import mailer from '../mailer.ts'; import { config as socialLoginConfig, handlers as socialLoginHandlers, lookup as socialLookup, } from '../social.ts'; import type { SocialProfilePayload } from '../social.ts'; import { addMfaInfo } from './mfa.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 { 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 & Partial>, 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({ 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 => { const authenticator = await db.get(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, type: string | undefined = undefined, ): Promise => { 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(query); return authenticators.map((a) => ({ ...a, payload: JSON.parse(a.payload), })); }; const findLatestEmailAuthenticator = async ( db: Database, email: string, type: string, ): Promise => { const authenticator = await db.get(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>(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 & { 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}, ${+newDate()} )`); } dbUser.avatar = await avatar(db, dbUser); return dbUser; }; export const issueAuthentication = async ( db: Database, user: Pick, 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 await jwt.sign(userAuthentication satisfies User); }; const validateHasMxRecords = async (domain: string): Promise => { 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 => { 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, cbFail: (() => Promise) | undefined, ttl = 5 * 60, ): Promise => { 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(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 = await jwt.validate(token); req.user = req.rawUser!; } next(); }; const resetCards = async (db: Database, id: string): Promise => { 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>(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 => { 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 & Partial>); const dbUser: UserRowOptionalTimesheets | undefined = await req.db.get(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 = await jwt.validate(token); 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 (env === 'development' && usernameOrEmail.endsWith('+')) { isTest = true; usernameOrEmail = usernameOrEmail.substring(0, usernameOrEmail.length - 1); } if (isEmail) { user = await req.db.get(SQL`SELECT * FROM users WHERE email = ${normalise(usernameOrEmail)}`); } else { user = await req.db.get(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({ 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({ 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({ 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({ 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({ user }, 'auth/requested_email_code_duplicate', { email: payload.email }); }, ); } return res.json({ token: await 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(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(Object.hasOwn(req.locales, req.params.locale)); req.session.socialRedirect = req.params.locale; if (req.query.token) { res.cookie('token', req.query.token, longtimeCookieSetting); } const searchParams: Record = {}; if (typeof req.query.instance === 'string') { searchParams.instance = req.query.instance; } return res.redirect(`/api/connect/${req.params.provider}?${new URLSearchParams(searchParams)}`); })); const normaliseExternalId = (id: string): string => id .replace(/@/g, '_') .replace(/^https?:\/\//, '') .replace(new RegExp('/', 'g'), '_'); // 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(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(SQL` SELECT * FROM users WHERE id = ${auth.userId} `) : req.user; const dbUser = await fetchOrCreateUser(req.db, user || { email: payload.email || `${normaliseExternalId(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 = env === 'development' || process.env.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>(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>(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'); })); const isValidAvatarSource = (source: string | null): boolean => { if (source === null) { return true; } if (process.env.CLOUDFRONT && source.startsWith(`${process.env.CLOUDFRONT}/`)) { return true; } if (source === 'gravatar') { return true; } if (Object.keys(socialLoginConfig).includes(source)) { return true; } return false; }; router.post('/user/set-avatar', handleErrorAsync(async (req, res) => { if (!req.user) { return res.status(401).json({ error: 'Unauthorised' }); } const source = req.body.source || null; if (!isValidAvatarSource(source)) { return res.status(400).json({ error: 'Invalid avatar source' }); } await req.db.get(SQL` UPDATE users SET avatarSource = ${source} WHERE id = ${req.user.id} `); await resetCards(req.db, req.user.id); await invalidateCache('banner', `@${req.user.username}`); 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>(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) => { 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;