mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(auth): add username sign-in mode and recovery kits (#3215)
This commit is contained in:
@@ -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>;
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user