mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-25 14:09:03 -04:00
938 lines
31 KiB
TypeScript
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;
|