feat(auth): add username sign-in mode and recovery kits (#3215)

This commit is contained in:
Hampus
2026-10-05 14:10:07 +02:00
committed by GitHub
parent bf3d73a5f7
commit e1eecc3b6c
355 changed files with 52138 additions and 24097 deletions
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
export const AccountIdentityModes = {
EMAIL: 'email',
USERNAME: 'username',
} as const;
export type AccountIdentityMode = ValueOf<typeof AccountIdentityModes>;
export const DEFAULT_ACCOUNT_IDENTITY_MODE: AccountIdentityMode = AccountIdentityModes.EMAIL;
export const TagStyles = {
NONE: 'none',
RANDOM: 'random',
} as const;
export type TagStyle = ValueOf<typeof TagStyles>;
+2
View File
@@ -77,6 +77,8 @@ export const AdminACLs = {
REPORT_VIEW_REPORTER_PII: 'report:view:reporter_pii',
SYSTEM_DM_SEND: 'system_dm:send',
USER_CANCEL_BULK_MESSAGE_DELETION: 'user:cancel:bulk_message_deletion',
USER_CREATE_PASSWORD_RESET_LINK: 'user:create:password_reset_link',
USER_DELETE_RECOVERY_KIT: 'user:delete:recovery_kit',
USER_DELETE: 'user:delete',
USER_LIST_DM_CHANNELS: 'user:list:dm_channels',
USER_LIST_GUILDS: 'user:list:guilds',
+3
View File
@@ -10,6 +10,7 @@ export const APIErrorCodes = {
BLUESKY_OAUTH_AUTHORIZATION_FAILED: 'BLUESKY_OAUTH_AUTHORIZATION_FAILED',
BLUESKY_OAUTH_NOT_ENABLED: 'BLUESKY_OAUTH_NOT_ENABLED',
ACCOUNT_LIMITED: 'ACCOUNT_LIMITED',
ACCOUNT_IDENTITY_LOCKED: 'ACCOUNT_IDENTITY_LOCKED',
ACCOUNT_SUSPENDED_PERMANENTLY: 'ACCOUNT_SUSPENDED_PERMANENTLY',
ACCOUNT_SUSPENDED_TEMPORARILY: 'ACCOUNT_SUSPENDED_TEMPORARILY',
ACCOUNT_TOO_NEW_FOR_GUILD: 'ACCOUNT_TOO_NEW_FOR_GUILD',
@@ -64,6 +65,7 @@ export const APIErrorCodes = {
DISCOVERY_NOT_DISCOVERABLE: 'DISCOVERY_NOT_DISCOVERABLE',
DISCRIMINATOR_REQUIRED: 'DISCRIMINATOR_REQUIRED',
EMAIL_SERVICE_NOT_TESTABLE: 'EMAIL_SERVICE_NOT_TESTABLE',
EMAIL_UNAVAILABLE_ON_INSTANCE: 'EMAIL_UNAVAILABLE_ON_INSTANCE',
EMAIL_VERIFICATION_REQUIRED: 'EMAIL_VERIFICATION_REQUIRED',
DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED: 'DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED',
FOLLOW_TARGET_CONTENT_WARNING_REQUIRED: 'FOLLOW_TARGET_CONTENT_WARNING_REQUIRED',
@@ -263,6 +265,7 @@ export const APIErrorCodes = {
UNKNOWN_APPLICATION: 'UNKNOWN_APPLICATION',
UNKNOWN_WEBHOOK: 'UNKNOWN_WEBHOOK',
USERNAME_NOT_AVAILABLE: 'USERNAME_NOT_AVAILABLE',
USERNAME_SIGN_IN_ONLY: 'USERNAME_SIGN_IN_ONLY',
UPDATE_FAILED: 'UPDATE_FAILED',
USER_BANNED_FROM_GUILD: 'USER_BANNED_FROM_GUILD',
USER_IP_BANNED_FROM_GUILD: 'USER_IP_BANNED_FROM_GUILD',
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
encodeRecoveryKey,
formatRecoveryKey,
formatRecoveryKeyInput,
generateRecoveryKey,
isValidRecoveryKey,
normalizeRecoveryKey,
RECOVERY_KEY_FORMATTED_LENGTH,
RECOVERY_KEY_LENGTH,
} from '@fluxer/constants/src/RecoveryKeyUtils';
import {describe, expect, it} from 'vitest';
function bytesFrom(values: Array<number>): Uint8Array {
return Uint8Array.from(values);
}
describe('encodeRecoveryKey', () => {
it('encodes 160 bits as 32 Crockford base32 characters', () => {
expect(encodeRecoveryKey(new Uint8Array(20))).toBe('0'.repeat(32));
expect(encodeRecoveryKey(new Uint8Array(20).fill(0xff))).toBe('Z'.repeat(32));
expect(encodeRecoveryKey(bytesFrom([0x08, 0x86, 0x42, 0x98, 0xe8, ...new Array(15).fill(0)]))).toBe(
`12345678${'0'.repeat(24)}`,
);
});
it('rejects input that is not exactly 20 bytes', () => {
expect(() => encodeRecoveryKey(new Uint8Array(19))).toThrow();
expect(() => encodeRecoveryKey(new Uint8Array(21))).toThrow();
});
});
describe('generateRecoveryKey', () => {
it('returns the raw key and its display form', () => {
const {key, formatted} = generateRecoveryKey(new Uint8Array(20).fill(0xa5));
expect(key).toHaveLength(RECOVERY_KEY_LENGTH);
expect(formatted).toHaveLength(RECOVERY_KEY_FORMATTED_LENGTH);
expect(formatted.split('-')).toHaveLength(8);
expect(formatted.replaceAll('-', '')).toBe(key);
expect(normalizeRecoveryKey(formatted)).toBe(key);
});
});
describe('normalizeRecoveryKey', () => {
const key = 'ABCD0123EFGH4567JKMN89PQRSTVWXYZ';
it('accepts the display form, lower case, spaces and dashes', () => {
expect(normalizeRecoveryKey(formatRecoveryKey(key))).toBe(key);
expect(normalizeRecoveryKey(formatRecoveryKey(key).toLowerCase())).toBe(key);
expect(normalizeRecoveryKey(` ${formatRecoveryKey(key).replaceAll('-', ' ')} `)).toBe(key);
});
it('maps O to 0 and I or L to 1', () => {
expect(normalizeRecoveryKey('OOOO-IIII-LLLL-oooo-iiii-llll-0000-1111')).toBe(
`${'0'.repeat(4)}${'1'.repeat(8)}${'0'.repeat(4)}${'1'.repeat(8)}${'0'.repeat(4)}${'1'.repeat(4)}`,
);
});
it('rejects U, other symbols and the wrong length', () => {
expect(normalizeRecoveryKey(`U${key.slice(1)}`)).toBeNull();
expect(normalizeRecoveryKey(`!${key.slice(1)}`)).toBeNull();
expect(normalizeRecoveryKey(key.slice(1))).toBeNull();
expect(normalizeRecoveryKey(`${key}0`)).toBeNull();
expect(isValidRecoveryKey('')).toBe(false);
expect(isValidRecoveryKey(key)).toBe(true);
});
it('rejects characters whose uppercase form is two letters', () => {
expect(normalizeRecoveryKey(`\uFB06${key.slice(1)}`)).toBeNull();
expect(normalizeRecoveryKey(`\uFB05${key.slice(1)}`)).toBeNull();
});
});
describe('formatRecoveryKeyInput', () => {
it('groups partial input as the user types', () => {
expect(formatRecoveryKeyInput('abcd0')).toBe('ABCD-0');
expect(formatRecoveryKeyInput('ab-cd 01o')).toBe('ABCD-010');
expect(formatRecoveryKeyInput('abcu')).toBe('ABC');
});
it('stops at the full key length', () => {
expect(formatRecoveryKeyInput('0'.repeat(40))).toBe(formatRecoveryKey('0'.repeat(32)));
});
it('skips ligatures that upper-case to two letters', () => {
expect(formatRecoveryKeyInput(`${'0'.repeat(31)}\uFB06${'1'.repeat(8)}`)).toBe(
formatRecoveryKey(`${'0'.repeat(31)}1`),
);
});
});
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const RECOVERY_KEY_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
export const RECOVERY_KEY_BYTE_LENGTH = 20;
export const RECOVERY_KEY_LENGTH = 32;
export const RECOVERY_KEY_GROUP_SIZE = 4;
export const RECOVERY_KEY_SEPARATOR = '-';
export const RECOVERY_KEY_FORMATTED_LENGTH = RECOVERY_KEY_LENGTH + RECOVERY_KEY_LENGTH / RECOVERY_KEY_GROUP_SIZE - 1;
const RECOVERY_KEY_ALIASES: Readonly<Record<string, string>> = {
O: '0',
I: '1',
L: '1',
};
const IGNORED_INPUT_CHARACTERS = /[\s\u2010-\u2015\u2212-]/gu;
function mapInputCharacter(character: string): string | null {
const upper = character.toUpperCase();
const mapped = RECOVERY_KEY_ALIASES[upper] ?? upper;
return mapped.length === 1 && RECOVERY_KEY_ALPHABET.includes(mapped) ? mapped : null;
}
export function encodeRecoveryKey(bytes: Uint8Array): string {
if (bytes.length !== RECOVERY_KEY_BYTE_LENGTH) {
throw new Error(`Recovery key needs exactly ${RECOVERY_KEY_BYTE_LENGTH} bytes`);
}
let output = '';
let buffer = 0;
let bits = 0;
for (const byte of bytes) {
buffer = (buffer << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
output += RECOVERY_KEY_ALPHABET[(buffer >> bits) & 31];
}
buffer &= (1 << bits) - 1;
}
return output;
}
export function formatRecoveryKey(key: string): string {
const groups: Array<string> = [];
for (let index = 0; index < key.length; index += RECOVERY_KEY_GROUP_SIZE) {
groups.push(key.slice(index, index + RECOVERY_KEY_GROUP_SIZE));
}
return groups.join(RECOVERY_KEY_SEPARATOR);
}
export function generateRecoveryKey(randomBytes: Uint8Array): {key: string; formatted: string} {
const key = encodeRecoveryKey(randomBytes);
return {key, formatted: formatRecoveryKey(key)};
}
export function normalizeRecoveryKey(input: string): string | null {
const stripped = input.replace(IGNORED_INPUT_CHARACTERS, '');
if (stripped.length !== RECOVERY_KEY_LENGTH) {
return null;
}
let normalized = '';
for (const character of stripped) {
const mapped = mapInputCharacter(character);
if (mapped === null) {
return null;
}
normalized += mapped;
}
return normalized;
}
export function isValidRecoveryKey(input: string): boolean {
return normalizeRecoveryKey(input) !== null;
}
export function formatRecoveryKeyInput(input: string): string {
let normalized = '';
for (const character of input.replace(IGNORED_INPUT_CHARACTERS, '')) {
const mapped = mapInputCharacter(character);
if (mapped === null) {
continue;
}
normalized += mapped;
if (normalized.length >= RECOVERY_KEY_LENGTH) {
break;
}
}
return formatRecoveryKey(normalized);
}
@@ -53,6 +53,7 @@ export const ValidationErrorCodes = {
CUSTOM_STICKERS_IN_DMS_REQUIRE_PREMIUM: 'CUSTOM_STICKERS_IN_DMS_REQUIRE_PREMIUM',
CUSTOM_STICKERS_REQUIRE_PREMIUM_OUTSIDE_SOURCE: 'CUSTOM_STICKERS_REQUIRE_PREMIUM_OUTSIDE_SOURCE',
DISCRIMINATOR_INVALID_FORMAT: 'DISCRIMINATOR_INVALID_FORMAT',
DISCRIMINATOR_NOT_SUPPORTED_ON_INSTANCE: 'DISCRIMINATOR_NOT_SUPPORTED_ON_INSTANCE',
DISCRIMINATOR_OUT_OF_RANGE: 'DISCRIMINATOR_OUT_OF_RANGE',
DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED: 'DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED',
DUPLICATE_FILE_INDEX: 'DUPLICATE_FILE_INDEX',
@@ -70,6 +71,7 @@ export const ValidationErrorCodes = {
EMAIL_IS_REQUIRED: 'EMAIL_IS_REQUIRED',
EMAIL_LENGTH_INVALID: 'EMAIL_LENGTH_INVALID',
EMAIL_MUST_BE_CHANGED_VIA_TOKEN: 'EMAIL_MUST_BE_CHANGED_VIA_TOKEN',
TAG_STYLE_REQUIRES_EMAIL_SIGN_IN: 'TAG_STYLE_REQUIRES_EMAIL_SIGN_IN',
EMAIL_TOKEN_EXPIRED: 'EMAIL_TOKEN_EXPIRED',
EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL: 'EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL',
EMAIL_DOMAIN_NOT_ALLOWED_FOR_SSO: 'EMAIL_DOMAIN_NOT_ALLOWED_FOR_SSO',
@@ -108,6 +110,8 @@ export const ValidationErrorCodes = {
INVALID_EMAIL_LOCAL_PART: 'INVALID_EMAIL_LOCAL_PART',
INVALID_EMAIL_OR_PASSWORD: 'INVALID_EMAIL_OR_PASSWORD',
INVALID_EMAIL_TOKEN: 'INVALID_EMAIL_TOKEN',
INVALID_LOGIN_OR_PASSWORD: 'INVALID_LOGIN_OR_PASSWORD',
INSTANCE_ADDRESS_REQUIRED: 'INSTANCE_ADDRESS_REQUIRED',
INVALID_FILE_FIELD_NAME: 'INVALID_FILE_FIELD_NAME',
INVALID_FORMAT: 'INVALID_FORMAT',
INVALID_IMAGE_FORMAT: 'INVALID_IMAGE_FORMAT',
@@ -128,6 +132,7 @@ export const ValidationErrorCodes = {
INVALID_PARENT_CHANNEL: 'INVALID_PARENT_CHANNEL',
INVALID_PASSWORD: 'INVALID_PASSWORD',
INVALID_PROOF_TOKEN: 'INVALID_PROOF_TOKEN',
INVALID_RECOVERY_KEY: 'INVALID_RECOVERY_KEY',
INVALID_ROLE_ID: 'INVALID_ROLE_ID',
INVALID_RTC_REGION: 'INVALID_RTC_REGION',
INVALID_SNOWFLAKE: 'INVALID_SNOWFLAKE',
@@ -209,6 +214,7 @@ export const ValidationErrorCodes = {
USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS: 'USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS',
USER_IS_NOT_BANNED: 'USER_IS_NOT_BANNED',
USER_NOT_IN_CHANNEL: 'USER_NOT_IN_CHANNEL',
USERNAME_ALREADY_TAKEN: 'USERNAME_ALREADY_TAKEN',
USERNAME_CANNOT_CONTAIN_RESERVED_TERMS: 'USERNAME_CANNOT_CONTAIN_RESERVED_TERMS',
USERNAME_CHANGED_TOO_MANY_TIMES: 'USERNAME_CHANGED_TOO_MANY_TIMES',
USERNAME_INVALID_CHARACTERS: 'USERNAME_INVALID_CHARACTERS',