mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-13 05:58:29 +09:00
1450 lines
61 KiB
TypeScript
1450 lines
61 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {
|
|
clearTestEmails,
|
|
createAuthHarness,
|
|
createTestAccount,
|
|
createUniqueUsername,
|
|
findLastTestEmail,
|
|
type LoginSuccessResponse,
|
|
listTestEmails,
|
|
setUserACLs,
|
|
} from '@app/api/auth/tests/AuthTestUtils';
|
|
import {createPasswordResetToken, createUserID} from '@app/api/BrandedTypes';
|
|
import {getConfig} from '@app/api/Config';
|
|
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
|
import {
|
|
getAdminRepository,
|
|
getInstanceConfigRepository,
|
|
getRateLimitService,
|
|
getUserRepository,
|
|
} from '@app/api/middleware/ServiceSingletons';
|
|
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
|
import {type CaptchaErrorBody, solveCaptchaChallenge, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
|
|
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
|
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
|
|
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
|
|
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
|
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
|
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
|
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
|
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
|
|
|
const PASSWORD = 'username-mode-password-123';
|
|
const NEW_PASSWORD = 'username-mode-new-password-456';
|
|
const HAS_SESSION_STARTED = BigInt(1) << BigInt(39);
|
|
|
|
interface ValidationErrorBody {
|
|
code: string;
|
|
errors: Array<{path: string; code: string}>;
|
|
}
|
|
|
|
interface UsernameAccount {
|
|
userId: string;
|
|
token: string;
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
interface CurrentUser {
|
|
id: string;
|
|
username: string;
|
|
discriminator: string;
|
|
email: string | null;
|
|
acls?: Array<string>;
|
|
}
|
|
|
|
const EMAIL_ONLY_PUBLIC_ROUTES: ReadonlyArray<[string, string]> = [
|
|
['POST', '/auth/verify'],
|
|
['POST', '/auth/forgot'],
|
|
['POST', '/auth/email-revert'],
|
|
['POST', '/auth/authorize-ip'],
|
|
['POST', '/auth/ip-authorization/resend'],
|
|
['POST', '/reports/dsa/email/send'],
|
|
['POST', '/reports/dsa/email/verify'],
|
|
];
|
|
|
|
const EMAIL_ONLY_USER_ROUTES: ReadonlyArray<[string, string]> = [
|
|
['POST', '/auth/verify/resend'],
|
|
['POST', '/users/@me/mfa/backup-codes/challenge'],
|
|
['POST', '/users/@me/mfa/backup-codes/challenge/resend'],
|
|
['POST', '/users/@me/mfa/backup-codes/challenge/verify'],
|
|
['POST', '/users/@me/mfa/backup-codes/challenge/regenerate'],
|
|
['POST', '/users/@me/password-change/start'],
|
|
['POST', '/users/@me/password-change/resend'],
|
|
['POST', '/users/@me/password-change/verify'],
|
|
['POST', '/users/@me/password-change/complete'],
|
|
['POST', '/users/@me/email-change/start'],
|
|
['POST', '/users/@me/email-change/resend-original'],
|
|
['POST', '/users/@me/email-change/verify-original'],
|
|
['POST', '/users/@me/email-change/request-new'],
|
|
['POST', '/users/@me/email-change/resend-new'],
|
|
['POST', '/users/@me/email-change/verify-new'],
|
|
['POST', '/users/@me/email-change/apply'],
|
|
['POST', '/users/@me/email-change/bounced/request-new'],
|
|
['POST', '/users/@me/email-change/bounced/resend-new'],
|
|
['POST', '/users/@me/email-change/bounced/verify-new'],
|
|
];
|
|
|
|
describe('Username sign-in', () => {
|
|
let harness: ApiTestHarness;
|
|
const config = getConfig();
|
|
let originalSelfHosted: boolean;
|
|
let originalHosts: {webApp: string; webAppOrigins: Array<string>; baseDomain: string};
|
|
|
|
beforeAll(async () => {
|
|
harness = await createAuthHarness();
|
|
});
|
|
beforeEach(async () => {
|
|
await harness.reset();
|
|
await clearTestEmails(harness);
|
|
originalSelfHosted = config.instance.selfHosted;
|
|
originalHosts = {
|
|
webApp: config.endpoints.webApp,
|
|
webAppOrigins: config.endpoints.webAppOrigins,
|
|
baseDomain: config.instance.baseDomain,
|
|
};
|
|
});
|
|
afterEach(() => {
|
|
config.instance.selfHosted = originalSelfHosted;
|
|
config.endpoints.webApp = originalHosts.webApp;
|
|
config.endpoints.webAppOrigins = originalHosts.webAppOrigins;
|
|
config.instance.baseDomain = originalHosts.baseDomain;
|
|
getInstanceConfigRepository().clearCacheForTesting();
|
|
});
|
|
afterAll(async () => {
|
|
await harness?.shutdown();
|
|
});
|
|
|
|
async function useUsernameMode(): Promise<void> {
|
|
config.instance.selfHosted = true;
|
|
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
|
|
}
|
|
|
|
function useInstanceHosts(): void {
|
|
config.endpoints.webApp = 'https://chat.example.org';
|
|
config.endpoints.webAppOrigins = ['https://chat.example.org', 'https://alias.example.net:8443'];
|
|
config.instance.baseDomain = 'example.org';
|
|
}
|
|
|
|
async function registerFromOlderApp(body: Record<string, unknown>): Promise<{user_id: string; token: string}> {
|
|
return await createBuilderWithoutAuth<{user_id: string; token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({password: PASSWORD, date_of_birth: '2000-01-01', ...body})
|
|
.execute();
|
|
}
|
|
|
|
async function useSelfHostedEmailMode(): Promise<void> {
|
|
config.instance.selfHosted = true;
|
|
await getInstanceConfigRepository().setAccountIdentityMode(
|
|
AccountIdentityModes.EMAIL,
|
|
'existing_instance',
|
|
'random',
|
|
);
|
|
}
|
|
|
|
async function useSelfHostedEmailUniqueMode(): Promise<void> {
|
|
config.instance.selfHosted = true;
|
|
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup', 'none');
|
|
}
|
|
|
|
async function registerEmailAccount(username: string): Promise<UsernameAccount & {email: string}> {
|
|
const email = `${username.toLowerCase()}-${Date.now()}@example.com`;
|
|
const registration = await createBuilderWithoutAuth<{user_id: string; token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({email, username, global_name: 'Email Person', password: PASSWORD, date_of_birth: '2000-01-01'})
|
|
.execute();
|
|
await markSessionStarted(registration.user_id);
|
|
await createBuilder(harness, '')
|
|
.post(`/test/users/${registration.user_id}/security-flags`)
|
|
.body({email_verified: true})
|
|
.execute();
|
|
return {userId: registration.user_id, token: registration.token, username, password: PASSWORD, email};
|
|
}
|
|
|
|
async function markSessionStarted(userId: string): Promise<void> {
|
|
await createBuilder(harness, '')
|
|
.patch(`/test/users/${userId}/flags`)
|
|
.body({flags: HAS_SESSION_STARTED.toString()})
|
|
.execute();
|
|
}
|
|
|
|
async function registerUsernameAccount(username = createUniqueUsername('uname')): Promise<UsernameAccount> {
|
|
const registration = await createBuilderWithoutAuth<{user_id: string; token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({username, global_name: 'Username Person', password: PASSWORD, date_of_birth: '2000-01-01'})
|
|
.execute();
|
|
await markSessionStarted(registration.user_id);
|
|
return {userId: registration.user_id, token: registration.token, username, password: PASSWORD};
|
|
}
|
|
|
|
async function fetchCurrentUser(token: string): Promise<CurrentUser> {
|
|
return await createBuilder<CurrentUser>(harness, token).get('/users/@me').execute();
|
|
}
|
|
|
|
async function expectFieldErrors(
|
|
request: {path: string; method?: string; body: unknown; token?: string},
|
|
expected: Array<{path: string; code: string}>,
|
|
): Promise<void> {
|
|
const builder = createBuilder<ValidationErrorBody>(harness, request.token ?? '');
|
|
const method = (request.method ?? 'POST').toLowerCase() as 'post' | 'patch' | 'put';
|
|
const {json} = await builder[method](request.path)
|
|
.body(request.body)
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.executeWithResponse();
|
|
expect(json.errors.map(({path, code}) => ({path, code}))).toEqual(expected);
|
|
}
|
|
|
|
async function login(body: Record<string, unknown>): Promise<LoginSuccessResponse> {
|
|
return await createBuilderWithoutAuth<LoginSuccessResponse>(harness).post('/auth/login').body(body).execute();
|
|
}
|
|
|
|
async function startSession(token: string): Promise<void> {
|
|
await createBuilder(harness, '')
|
|
.post('/test/rpc-session-init')
|
|
.body({type: 'session', token, version: 1, ip: '127.0.0.1'})
|
|
.execute();
|
|
}
|
|
|
|
async function markPremiumDiscriminator(userId: string): Promise<void> {
|
|
await createBuilder(harness, '')
|
|
.patch(`/test/users/${userId}/premium-flags`)
|
|
.body({premium_flags: PremiumFlags.DISCRIMINATOR})
|
|
.execute();
|
|
}
|
|
|
|
describe('registration', () => {
|
|
it('accepts an email from older apps and never stores it', async () => {
|
|
const existing = await createTestAccount(harness);
|
|
await clearTestEmails(harness);
|
|
await useUsernameMode();
|
|
const registration = await createBuilderWithoutAuth<{user_id: string; token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({
|
|
email: existing.email,
|
|
username: 'withemail',
|
|
password: PASSWORD,
|
|
date_of_birth: '2000-01-01',
|
|
})
|
|
.execute();
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(registration.user_id)));
|
|
expect({email: user.email, emailVerified: user.emailVerified, username: user.username}).toEqual({
|
|
email: null,
|
|
emailVerified: true,
|
|
username: 'withemail',
|
|
});
|
|
expect((await fetchCurrentUser(registration.token)).discriminator).toBe('0000');
|
|
expect(await listTestEmails(harness, {recipient: existing.email})).toEqual([]);
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/register')
|
|
.body({email: 'not-an-address', username: 'withoddemail', password: PASSWORD, date_of_birth: '2000-01-01'})
|
|
.execute();
|
|
});
|
|
|
|
it('takes the username from an address at the instance host', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
const registration = await createBuilderWithoutAuth<{user_id: string; token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({
|
|
email: '[email protected]',
|
|
global_name: 'Shown Name',
|
|
password: PASSWORD,
|
|
date_of_birth: '2000-01-01',
|
|
})
|
|
.execute();
|
|
const me = await fetchCurrentUser(registration.token);
|
|
expect({username: me.username, discriminator: me.discriminator, email: me.email}).toEqual({
|
|
username: 'Local_Person',
|
|
discriminator: '0000',
|
|
email: null,
|
|
});
|
|
expect((await login({email: '[email protected]', password: PASSWORD})).user_id).toBe(
|
|
registration.user_id,
|
|
);
|
|
const fromDomain = await createBuilderWithoutAuth<{user_id: string; token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({email: '[email protected]', password: PASSWORD, date_of_birth: '2000-01-01'})
|
|
.execute();
|
|
expect((await fetchCurrentUser(fromDomain.token)).username).toBe('domainperson');
|
|
});
|
|
|
|
it('refuses a taken username from an address at the instance host', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
await registerUsernameAccount('TakenLocal');
|
|
await expectFieldErrors(
|
|
{
|
|
path: '/auth/register',
|
|
body: {email: '[email protected]', password: PASSWORD, date_of_birth: '2000-01-01'},
|
|
},
|
|
[{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN}],
|
|
);
|
|
});
|
|
|
|
it('asks for an address at the instance host instead of turning a foreign address into a username', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
for (const email of [
|
|
'[email protected]',
|
|
'[email protected]',
|
|
'[email protected]@chat.example.org',
|
|
'[email protected]/elsewhere',
|
|
'secretlocal',
|
|
]) {
|
|
await expectFieldErrors(
|
|
{
|
|
path: '/auth/register',
|
|
body: {email, global_name: 'Visible Name', password: PASSWORD, date_of_birth: '2000-01-01'},
|
|
},
|
|
[{path: 'email', code: ValidationErrorCodes.INSTANCE_ADDRESS_REQUIRED}],
|
|
);
|
|
}
|
|
const {json} = await createBuilderWithoutAuth<{errors: Array<{message: string}>}>(harness)
|
|
.post('/auth/register')
|
|
.body({email: '[email protected]', password: PASSWORD, date_of_birth: '2000-01-01'})
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.executeWithResponse();
|
|
expect(json.errors[0]!.message).toContain('@chat.example.org');
|
|
const named = await registerFromOlderApp({
|
|
email: '[email protected]',
|
|
username: 'chosenname',
|
|
global_name: 'Visible Name',
|
|
});
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(named.user_id)));
|
|
expect({username: user.username, email: user.email}).toEqual({username: 'chosenname', email: null});
|
|
});
|
|
|
|
it('refuses a local part that cannot be a username with the username field codes', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
for (const [email, code] of [
|
|
['[email protected]', ValidationErrorCodes.USERNAME_RESERVED_VALUE],
|
|
['[email protected]', ValidationErrorCodes.USERNAME_CANNOT_CONTAIN_RESERVED_TERMS],
|
|
['alex#[email protected]', ValidationErrorCodes.USERNAME_INVALID_CHARACTERS],
|
|
['[email protected]', ValidationErrorCodes.USERNAME_INVALID_CHARACTERS],
|
|
] as const) {
|
|
await expectFieldErrors(
|
|
{path: '/auth/register', body: {email, password: PASSWORD, date_of_birth: '2000-01-01'}},
|
|
[{path: 'username', code}],
|
|
);
|
|
}
|
|
});
|
|
|
|
it('maps a local part to the same username at sign-up and sign-in', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
for (const [email, username] of [
|
|
['tagged#[email protected]', 'tagged'],
|
|
['zerotag#[email protected]', 'zerotag'],
|
|
['[email protected]', 'dotted_name'],
|
|
] as const) {
|
|
const registration = await registerFromOlderApp({email});
|
|
expect((await fetchCurrentUser(registration.token)).username).toBe(username);
|
|
expect((await login({email, password: PASSWORD})).user_id).toBe(registration.user_id);
|
|
expect((await login({login: username, password: PASSWORD})).user_id).toBe(registration.user_id);
|
|
}
|
|
});
|
|
|
|
it('matches the instance host with a port, in any case, as an IDN and through app origin aliases', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
const withPort = await registerFromOlderApp({email: '[email protected]:8443'});
|
|
expect((await fetchCurrentUser(withPort.token)).username).toBe('portperson');
|
|
const throughAlias = await registerFromOlderApp({email: '[email protected]'});
|
|
expect((await fetchCurrentUser(throughAlias.token)).username).toBe('aliasperson');
|
|
expect((await login({email: '[email protected]:8443', password: PASSWORD})).user_id).toBe(
|
|
throughAlias.user_id,
|
|
);
|
|
config.endpoints.webApp = 'https://bücher.example';
|
|
config.endpoints.webAppOrigins = ['https://bücher.example'];
|
|
const idn = await registerFromOlderApp({email: 'idnperson@Bücher.example'});
|
|
expect((await fetchCurrentUser(idn.token)).username).toBe('idnperson');
|
|
expect((await login({email: '[email protected]', password: PASSWORD})).user_id).toBe(idn.user_id);
|
|
});
|
|
|
|
it('requires a username when a password is given', async () => {
|
|
await useUsernameMode();
|
|
await expectFieldErrors({path: '/auth/register', body: {global_name: 'No Username', password: PASSWORD}}, [
|
|
{path: 'username', code: ValidationErrorCodes.USERNAME_LENGTH_INVALID},
|
|
]);
|
|
});
|
|
|
|
it('creates an email-less verified account and makes the first one the admin', async () => {
|
|
await useUsernameMode();
|
|
const first = await registerUsernameAccount('firstperson');
|
|
const second = await registerUsernameAccount('secondperson');
|
|
const firstUser = await getUserRepository().findUniqueAssert(createUserID(BigInt(first.userId)));
|
|
const secondUser = await getUserRepository().findUniqueAssert(createUserID(BigInt(second.userId)));
|
|
expect(firstUser.email).toBeNull();
|
|
expect(firstUser.emailVerified).toBe(true);
|
|
expect([...firstUser.acls]).toEqual([AdminACLs.WILDCARD]);
|
|
expect([...secondUser.acls]).toEqual([]);
|
|
expect(await listTestEmails(harness)).toEqual([]);
|
|
});
|
|
|
|
it('refuses a username that is already taken in any case', async () => {
|
|
await useUsernameMode();
|
|
await registerUsernameAccount('TakenName');
|
|
await expectFieldErrors({path: '/auth/register', body: {username: 'takenname', password: PASSWORD}}, [
|
|
{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN},
|
|
]);
|
|
});
|
|
|
|
it('derives a free username for an unclaimed account', async () => {
|
|
await useUsernameMode();
|
|
await registerUsernameAccount('sharedname');
|
|
const unclaimed = await createBuilderWithoutAuth<{user_id: string; token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({global_name: 'sharedname'})
|
|
.execute();
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(unclaimed.user_id)));
|
|
expect(user.username.toLowerCase()).not.toBe('sharedname');
|
|
expect(user.username.toLowerCase().startsWith('sharedname')).toBe(true);
|
|
expect(user.isUnclaimedAccount()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('login', () => {
|
|
it('signs in with a username in any case and with the full tag', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('LoginPerson');
|
|
const me = await fetchCurrentUser(account.token);
|
|
const byName = await login({login: 'loginperson', password: PASSWORD});
|
|
expect(byName.user_id).toBe(account.userId);
|
|
const byTag = await login({login: `LoginPerson#${me.discriminator.padStart(4, '0')}`, password: PASSWORD});
|
|
expect(byTag.user_id).toBe(account.userId);
|
|
});
|
|
|
|
it('returns the same error for a wrong password, a wrong tag and an unknown username', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('wrongcreds');
|
|
const me = await fetchCurrentUser(account.token);
|
|
const wrongTag = ((Number(me.discriminator) % 9999) + 1).toString().padStart(4, '0');
|
|
const expected = [
|
|
{path: 'login', code: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD},
|
|
{path: 'password', code: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD},
|
|
];
|
|
await expectFieldErrors({path: '/auth/login', body: {login: 'wrongcreds', password: NEW_PASSWORD}}, expected);
|
|
await expectFieldErrors(
|
|
{path: '/auth/login', body: {login: `wrongcreds#${wrongTag}`, password: PASSWORD}},
|
|
expected,
|
|
);
|
|
await expectFieldErrors({path: '/auth/login', body: {login: 'nobodyhere', password: PASSWORD}}, expected);
|
|
await expectFieldErrors({path: '/auth/login', body: {login: 'not a handle!', password: PASSWORD}}, expected);
|
|
});
|
|
|
|
it('signs older apps in with a username or username@host in the email field', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
const account = await registerUsernameAccount('OldAppPerson');
|
|
for (const email of [
|
|
'oldappperson',
|
|
'[email protected]',
|
|
'[email protected]',
|
|
'[email protected]',
|
|
'oldappperson#[email protected]',
|
|
'oldappperson#[email protected]',
|
|
]) {
|
|
expect((await login({email, password: PASSWORD})).user_id).toBe(account.userId);
|
|
}
|
|
});
|
|
|
|
it('gives older apps the wrong-credentials error on the email field', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
await registerUsernameAccount('oldappwrong');
|
|
const expected = [
|
|
{path: 'email', code: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD},
|
|
{path: 'password', code: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD},
|
|
];
|
|
for (const body of [
|
|
{email: '[email protected]', password: NEW_PASSWORD},
|
|
{email: 'oldappwrong', password: NEW_PASSWORD},
|
|
{email: '[email protected]', password: PASSWORD},
|
|
{email: '[email protected]', password: PASSWORD},
|
|
{email: 'oldappwrong#[email protected]', password: PASSWORD},
|
|
{email: '[email protected]@chat.example.org', password: PASSWORD},
|
|
{email: '[email protected]/elsewhere', password: PASSWORD},
|
|
{email: '[email protected]', password: PASSWORD},
|
|
{email: '[email protected]', login: 'oldappwrong', password: NEW_PASSWORD},
|
|
]) {
|
|
await expectFieldErrors({path: '/auth/login', body}, expected);
|
|
}
|
|
});
|
|
|
|
it('reports a missing identifier on email', async () => {
|
|
await useUsernameMode();
|
|
await expectFieldErrors({path: '/auth/login', body: {password: PASSWORD}}, [
|
|
{path: 'email', code: ValidationErrorCodes.INVALID_FORMAT},
|
|
]);
|
|
});
|
|
|
|
it('limits failed sign-ins per username and source without locking out other sources', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('lockouttarget');
|
|
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
|
|
const previous = {testMode: config.dev.testModeEnabled, disabled: config.dev.disableRateLimits};
|
|
config.dev.testModeEnabled = false;
|
|
config.dev.disableRateLimits = false;
|
|
try {
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.40')
|
|
.body({login: account.username, password: NEW_PASSWORD})
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.execute();
|
|
}
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.40')
|
|
.body({login: account.username.toUpperCase(), password: PASSWORD})
|
|
.expect(429)
|
|
.execute();
|
|
const owner = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.41')
|
|
.body({login: account.username, password: PASSWORD})
|
|
.execute();
|
|
expect(owner.user_id).toBe(account.userId);
|
|
} finally {
|
|
config.dev.testModeEnabled = previous.testMode;
|
|
config.dev.disableRateLimits = previous.disabled;
|
|
}
|
|
});
|
|
|
|
it('lets the owner through a spent shared limit from a known address or with a solved captcha', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('floodtarget');
|
|
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
|
|
const previous = {testMode: config.dev.testModeEnabled, disabled: config.dev.disableRateLimits};
|
|
config.dev.testModeEnabled = false;
|
|
config.dev.disableRateLimits = false;
|
|
const attempt = (ip: string, password: string, captchaToken?: string) => {
|
|
const builder = createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', ip)
|
|
.body({login: account.username, password});
|
|
if (captchaToken) builder.header('X-Captcha-Token', captchaToken);
|
|
return builder;
|
|
};
|
|
try {
|
|
expect((await attempt('198.51.100.90', PASSWORD).execute()).user_id).toBe(account.userId);
|
|
for (let source = 0; source < 20; source++) {
|
|
for (let failure = 0; failure < 5; failure++) {
|
|
await attempt(`203.0.113.${source + 1}`, NEW_PASSWORD)
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.execute();
|
|
}
|
|
}
|
|
await attempt('198.51.100.91', NEW_PASSWORD).expect(429).execute();
|
|
await attempt('198.51.100.92', PASSWORD).expect(429).execute();
|
|
expect((await attempt('198.51.100.90', PASSWORD).execute()).user_id).toBe(account.userId);
|
|
await useCheapCaptcha();
|
|
const {json} = await createBuilderWithoutAuth<CaptchaErrorBody>(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.93')
|
|
.body({login: account.username, password: PASSWORD})
|
|
.expect(400, APIErrorCodes.CAPTCHA_REQUIRED)
|
|
.executeWithResponse();
|
|
const token = await solveCaptchaChallenge(json);
|
|
expect((await attempt('198.51.100.93', PASSWORD, token).execute()).user_id).toBe(account.userId);
|
|
} finally {
|
|
config.dev.testModeEnabled = previous.testMode;
|
|
config.dev.disableRateLimits = previous.disabled;
|
|
}
|
|
});
|
|
|
|
it('counts only failed sign-ins against the shared per-username limit', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('sharedcount');
|
|
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
|
|
const previous = {testMode: config.dev.testModeEnabled, disabled: config.dev.disableRateLimits};
|
|
config.dev.testModeEnabled = false;
|
|
config.dev.disableRateLimits = false;
|
|
try {
|
|
for (let source = 0; source < 20; source++) {
|
|
for (let success = 0; success < 5; success++) {
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', `203.0.113.${source + 101}`)
|
|
.body({login: account.username, password: PASSWORD})
|
|
.execute();
|
|
}
|
|
}
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.95')
|
|
.body({login: account.username, password: NEW_PASSWORD})
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.execute();
|
|
} finally {
|
|
config.dev.testModeEnabled = previous.testMode;
|
|
config.dev.disableRateLimits = previous.disabled;
|
|
}
|
|
});
|
|
|
|
it('never lets crafted identifiers fill another source bucket for a named user', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('bucketvictim');
|
|
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
|
|
const previous = {testMode: config.dev.testModeEnabled, disabled: config.dev.disableRateLimits};
|
|
config.dev.testModeEnabled = false;
|
|
config.dev.disableRateLimits = false;
|
|
const victimSource = getSameIpDecisionKey('198.51.100.71') ?? '198.51.100.71';
|
|
try {
|
|
for (const [field, attackerIp] of [
|
|
['email', '198.51.100.70'],
|
|
['login', '198.51.100.72'],
|
|
] as const) {
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', attackerIp)
|
|
.body({[field]: `${account.username}:${victimSource}`, password: NEW_PASSWORD})
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.execute();
|
|
}
|
|
}
|
|
const owner = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.71')
|
|
.body({login: account.username, password: PASSWORD})
|
|
.execute();
|
|
expect(owner.user_id).toBe(account.userId);
|
|
} finally {
|
|
config.dev.testModeEnabled = previous.testMode;
|
|
config.dev.disableRateLimits = previous.disabled;
|
|
}
|
|
});
|
|
|
|
it('counts older-app sign-ins against the same per-username limit', async () => {
|
|
await useUsernameMode();
|
|
useInstanceHosts();
|
|
const account = await registerUsernameAccount('oldapplimit');
|
|
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
|
|
const previous = {testMode: config.dev.testModeEnabled, disabled: config.dev.disableRateLimits};
|
|
config.dev.testModeEnabled = false;
|
|
config.dev.disableRateLimits = false;
|
|
try {
|
|
for (const email of ['oldapplimit', '[email protected]', '[email protected]']) {
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.50')
|
|
.body({email, password: NEW_PASSWORD})
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.execute();
|
|
}
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.50')
|
|
.body({login: account.username, password: NEW_PASSWORD})
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.execute();
|
|
}
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '198.51.100.50')
|
|
.body({email: '[email protected]', password: PASSWORD})
|
|
.expect(429)
|
|
.execute();
|
|
} finally {
|
|
config.dev.testModeEnabled = previous.testMode;
|
|
config.dev.disableRateLimits = previous.disabled;
|
|
}
|
|
});
|
|
|
|
it('never asks for IP authorization', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('newipperson');
|
|
const result = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '203.0.113.77')
|
|
.body({login: account.username, password: PASSWORD})
|
|
.execute();
|
|
expect(result.user_id).toBe(account.userId);
|
|
expect(await listTestEmails(harness)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('email-only routes', () => {
|
|
it('refuses every email route before minting anything', async () => {
|
|
await useUsernameMode();
|
|
const admin = await registerUsernameAccount('emailrouteadmin');
|
|
const account = await registerUsernameAccount('emailrouteuser');
|
|
for (const [method, path] of EMAIL_ONLY_PUBLIC_ROUTES) {
|
|
const {response, json} = await createBuilderWithoutAuth<{code: string}>(harness)
|
|
[method.toLowerCase() as 'post'](path)
|
|
.body({})
|
|
.executeRaw();
|
|
expect({path, status: response.status, code: json?.code}).toEqual({
|
|
path,
|
|
status: 400,
|
|
code: APIErrorCodes.EMAIL_UNAVAILABLE_ON_INSTANCE,
|
|
});
|
|
}
|
|
for (const [method, path] of EMAIL_ONLY_USER_ROUTES) {
|
|
const {response, json} = await createBuilder<{code: string}>(harness, account.token)
|
|
[method.toLowerCase() as 'post'](path)
|
|
.body({})
|
|
.executeRaw();
|
|
expect({path, status: response.status, code: json?.code}).toEqual({
|
|
path,
|
|
status: 400,
|
|
code: APIErrorCodes.EMAIL_UNAVAILABLE_ON_INSTANCE,
|
|
});
|
|
}
|
|
for (const [method, path] of [
|
|
['post', `/admin/users/${account.userId}/password-reset`],
|
|
['post', `/admin/users/${account.userId}/verification-email`],
|
|
['patch', `/admin/users/${account.userId}/email`],
|
|
['put', `/admin/users/${account.userId}/email-verification`],
|
|
] as const) {
|
|
const {response, json} = await createBuilder<{code: string}>(harness, admin.token)
|
|
[method](path)
|
|
.body({})
|
|
.executeRaw();
|
|
expect({path, status: response.status, code: json?.code}).toEqual({
|
|
path,
|
|
status: 400,
|
|
code: APIErrorCodes.EMAIL_UNAVAILABLE_ON_INSTANCE,
|
|
});
|
|
}
|
|
expect(await listTestEmails(harness)).toEqual([]);
|
|
});
|
|
|
|
it('treats a missing email verification as satisfied', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('unverifiedguild');
|
|
await createBuilder(harness, '')
|
|
.post(`/test/users/${account.userId}/security-flags`)
|
|
.body({email_verified: false})
|
|
.execute();
|
|
await createBuilder(harness, account.token).post('/guilds').body({name: 'Username Guild'}).execute();
|
|
});
|
|
});
|
|
|
|
describe('usernames stay unique', () => {
|
|
it('refuses a rename to a name someone else holds', async () => {
|
|
await useUsernameMode();
|
|
await registerUsernameAccount('holder');
|
|
const other = await registerUsernameAccount('renamer');
|
|
await expectFieldErrors(
|
|
{
|
|
path: '/users/@me',
|
|
method: 'PATCH',
|
|
token: other.token,
|
|
body: {username: 'HOLDER', password: PASSWORD},
|
|
},
|
|
[{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN}],
|
|
);
|
|
const renamed = await createBuilder<CurrentUser>(harness, other.token)
|
|
.patch('/users/@me')
|
|
.body({username: 'freshname', password: PASSWORD})
|
|
.execute();
|
|
expect(renamed.username).toBe('freshname');
|
|
});
|
|
|
|
it('refuses an admin rename to a name someone else holds', async () => {
|
|
await useUsernameMode();
|
|
const admin = await registerUsernameAccount('renameadmin');
|
|
await registerUsernameAccount('adminholder');
|
|
const target = await registerUsernameAccount('admintarget');
|
|
const {response, json} = await createBuilder<ValidationErrorBody>(harness, admin.token)
|
|
.patch(`/admin/users/${target.userId}/username`)
|
|
.body({username: 'AdminHolder'})
|
|
.executeRaw();
|
|
expect(response.status).toBe(400);
|
|
expect(json.errors.map(({path, code}) => ({path, code}))).toEqual([
|
|
{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN},
|
|
]);
|
|
});
|
|
|
|
it('lets an unclaimed account claim with a username and password', async () => {
|
|
await useUsernameMode();
|
|
const unclaimed = await createBuilderWithoutAuth<{user_id: string; token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({global_name: 'Guest'})
|
|
.execute();
|
|
await markSessionStarted(unclaimed.user_id);
|
|
const claimed = await createBuilder<CurrentUser>(harness, unclaimed.token)
|
|
.patch('/users/@me')
|
|
.body({username: 'claimedguest', new_password: PASSWORD})
|
|
.execute();
|
|
expect({username: claimed.username, discriminator: claimed.discriminator}).toEqual({
|
|
username: 'claimedguest',
|
|
discriminator: '0000',
|
|
});
|
|
const result = await login({login: 'claimedguest', password: PASSWORD});
|
|
expect(result.user_id).toBe(unclaimed.user_id);
|
|
});
|
|
});
|
|
|
|
describe('username availability', () => {
|
|
async function checkAvailability(username: string): Promise<boolean> {
|
|
const result = await createBuilderWithoutAuth<{available: boolean}>(harness)
|
|
.get(`/auth/username-availability?username=${encodeURIComponent(username)}`)
|
|
.execute();
|
|
return result.available;
|
|
}
|
|
|
|
async function expectAvailabilityFieldError(username: string, code: string): Promise<void> {
|
|
const {json} = await createBuilderWithoutAuth<ValidationErrorBody>(harness)
|
|
.get(`/auth/username-availability?username=${encodeURIComponent(username)}`)
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.executeWithResponse();
|
|
expect(json.errors.map(({path, code}) => ({path, code}))).toEqual([{path: 'username', code}]);
|
|
}
|
|
|
|
it('reports a held name as taken in any case and a free one as available', async () => {
|
|
await useUsernameMode();
|
|
await registerUsernameAccount('HeldName');
|
|
expect(await checkAvailability('heldname')).toBe(false);
|
|
expect(await checkAvailability('HELDNAME')).toBe(false);
|
|
expect(await checkAvailability(' heldname ')).toBe(false);
|
|
expect(await checkAvailability('freename')).toBe(true);
|
|
});
|
|
|
|
it('rejects an invalid or reserved name with the registration field codes', async () => {
|
|
await useUsernameMode();
|
|
await expectAvailabilityFieldError('not valid', ValidationErrorCodes.USERNAME_INVALID_CHARACTERS);
|
|
await expectAvailabilityFieldError('everyone', ValidationErrorCodes.USERNAME_RESERVED_VALUE);
|
|
await expectAvailabilityFieldError('myfluxername', ValidationErrorCodes.USERNAME_CANNOT_CONTAIN_RESERVED_TERMS);
|
|
await expectAvailabilityFieldError('a'.repeat(33), ValidationErrorCodes.USERNAME_LENGTH_INVALID);
|
|
});
|
|
|
|
it('refuses an email instance with tags and the hosted service', async () => {
|
|
await useSelfHostedEmailMode();
|
|
await createBuilderWithoutAuth(harness)
|
|
.get('/auth/username-availability?username=anyname')
|
|
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
|
|
.execute();
|
|
config.instance.selfHosted = false;
|
|
await createBuilderWithoutAuth(harness)
|
|
.get('/auth/username-availability?username=anyname')
|
|
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
|
|
.execute();
|
|
});
|
|
});
|
|
|
|
describe('password change', () => {
|
|
it('is username mode only', async () => {
|
|
const account = await createTestAccount(harness);
|
|
await createBuilder(harness, account.token)
|
|
.post('/users/@me/password')
|
|
.body({password: account.password, new_password: NEW_PASSWORD})
|
|
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
|
|
.execute();
|
|
});
|
|
|
|
it('needs sudo, replaces the session and ends every other session', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('changepassword');
|
|
const otherSession = await login({login: account.username, password: PASSWORD});
|
|
await createBuilder(harness, account.token)
|
|
.post('/users/@me/password')
|
|
.body({new_password: NEW_PASSWORD})
|
|
.expect(403, APIErrorCodes.SUDO_MODE_REQUIRED)
|
|
.execute();
|
|
const result = await createBuilder<{token: string; auth_session_id_hash: string}>(harness, account.token)
|
|
.post('/users/@me/password')
|
|
.body({password: PASSWORD, new_password: NEW_PASSWORD})
|
|
.execute();
|
|
expect(result.token.length).toBeGreaterThan(0);
|
|
expect(result.auth_session_id_hash.length).toBeGreaterThan(0);
|
|
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
|
|
await createBuilder(harness, otherSession.token).get('/users/@me').expect(401).execute();
|
|
expect((await fetchCurrentUser(result.token)).id).toBe(account.userId);
|
|
await expectFieldErrors({path: '/auth/login', body: {login: account.username, password: PASSWORD}}, [
|
|
{path: 'login', code: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD},
|
|
{path: 'password', code: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD},
|
|
]);
|
|
expect((await login({login: account.username, password: NEW_PASSWORD})).user_id).toBe(account.userId);
|
|
});
|
|
});
|
|
|
|
describe('single sign-on accounts without a password', () => {
|
|
it('cannot set a password or create a recovery kit with an empty sudo body', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('ssoperson');
|
|
const users = getUserRepository();
|
|
const userId = createUserID(BigInt(account.userId));
|
|
const user = await users.findUniqueAssert(userId);
|
|
await users.patchUpsert(userId, {password_hash: null, traits: new Set(['sso'])}, user.toRow());
|
|
await expectFieldErrors({path: '/users/@me/password', token: account.token, body: {new_password: NEW_PASSWORD}}, [
|
|
{path: 'new_password', code: ValidationErrorCodes.PASSWORD_NOT_SET},
|
|
]);
|
|
await expectFieldErrors({path: '/users/@me/recovery-kit', token: account.token, body: {}}, [
|
|
{path: 'password', code: ValidationErrorCodes.PASSWORD_NOT_SET},
|
|
]);
|
|
expect((await users.findUniqueAssert(userId)).passwordHash).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('admin password reset link', () => {
|
|
it('is username mode only', async () => {
|
|
const admin = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]);
|
|
const target = await createTestAccount(harness);
|
|
await createBuilder(harness, admin.token)
|
|
.post(`/admin/users/${target.userId}/password-reset-link`)
|
|
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
|
|
.execute();
|
|
});
|
|
|
|
it('needs its own ACL', async () => {
|
|
await useUsernameMode();
|
|
await registerUsernameAccount('bootstrapadmin');
|
|
const limitedAdmin = await registerUsernameAccount('limitedadmin');
|
|
await createBuilder(harness, '')
|
|
.post(`/test/users/${limitedAdmin.userId}/acls`)
|
|
.body({acls: [AdminACLs.AUTHENTICATE, AdminACLs.USER_UPDATE_EMAIL]})
|
|
.execute();
|
|
const target = await registerUsernameAccount('linktargetacl');
|
|
await createBuilder(harness, limitedAdmin.token)
|
|
.post(`/admin/users/${target.userId}/password-reset-link`)
|
|
.expect(403)
|
|
.execute();
|
|
});
|
|
|
|
it('issues a link that resets the password through /auth/reset and is audited', async () => {
|
|
await useUsernameMode();
|
|
const admin = await registerUsernameAccount('resetadmin');
|
|
const target = await registerUsernameAccount('resettarget');
|
|
const before = Date.now();
|
|
const link = await createBuilder<{url: string; expires_at: string}>(harness, admin.token)
|
|
.post(`/admin/users/${target.userId}/password-reset-link`)
|
|
.execute();
|
|
const [base, token] = link.url.split('/reset#token=');
|
|
expect(base).toBe(config.email.appBaseUrl);
|
|
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
const expiresAt = Date.parse(link.expires_at);
|
|
expect(expiresAt).toBeGreaterThan(before + 59 * 60 * 1000);
|
|
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 60 * 60 * 1000);
|
|
const validation = await createBuilderWithoutAuth<{valid: boolean}>(harness)
|
|
.get(`/auth/reset/${token}`)
|
|
.execute();
|
|
expect(validation.valid).toBe(true);
|
|
const reset = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
|
.post('/auth/reset')
|
|
.body({token, password: NEW_PASSWORD})
|
|
.execute();
|
|
expect(reset.user_id).toBe(target.userId);
|
|
await createBuilder(harness, target.token).get('/users/@me').expect(401).execute();
|
|
expect((await login({login: target.username, password: NEW_PASSWORD})).user_id).toBe(target.userId);
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/reset')
|
|
.body({token, password: PASSWORD})
|
|
.expect(400)
|
|
.execute();
|
|
const audit = (await getAdminRepository().listAllAuditLogsPaginated(1000)).find(
|
|
(log) => log.action === 'create_password_reset_link',
|
|
);
|
|
expect(audit?.targetId.toString()).toBe(target.userId);
|
|
expect(Object.fromEntries(audit!.metadata)).toEqual({});
|
|
expect(await listTestEmails(harness)).toEqual([]);
|
|
});
|
|
|
|
it('revokes earlier links when a new one is created', async () => {
|
|
await useUsernameMode();
|
|
const admin = await registerUsernameAccount('revokeadmin');
|
|
const target = await registerUsernameAccount('revoketarget');
|
|
const createLink = async () =>
|
|
(
|
|
await createBuilder<{url: string}>(harness, admin.token)
|
|
.post(`/admin/users/${target.userId}/password-reset-link`)
|
|
.execute()
|
|
).url.split('/reset#token=')[1]!;
|
|
const first = await createLink();
|
|
const second = await createLink();
|
|
const validate = async (token: string) =>
|
|
(await createBuilderWithoutAuth<{valid: boolean}>(harness).get(`/auth/reset/${token}`).execute()).valid;
|
|
expect(await validate(first)).toBe(false);
|
|
expect(await validate(second)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('tags', () => {
|
|
const DISCRIMINATOR_REFUSED = [
|
|
{path: 'discriminator', code: ValidationErrorCodes.DISCRIMINATOR_NOT_SUPPORTED_ON_INSTANCE},
|
|
];
|
|
|
|
it('keeps unique names when username mode is given a random tag style', async () => {
|
|
config.instance.selfHosted = true;
|
|
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup', 'random');
|
|
expect(await getInstanceConfigRepository().getTagStyle()).toBe('none');
|
|
const account = await registerUsernameAccount('ForcedUnique');
|
|
expect((await fetchCurrentUser(account.token)).discriminator).toBe('0000');
|
|
await expectFieldErrors(
|
|
{
|
|
path: '/auth/register',
|
|
body: {username: 'forcedunique', password: PASSWORD, date_of_birth: '2000-01-01'},
|
|
},
|
|
[{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN}],
|
|
);
|
|
});
|
|
|
|
it('gives every new person the 0000 tag', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('tagperson');
|
|
expect((await fetchCurrentUser(account.token)).discriminator).toBe('0000');
|
|
const unclaimed = await createBuilderWithoutAuth<{user_id: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({global_name: 'tagperson'})
|
|
.execute();
|
|
const unclaimedUser = await getUserRepository().findUniqueAssert(createUserID(BigInt(unclaimed.user_id)));
|
|
expect(unclaimedUser.discriminator).toBe(0);
|
|
expect(unclaimedUser.username.toLowerCase()).not.toBe('tagperson');
|
|
});
|
|
|
|
it('never lets two people hold the same name', async () => {
|
|
await useUsernameMode();
|
|
await registerUsernameAccount('OnlyOne');
|
|
for (const username of ['onlyone', 'ONLYONE', 'DeletedUser']) {
|
|
await expectFieldErrors({path: '/auth/register', body: {username, password: PASSWORD}}, [
|
|
{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN},
|
|
]);
|
|
}
|
|
});
|
|
|
|
it('keeps a name held while an admin deletion is pending', async () => {
|
|
await useUsernameMode();
|
|
const admin = await registerUsernameAccount('heldadmin');
|
|
const target = await registerUsernameAccount('PendingGone');
|
|
await createBuilder(harness, admin.token)
|
|
.put(`/admin/users/${target.userId}/deletion`)
|
|
.body({reason_code: 2, days_until_deletion: 30})
|
|
.execute();
|
|
await expectFieldErrors({path: '/auth/register', body: {username: 'pendinggone', password: PASSWORD}}, [
|
|
{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN},
|
|
]);
|
|
});
|
|
|
|
it('signs in with name#0 and name#0000 and refuses any other tag', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('ZeroTag');
|
|
expect((await login({login: 'zerotag#0', password: PASSWORD})).user_id).toBe(account.userId);
|
|
expect((await login({login: 'ZeroTag#0000', password: PASSWORD})).user_id).toBe(account.userId);
|
|
await expectFieldErrors({path: '/auth/login', body: {login: 'zerotag#0001', password: PASSWORD}}, [
|
|
{path: 'login', code: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD},
|
|
{path: 'password', code: ValidationErrorCodes.INVALID_LOGIN_OR_PASSWORD},
|
|
]);
|
|
});
|
|
|
|
it('refuses a tag change, even with the custom tag perk, and keeps 0000 through renames', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('fixedtag');
|
|
await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION);
|
|
await expectFieldErrors(
|
|
{path: '/users/@me', method: 'PATCH', token: account.token, body: {discriminator: '1234', password: PASSWORD}},
|
|
DISCRIMINATOR_REFUSED,
|
|
);
|
|
await expectFieldErrors(
|
|
{
|
|
path: '/users/@me',
|
|
method: 'PATCH',
|
|
token: account.token,
|
|
body: {username: 'fixedtagtwo', discriminator: '0042', password: PASSWORD},
|
|
},
|
|
DISCRIMINATOR_REFUSED,
|
|
);
|
|
const unchanged = await createBuilder<CurrentUser>(harness, account.token)
|
|
.patch('/users/@me')
|
|
.body({discriminator: '0000', password: PASSWORD})
|
|
.execute();
|
|
expect(unchanged.discriminator).toBe('0000');
|
|
const renamed = await createBuilder<CurrentUser>(harness, account.token)
|
|
.patch('/users/@me')
|
|
.body({username: 'FixedTagTwo', password: PASSWORD})
|
|
.execute();
|
|
expect({username: renamed.username, discriminator: renamed.discriminator}).toEqual({
|
|
username: 'FixedTagTwo',
|
|
discriminator: '0000',
|
|
});
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(account.userId)));
|
|
expect(user.premiumFlags & PremiumFlags.DISCRIMINATOR).toBe(0);
|
|
});
|
|
|
|
it('refuses an admin tag change and keeps 0000 on admin renames', async () => {
|
|
await useUsernameMode();
|
|
const admin = await registerUsernameAccount('tagadmin');
|
|
const target = await registerUsernameAccount('tagtarget');
|
|
const {response, json} = await createBuilder<ValidationErrorBody>(harness, admin.token)
|
|
.patch(`/admin/users/${target.userId}/username`)
|
|
.body({username: 'tagtargetnew', discriminator: '0007'})
|
|
.executeRaw();
|
|
expect(response.status).toBe(400);
|
|
expect(json.errors.map(({path, code}) => ({path, code}))).toEqual(DISCRIMINATOR_REFUSED);
|
|
await createBuilder(harness, admin.token)
|
|
.patch(`/admin/users/${target.userId}/username`)
|
|
.body({username: 'TagTargetNew'})
|
|
.execute();
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(target.userId)));
|
|
expect({username: user.username, discriminator: user.discriminator}).toEqual({
|
|
username: 'TagTargetNew',
|
|
discriminator: 0,
|
|
});
|
|
});
|
|
|
|
it('skips the premium tag reroll at session start', async () => {
|
|
await useUsernameMode();
|
|
const account = await registerUsernameAccount('noreroll');
|
|
await markPremiumDiscriminator(account.userId);
|
|
const previousPremiumMode = getCachedInstancePremiumMode();
|
|
setCachedInstancePremiumMode('mirror');
|
|
try {
|
|
await startSession(account.token);
|
|
} finally {
|
|
setCachedInstancePremiumMode(previousPremiumMode);
|
|
}
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(account.userId)));
|
|
expect(user.discriminator).toBe(0);
|
|
expect(user.premiumFlags & PremiumFlags.DISCRIMINATOR).toBe(PremiumFlags.DISCRIMINATOR);
|
|
});
|
|
|
|
it('keeps random tags for bots', async () => {
|
|
await useUsernameMode();
|
|
const owner = await registerUsernameAccount('botowner');
|
|
const app = await createBuilder<{id: string; bot?: {discriminator: string}}>(harness, owner.token)
|
|
.post('/oauth2/applications')
|
|
.body({name: 'botowner', redirect_uris: []})
|
|
.execute();
|
|
expect(app.bot?.discriminator).toMatch(/^\d{4}$/);
|
|
expect(app.bot?.discriminator).not.toBe('0000');
|
|
});
|
|
|
|
it('checks tag availability by name alone', async () => {
|
|
await useUsernameMode();
|
|
await registerUsernameAccount('heldtag');
|
|
const account = await registerUsernameAccount('checktag');
|
|
const checkTag = async (username: string, discriminator: string) =>
|
|
(
|
|
await createBuilder<{taken: boolean}>(harness, account.token)
|
|
.get(`/users/check-tag?username=${username}&discriminator=${discriminator}`)
|
|
.execute()
|
|
).taken;
|
|
expect(await checkTag('HeldTag', '0000')).toBe(true);
|
|
expect(await checkTag('heldtag', '1234')).toBe(true);
|
|
expect(await checkTag('CheckTag', '0000')).toBe(false);
|
|
expect(await checkTag('freetag', '0000')).toBe(false);
|
|
});
|
|
|
|
it('shows bare names in the change log and finds people by bare name', async () => {
|
|
await useUsernameMode();
|
|
const admin = await registerUsernameAccount('logadmin');
|
|
const target = await registerUsernameAccount('logbefore');
|
|
await createBuilder(harness, target.token)
|
|
.patch('/users/@me')
|
|
.body({username: 'logafter', password: PASSWORD})
|
|
.execute();
|
|
const log = await createBuilder<{entries: Array<{field: string; old_value: string; new_value: string}>}>(
|
|
harness,
|
|
admin.token,
|
|
)
|
|
.get(`/admin/users/${target.userId}/change-log`)
|
|
.execute();
|
|
const tagChanges = log.entries
|
|
.filter((entry) => entry.field === 'fluxer_tag')
|
|
.map(({old_value, new_value}) => ({old_value, new_value}));
|
|
expect(tagChanges).toEqual([{old_value: 'logbefore', new_value: 'logafter'}]);
|
|
const found = await createBuilder<{users: Array<{id: string}>}>(harness, admin.token)
|
|
.get('/admin/users?resolve=LogAfter')
|
|
.execute();
|
|
expect(found.users.map((user) => user.id)).toEqual([target.userId]);
|
|
});
|
|
});
|
|
|
|
describe('email mode with unique usernames', () => {
|
|
const TAG_REFUSED = [{path: 'discriminator', code: ValidationErrorCodes.DISCRIMINATOR_NOT_SUPPORTED_ON_INSTANCE}];
|
|
|
|
it('gives email registrations 0000 and refuses a taken name in any case', async () => {
|
|
await useSelfHostedEmailUniqueMode();
|
|
const account = await registerEmailAccount('EmailUnique');
|
|
expect((await fetchCurrentUser(account.token)).discriminator).toBe('0000');
|
|
await expectFieldErrors(
|
|
{
|
|
path: '/auth/register',
|
|
body: {email: '[email protected]', username: 'emailunique', password: PASSWORD},
|
|
},
|
|
[{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN}],
|
|
);
|
|
});
|
|
|
|
it('keeps email sign-in and refuses a username login', async () => {
|
|
await useSelfHostedEmailUniqueMode();
|
|
const account = await registerEmailAccount('emailsignin');
|
|
expect((await login({email: account.email, password: PASSWORD})).user_id).toBe(account.userId);
|
|
await expectFieldErrors({path: '/auth/login', body: {login: 'emailsignin', password: PASSWORD}}, [
|
|
{path: 'login', code: ValidationErrorCodes.INVALID_EMAIL_FORMAT},
|
|
]);
|
|
});
|
|
|
|
it('refuses a rename to a held name and any tag change', async () => {
|
|
await useSelfHostedEmailUniqueMode();
|
|
await registerEmailAccount('heldbyother');
|
|
const account = await registerEmailAccount('renamer');
|
|
await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION);
|
|
await expectFieldErrors(
|
|
{
|
|
path: '/users/@me',
|
|
method: 'PATCH',
|
|
token: account.token,
|
|
body: {username: 'HeldByOther', password: PASSWORD},
|
|
},
|
|
[{path: 'username', code: ValidationErrorCodes.USERNAME_ALREADY_TAKEN}],
|
|
);
|
|
await expectFieldErrors(
|
|
{path: '/users/@me', method: 'PATCH', token: account.token, body: {discriminator: '1234', password: PASSWORD}},
|
|
TAG_REFUSED,
|
|
);
|
|
const renamed = await createBuilder<CurrentUser>(harness, account.token)
|
|
.patch('/users/@me')
|
|
.body({username: 'renamedfree', password: PASSWORD})
|
|
.execute();
|
|
expect({username: renamed.username, discriminator: renamed.discriminator}).toEqual({
|
|
username: 'renamedfree',
|
|
discriminator: '0000',
|
|
});
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(account.userId)));
|
|
expect(user.premiumFlags & PremiumFlags.DISCRIMINATOR).toBe(0);
|
|
});
|
|
|
|
it('skips the premium tag reroll at session start', async () => {
|
|
await useSelfHostedEmailUniqueMode();
|
|
const account = await registerEmailAccount('emailnoreroll');
|
|
await markPremiumDiscriminator(account.userId);
|
|
const previousPremiumMode = getCachedInstancePremiumMode();
|
|
setCachedInstancePremiumMode('mirror');
|
|
try {
|
|
await startSession(account.token);
|
|
} finally {
|
|
setCachedInstancePremiumMode(previousPremiumMode);
|
|
}
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(account.userId)));
|
|
expect(user.discriminator).toBe(0);
|
|
expect(user.premiumFlags & PremiumFlags.DISCRIMINATOR).toBe(PremiumFlags.DISCRIMINATOR);
|
|
});
|
|
|
|
it('answers the availability check', async () => {
|
|
await useSelfHostedEmailUniqueMode();
|
|
await registerEmailAccount('availheld');
|
|
const taken = await createBuilderWithoutAuth<{available: boolean}>(harness)
|
|
.get('/auth/username-availability?username=AvailHeld')
|
|
.execute();
|
|
expect(taken.available).toBe(false);
|
|
const free = await createBuilderWithoutAuth<{available: boolean}>(harness)
|
|
.get('/auth/username-availability?username=availfree')
|
|
.execute();
|
|
expect(free.available).toBe(true);
|
|
});
|
|
|
|
it('still serves the email routes', async () => {
|
|
await useSelfHostedEmailUniqueMode();
|
|
const account = await registerEmailAccount('emailroutes');
|
|
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
|
|
await createBuilder(harness, account.token)
|
|
.get('/users/@me/recovery-kit')
|
|
.expect(400, APIErrorCodes.USERNAME_SIGN_IN_ONLY)
|
|
.execute();
|
|
});
|
|
});
|
|
|
|
describe('email mode stays as it was', () => {
|
|
it('signs in by email and by login on the hosted service', async () => {
|
|
const account = await createTestAccount(harness);
|
|
expect((await login({email: account.email, password: account.password})).user_id).toBe(account.userId);
|
|
expect((await login({login: account.email, password: account.password})).user_id).toBe(account.userId);
|
|
await expectFieldErrors({path: '/auth/login', body: {email: account.email, password: NEW_PASSWORD}}, [
|
|
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
|
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
|
]);
|
|
await expectFieldErrors({path: '/auth/login', body: {login: account.email, password: NEW_PASSWORD}}, [
|
|
{path: 'login', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
|
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
|
]);
|
|
await expectFieldErrors({path: '/auth/login', body: {login: account.username, password: account.password}}, [
|
|
{path: 'login', code: ValidationErrorCodes.INVALID_EMAIL_FORMAT},
|
|
]);
|
|
});
|
|
|
|
for (const variant of ['hosted', 'self-hosted email'] as const) {
|
|
it(`answers older-app bodies as before on the ${variant} instance`, async () => {
|
|
if (variant === 'self-hosted email') {
|
|
await useSelfHostedEmailMode();
|
|
}
|
|
useInstanceHosts();
|
|
const account = await createTestAccount(harness);
|
|
const notAnAddress = [
|
|
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_FORMAT},
|
|
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART},
|
|
];
|
|
await expectFieldErrors(
|
|
{path: '/auth/login', body: {email: account.username, password: PASSWORD}},
|
|
notAnAddress,
|
|
);
|
|
await expectFieldErrors(
|
|
{path: '/auth/login', body: {email: `${account.username}@chat.example.org`, password: PASSWORD}},
|
|
[
|
|
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
|
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
|
],
|
|
);
|
|
await expectFieldErrors({path: '/auth/login', body: {password: PASSWORD}}, [
|
|
{path: 'email', code: ValidationErrorCodes.INVALID_FORMAT},
|
|
]);
|
|
for (const login of ['', null]) {
|
|
await expectFieldErrors({path: '/auth/login', body: {login, password: PASSWORD}}, [
|
|
{path: 'email', code: ValidationErrorCodes.INVALID_FORMAT},
|
|
]);
|
|
}
|
|
for (const strayLogin of ['', null, 5, 'x'.repeat(400)]) {
|
|
const signedIn = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
|
.post('/auth/login')
|
|
.body({email: account.email, login: strayLogin, password: account.password})
|
|
.execute();
|
|
expect(signedIn.user_id).toBe(account.userId);
|
|
}
|
|
await expectFieldErrors(
|
|
{path: '/auth/register', body: {email: 'not-an-address', password: PASSWORD, consent: true}},
|
|
notAnAddress,
|
|
);
|
|
await expectFieldErrors(
|
|
{
|
|
path: '/auth/register',
|
|
body: {email: account.email, password: PASSWORD, date_of_birth: '2000-01-01', consent: true},
|
|
},
|
|
[{path: 'email', code: ValidationErrorCodes.EMAIL_ALREADY_IN_USE}],
|
|
);
|
|
const email = `instancelocal-${Date.now()}@chat.example.org`;
|
|
const registration = await createBuilderWithoutAuth<{user_id: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({email, global_name: 'Hosted Person', password: PASSWORD, date_of_birth: '2000-01-01', consent: true})
|
|
.execute();
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(registration.user_id)));
|
|
expect({email: user.email, username: user.username}).toEqual({email, username: 'Hosted_Person'});
|
|
});
|
|
}
|
|
|
|
it('counts the per-email sign-in limit before it needs a client IP', async () => {
|
|
const account = await createTestAccount(harness);
|
|
const emailLimit = {
|
|
identifier: `login:email:${account.email.toLowerCase()}`,
|
|
maxAttempts: 5,
|
|
windowMs: 15 * 60 * 1000,
|
|
};
|
|
await getRateLimitService().resetLimit(emailLimit.identifier);
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/login')
|
|
.header('x-forwarded-for', '')
|
|
.body({email: account.email, password: account.password})
|
|
.expect(403)
|
|
.execute();
|
|
expect((await getRateLimitService().peekLimit(emailLimit)).remaining).toBe(4);
|
|
});
|
|
|
|
it('reports email in discovery on the hosted service', async () => {
|
|
const discovery = await createBuilderWithoutAuth<{features: {account_identity: string}}>(harness)
|
|
.get('/.well-known/fluxer')
|
|
.execute();
|
|
expect(discovery.features.account_identity).toBe(AccountIdentityModes.EMAIL);
|
|
});
|
|
|
|
for (const variant of ['hosted', 'self-hosted email'] as const) {
|
|
it(`allocates random tags and allows tag changes on the ${variant} instance`, async () => {
|
|
if (variant === 'self-hosted email') {
|
|
await useSelfHostedEmailMode();
|
|
}
|
|
const account = await createTestAccount(harness);
|
|
const me = await fetchCurrentUser(account.token);
|
|
expect(me.discriminator).toMatch(/^\d{4}$/);
|
|
expect(me.discriminator).not.toBe('0000');
|
|
await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION);
|
|
const changed = await createBuilder<CurrentUser>(harness, account.token)
|
|
.patch('/users/@me')
|
|
.body({discriminator: '0042', password: account.password})
|
|
.execute();
|
|
expect(changed.discriminator).toBe('0042');
|
|
});
|
|
}
|
|
|
|
it('rerolls an expired premium tag on the hosted service', async () => {
|
|
const account = await createTestAccount(harness);
|
|
await markPremiumDiscriminator(account.userId);
|
|
await startSession(account.token);
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(account.userId)));
|
|
expect(user.premiumFlags & PremiumFlags.DISCRIMINATOR).toBe(0);
|
|
expect(user.discriminator).not.toBe(0);
|
|
});
|
|
|
|
for (const variant of ['hosted', 'self-hosted email'] as const) {
|
|
it(`runs forgot and reset by email on the ${variant} instance`, async () => {
|
|
if (variant === 'self-hosted email') {
|
|
await useSelfHostedEmailMode();
|
|
}
|
|
const account = await createTestAccount(harness);
|
|
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
|
|
const resetEmail = findLastTestEmail(
|
|
await listTestEmails(harness, {recipient: account.email}),
|
|
'password_reset',
|
|
);
|
|
const token = resetEmail!.metadata!.token!;
|
|
const reset = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
|
.post('/auth/reset')
|
|
.body({token, password: NEW_PASSWORD})
|
|
.execute();
|
|
expect(reset.user_id).toBe(account.userId);
|
|
expect((await login({email: account.email, password: NEW_PASSWORD})).user_id).toBe(account.userId);
|
|
});
|
|
}
|
|
|
|
it('uses up the emailed reset link even when the session cannot be created', async () => {
|
|
const account = await createTestAccount(harness);
|
|
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
|
|
const token = findLastTestEmail(await listTestEmails(harness, {recipient: account.email}), 'password_reset')!
|
|
.metadata!.token!;
|
|
const users = getUserRepository();
|
|
const userId = createUserID(BigInt(account.userId));
|
|
const user = await users.findUniqueAssert(userId);
|
|
await users.patchUpsert(userId, {traits: new Set(['registration_pending_approval'])}, user.toRow());
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/reset')
|
|
.body({token, password: NEW_PASSWORD})
|
|
.expect(403)
|
|
.execute();
|
|
const validation = await createBuilderWithoutAuth<{valid: boolean}>(harness)
|
|
.get(`/auth/reset/${token}`)
|
|
.execute();
|
|
expect(validation.valid).toBe(false);
|
|
});
|
|
|
|
it('keeps allowing an email on self-hosted email instances', async () => {
|
|
await useSelfHostedEmailMode();
|
|
const account = await createTestAccount(harness);
|
|
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(account.userId)));
|
|
expect(user.email).toBe(account.email);
|
|
expect((await login({email: account.email, password: account.password})).user_id).toBe(account.userId);
|
|
});
|
|
|
|
it('rejects a reset token without an email', async () => {
|
|
const account = await createTestAccount(harness);
|
|
const token = createPasswordResetToken('a'.repeat(64));
|
|
await getUserRepository().createPasswordResetToken({
|
|
token_: token,
|
|
user_id: createUserID(BigInt(account.userId)),
|
|
email: null,
|
|
});
|
|
const validation = await createBuilderWithoutAuth<{valid: boolean}>(harness)
|
|
.get(`/auth/reset/${token}`)
|
|
.execute();
|
|
expect(validation.valid).toBe(false);
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/reset')
|
|
.body({token, password: NEW_PASSWORD})
|
|
.expect(400)
|
|
.execute();
|
|
});
|
|
});
|
|
});
|