feat: add local signup and Kakao verification gate

This commit is contained in:
2026-07-26 10:17:16 +00:00
parent c364552960
commit a7bb352ba2
41 changed files with 2293 additions and 95 deletions
@@ -21,6 +21,14 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
async findByUsername(username: string): Promise<UserRecord | null> {
return usersByName.get(username) ?? null;
},
async findByDisplayName(displayName: string): Promise<UserRecord | null> {
for (const user of usersByName.values()) {
if (user.displayName === displayName) {
return user;
}
}
return null;
},
async findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null> {
return usersByOauthId.get(`${type}:${oauthId}`) ?? null;
},
@@ -31,8 +39,14 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
if (usersByName.has(input.username)) {
throw new Error('User already exists.');
}
const salt = hasher.createSalt();
for (const existing of usersByName.values()) {
if ((input.displayName ?? input.username) === existing.displayName) {
throw new Error('Display name already exists.');
}
}
const password = await hasher.hash(input.password);
const oauthType = input.oauth?.type ?? 'NONE';
const now = new Date();
const user: UserRecord = {
id: randomUUID(),
username: input.username,
@@ -45,10 +59,14 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
oauthInfo: input.oauth?.info,
picture: 'default.jpg',
imageServer: 0,
thirdPartyUse: true,
passwordSalt: salt,
passwordHash: hasher.hash(input.password, salt),
createdAt: new Date().toISOString(),
thirdPartyUse: input.thirdPartyUse ?? false,
termsAcceptedAt: input.termsAcceptedAt?.toISOString(),
privacyAcceptedAt: input.privacyAcceptedAt?.toISOString(),
kakaoVerifiedAt: input.oauth ? now.toISOString() : undefined,
kakaoGraceStartedAt: now.toISOString(),
passwordSalt: password.salt,
passwordHash: password.hash,
createdAt: now.toISOString(),
};
usersByName.set(input.username, user);
if (user.oauthType === 'KAKAO' && user.oauthId) {
@@ -60,14 +78,20 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
return user;
},
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
return hasher.hash(password, user.passwordSalt) === user.passwordHash;
const verified = await hasher.verify(password, user.passwordHash, user.passwordSalt);
if (verified.ok && verified.needsUpgrade) {
const upgraded = await hasher.hash(password);
user.passwordSalt = upgraded.salt;
user.passwordHash = upgraded.hash;
}
return verified.ok;
},
async updatePassword(userId: string, password: string): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
const salt = hasher.createSalt();
user.passwordSalt = salt;
user.passwordHash = hasher.hash(password, salt);
const next = await hasher.hash(password);
user.passwordSalt = next.salt;
user.passwordHash = next.hash;
return;
}
}
@@ -82,6 +106,25 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async linkKakao(userId, input): Promise<UserRecord> {
if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) {
throw new Error('Kakao account already linked.');
}
for (const user of usersByName.values()) {
if (user.id !== userId) {
continue;
}
user.oauthType = 'KAKAO';
user.oauthId = input.oauthId;
user.email = input.email.toLowerCase();
user.oauthInfo = input.oauthInfo;
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
usersByEmail.set(user.email, user);
return user;
}
throw new Error('User not found.');
},
async updateRoles(userId: string, roles: string[]): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -0,0 +1,63 @@
import type { UserRecord } from './userRepository.js';
const GENERAL_CREATION_GRACE_PROFILES = new Set(['nya', 'pya', 'hwe']);
const ADMIN_ROLES = new Set(['superuser', 'admin', 'admin.superuser']);
const DAY_MS = 24 * 60 * 60 * 1000;
export interface LocalAccountProfilePolicy {
requiresKakaoVerification: boolean;
kakaoVerified: boolean;
accessAllowed: boolean;
canCreateGeneral: boolean;
graceEndsAt: string | null;
generalCreationGraceDays: number;
accessGraceDays: number;
}
const readGraceDays = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback;
}
return Math.min(Math.max(Math.floor(value), 0), 365);
};
const hasAdminBypass = (user: UserRecord): boolean =>
user.roles.some((role) => ADMIN_ROLES.has(role) || role.startsWith('admin.'));
export const resolveLocalAccountProfilePolicy = (options: {
profile: string;
profileMeta?: Record<string, unknown>;
defaultGraceDays: number;
user: UserRecord;
now?: Date;
}): LocalAccountProfilePolicy => {
const profile = options.profile.toLowerCase();
const meta = options.profileMeta ?? {};
const defaultGraceDays = Math.min(Math.max(Math.floor(options.defaultGraceDays), 0), 365);
const accessGraceDays = readGraceDays(meta, 'localAccountAccessGraceDays', defaultGraceDays);
const generalCreationDefault = GENERAL_CREATION_GRACE_PROFILES.has(profile) ? defaultGraceDays : 0;
const generalCreationGraceDays = readGraceDays(
meta,
'localAccountGeneralCreationGraceDays',
generalCreationDefault
);
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
const bypass = hasAdminBypass(options.user);
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
const now = options.now ?? new Date();
const accessEndsAt = new Date(graceStartedAt.getTime() + accessGraceDays * DAY_MS);
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
const accessAllowed = kakaoVerified || bypass || now < accessEndsAt;
const canCreateGeneral = kakaoVerified || bypass || (accessAllowed && now < generalCreationEndsAt);
return {
requiresKakaoVerification: !kakaoVerified && !bypass,
kakaoVerified,
accessAllowed,
canCreateGeneral,
graceEndsAt: kakaoVerified || bypass ? null : accessEndsAt.toISOString(),
generalCreationGraceDays,
accessGraceDays,
};
};
@@ -1,12 +1,13 @@
import { randomUUID } from 'node:crypto';
import { parseJson } from '@sammo-ts/common';
export type OAuthMode = 'login' | 'change_pw';
export type OAuthMode = 'login' | 'change_pw' | 'verify';
export interface OAuthPendingState {
state: string;
mode: OAuthMode;
scopes: string[];
userId?: string;
createdAt: string;
}
@@ -23,7 +24,7 @@ export interface OAuthSession {
}
export interface OAuthSessionStore {
createPendingState(mode: OAuthMode, scopes: string[]): Promise<OAuthPendingState>;
createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState>;
consumePendingState(state: string): Promise<OAuthPendingState | null>;
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
consumeSession(sessionId: string): Promise<OAuthSession | null>;
@@ -61,11 +62,12 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
return `${this.prefix}:oauth-session:${sessionId}`;
}
async createPendingState(mode: OAuthMode, scopes: string[]): Promise<OAuthPendingState> {
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
const state: OAuthPendingState = {
state: randomUUID(),
mode,
scopes,
userId,
createdAt: new Date().toISOString(),
};
await this.client.set(this.stateKey(state.state), JSON.stringify(state), {
@@ -111,11 +113,12 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
private readonly pendingStates = new Map<string, OAuthPendingState>();
private readonly sessions = new Map<string, OAuthSession>();
async createPendingState(mode: OAuthMode, scopes: string[]): Promise<OAuthPendingState> {
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
const pending: OAuthPendingState = {
state: randomUUID(),
mode,
scopes,
userId,
createdAt: new Date().toISOString(),
};
this.pendingStates.set(pending.state, pending);
@@ -0,0 +1,87 @@
import {
createHash,
createPrivateKey,
createPublicKey,
generateKeyPairSync,
privateDecrypt,
type KeyObject,
constants,
} from 'node:crypto';
export interface PasswordEnvelopeInput {
keyId: string;
ciphertext: string;
}
export interface PasswordPublicKey {
keyId: string;
algorithm: 'RSA-OAEP-256';
publicKeyPem: string;
}
export interface PasswordEnvelopeService {
getPublicKey(): PasswordPublicKey;
open(input: PasswordEnvelopeInput): string;
}
const MAX_PASSWORD_BYTES = 128;
const buildKeyId = (publicKey: KeyObject): string =>
createHash('sha256').update(publicKey.export({ format: 'der', type: 'spki' })).digest('base64url').slice(0, 22);
const decodeUtf8 = (value: Buffer): string => {
if (value.length === 0 || value.length > MAX_PASSWORD_BYTES) {
throw new Error('Password envelope has an invalid length.');
}
const decoded = value.toString('utf8');
if (!Buffer.from(decoded, 'utf8').equals(value)) {
throw new Error('Password envelope is not valid UTF-8.');
}
return decoded;
};
export const createPasswordEnvelopeService = (privateKeyPem?: string): PasswordEnvelopeService => {
const privateKey = privateKeyPem
? createPrivateKey(privateKeyPem)
: generateKeyPairSync('rsa', {
modulusLength: 3072,
publicExponent: 0x10001,
}).privateKey;
const publicKey = createPublicKey(privateKey.export({ format: 'pem', type: 'pkcs8' }));
const keyId = buildKeyId(publicKey);
const publicKeyPem = publicKey.export({ format: 'pem', type: 'spki' }).toString();
return {
getPublicKey: () => ({
keyId,
algorithm: 'RSA-OAEP-256',
publicKeyPem,
}),
open: (input) => {
if (input.keyId !== keyId) {
throw new Error('Password encryption key has changed.');
}
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input.ciphertext)) {
throw new Error('Password envelope is not valid base64.');
}
let ciphertext: Buffer;
try {
ciphertext = Buffer.from(input.ciphertext, 'base64');
} catch {
throw new Error('Password envelope is not valid base64.');
}
if (!ciphertext.length) {
throw new Error('Password envelope is empty.');
}
const plaintext = privateDecrypt(
{
key: privateKey,
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
ciphertext
);
return decodeUtf8(plaintext);
},
};
};
+95 -5
View File
@@ -1,12 +1,102 @@
import { createHash, randomBytes } from 'node:crypto';
import { argon2, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
export interface PasswordHasher {
createSalt(): string;
hash(password: string, salt: string): string;
hash(password: string): Promise<{ hash: string; salt: string }>;
verify(password: string, hash: string, salt: string): Promise<{ ok: boolean; needsUpgrade: boolean }>;
}
// 비밀번호 해싱은 임시 구현이므로 이후 안전한 KDF로 교체한다.
export const createSimplePasswordHasher = (): PasswordHasher => ({
const ARGON2_MEMORY_KIB = 19 * 1024;
const ARGON2_PASSES = 2;
const ARGON2_PARALLELISM = 1;
const ARGON2_TAG_LENGTH = 32;
const ARGON2_PREFIX = `$argon2id$v=19$m=${ARGON2_MEMORY_KIB},t=${ARGON2_PASSES},p=${ARGON2_PARALLELISM}$`;
const deriveArgon2id = (password: string, salt: Buffer): Promise<Buffer> =>
new Promise((resolve, reject) => {
argon2(
'argon2id',
{
message: Buffer.from(password, 'utf8'),
nonce: salt,
parallelism: ARGON2_PARALLELISM,
tagLength: ARGON2_TAG_LENGTH,
memory: ARGON2_MEMORY_KIB,
passes: ARGON2_PASSES,
},
(error, result) => {
if (error) {
reject(error);
return;
}
resolve(result);
}
);
});
const safeEqualHex = (left: string, right: string): boolean => {
if (!/^[a-f0-9]+$/i.test(left) || !/^[a-f0-9]+$/i.test(right) || left.length !== right.length) {
return false;
}
return timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'));
};
const parseArgon2Hash = (hash: string): { salt: Buffer; expected: Buffer } | null => {
if (!hash.startsWith(ARGON2_PREFIX)) {
return null;
}
const encoded = hash.slice(ARGON2_PREFIX.length).split('$');
if (encoded.length !== 2 || !encoded[0] || !encoded[1]) {
return null;
}
try {
const salt = Buffer.from(encoded[0], 'base64url');
const expected = Buffer.from(encoded[1], 'base64url');
if (salt.length < 16 || expected.length !== ARGON2_TAG_LENGTH) {
return null;
}
return { salt, expected };
} catch {
return null;
}
};
export const createPasswordHasher = (options: { legacyGlobalSalt?: string } = {}): PasswordHasher => ({
createSalt: () => randomBytes(16).toString('hex'),
hash: (password: string, salt: string) => createHash('sha256').update(`${salt}:${password}`).digest('hex'),
hash: async (password) => {
const salt = randomBytes(16);
const derived = await deriveArgon2id(password, salt);
return {
hash: `${ARGON2_PREFIX}${salt.toString('base64url')}$${derived.toString('base64url')}`,
salt: '',
};
},
verify: async (password, hash, salt) => {
const modern = parseArgon2Hash(hash);
if (modern) {
const actual = await deriveArgon2id(password, modern.salt);
return {
ok: timingSafeEqual(actual, modern.expected),
needsUpgrade: false,
};
}
if (/^[a-f0-9]{64}$/i.test(hash)) {
const actual = createHash('sha256').update(`${salt}:${password}`).digest('hex');
return { ok: safeEqualHex(actual, hash), needsUpgrade: true };
}
if (/^[a-f0-9]{128}$/i.test(hash) && options.legacyGlobalSalt) {
const browserHash = createHash('sha512')
.update(`${options.legacyGlobalSalt}${password}${options.legacyGlobalSalt}`)
.digest('hex');
const actual = createHash('sha512').update(`${salt}${browserHash}${salt}`).digest('hex');
return { ok: safeEqualHex(actual, hash), needsUpgrade: true };
}
return { ok: false, needsUpgrade: false };
},
});
// 기존 import 지점을 깨지 않되 새 계정은 Argon2id를 사용한다.
export const createSimplePasswordHasher = createPasswordHasher;
@@ -33,6 +33,10 @@ const mapUser = (row: {
imageServer: number;
iconUpdatedAt: Date | null;
thirdPartyUse: boolean;
termsAcceptedAt: Date | null;
privacyAcceptedAt: Date | null;
kakaoVerifiedAt: Date | null;
kakaoGraceStartedAt: Date;
deleteAfter: Date | null;
createdAt: Date;
}): UserRecord => ({
@@ -49,6 +53,10 @@ const mapUser = (row: {
imageServer: row.imageServer,
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
thirdPartyUse: row.thirdPartyUse,
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
kakaoVerifiedAt: row.kakaoVerifiedAt?.toISOString(),
kakaoGraceStartedAt: row.kakaoGraceStartedAt.toISOString(),
deleteAfter: row.deleteAfter?.toISOString(),
passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt,
@@ -76,6 +84,14 @@ export const createPostgresUserRepository = (
});
return row ? mapUser(row) : null;
},
async findByDisplayName(displayName: string): Promise<UserRecord | null> {
const row = await prisma.appUser.findUnique({
where: {
displayName,
},
});
return row ? mapUser(row) : null;
},
async findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null> {
const row = await prisma.appUser.findFirst({
where: {
@@ -94,34 +110,53 @@ export const createPostgresUserRepository = (
return row ? mapUser(row) : null;
},
async createUser(input: CreateUserInput): Promise<UserRecord> {
const salt = hasher.createSalt();
const password = await hasher.hash(input.password);
const oauthType = input.oauth?.type ?? 'NONE';
const now = new Date();
const row = await prisma.appUser.create({
data: {
loginId: input.username,
displayName: input.displayName ?? input.username,
passwordHash: hasher.hash(input.password, salt),
passwordSalt: salt,
passwordHash: password.hash,
passwordSalt: password.salt,
roles: ['user'] satisfies GatewayPrisma.JsonArray,
sanctions: {} satisfies GatewayPrisma.JsonObject,
oauthType,
oauthId: input.oauth?.id,
email: input.oauth?.email?.toLowerCase(),
oauthInfo: (input.oauth?.info ?? {}) as GatewayPrisma.JsonObject,
termsAcceptedAt: input.termsAcceptedAt,
privacyAcceptedAt: input.privacyAcceptedAt,
thirdPartyUse: input.thirdPartyUse ?? false,
kakaoVerifiedAt: input.oauth ? now : undefined,
kakaoGraceStartedAt: now,
},
});
return mapUser(row);
},
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
return hasher.hash(password, user.passwordSalt) === user.passwordHash;
const verified = await hasher.verify(password, user.passwordHash, user.passwordSalt);
if (verified.ok && verified.needsUpgrade) {
const upgraded = await hasher.hash(password);
await prisma.appUser.update({
where: { id: user.id },
data: {
passwordHash: upgraded.hash,
passwordSalt: upgraded.salt,
},
});
user.passwordHash = upgraded.hash;
user.passwordSalt = upgraded.salt;
}
return verified.ok;
},
async updatePassword(userId: string, password: string): Promise<void> {
const salt = hasher.createSalt();
const next = await hasher.hash(password);
await prisma.appUser.update({
where: { id: userId },
data: {
passwordHash: hasher.hash(password, salt),
passwordSalt: salt,
passwordHash: next.hash,
passwordSalt: next.salt,
},
});
},
@@ -133,6 +168,19 @@ export const createPostgresUserRepository = (
},
});
},
async linkKakao(userId, input): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
data: {
oauthType: 'KAKAO',
oauthId: input.oauthId,
email: input.email.toLowerCase(),
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
kakaoVerifiedAt: input.verifiedAt,
},
});
return mapUser(row);
},
async updateRoles(userId: string, roles: string[]): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
@@ -0,0 +1,79 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { PasswordEnvelopeInput, PasswordEnvelopeService } from './passwordEnvelope.js';
const utf8Length = (value: string): number => Buffer.byteLength(value, 'utf8');
const isWideCodePoint = (codePoint: number): boolean =>
codePoint >= 0x1100 &&
(codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
(codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
export const legacyStringWidth = (value: string): number =>
Array.from(value).reduce((width, character) => {
const codePoint = character.codePointAt(0) ?? 0;
return width + (isWideCodePoint(codePoint) ? 2 : 1);
}, 0);
export const zPasswordEnvelope = z.object({
keyId: z.string().min(1).max(128),
ciphertext: z.string().min(1).max(4096),
});
export const zRegistrationUsername = z
.string()
.trim()
.transform((value) => value.toLocaleLowerCase('en-US'))
.superRefine((value, context) => {
const length = utf8Length(value);
if (length < 4 || length > 64) {
context.addIssue({
code: 'custom',
message: '계정명은 UTF-8 기준 4~64바이트여야 합니다.',
});
}
});
export const zDisplayName = z
.string()
.trim()
.superRefine((value, context) => {
const width = legacyStringWidth(value);
if (width < 1 || width > 18) {
context.addIssue({
code: 'custom',
message: '닉네임은 영문 기준 1~18자 너비여야 합니다.',
});
}
});
export const openPassword = (service: PasswordEnvelopeService, envelope: PasswordEnvelopeInput): string => {
let password: string;
try {
password = service.open(envelope);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: error instanceof Error ? error.message : '비밀번호 봉인을 열 수 없습니다.',
});
}
if (Array.from(password).length < 6 || Buffer.byteLength(password, 'utf8') > 128) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '비밀번호는 6자 이상, UTF-8 기준 128바이트 이하여야 합니다.',
});
}
return password;
};
@@ -12,6 +12,10 @@ export interface UserRecord {
imageServer: number;
iconUpdatedAt?: string;
thirdPartyUse: boolean;
termsAcceptedAt?: string;
privacyAcceptedAt?: string;
kakaoVerifiedAt?: string;
kakaoGraceStartedAt: string;
deleteAfter?: string;
passwordHash: string;
passwordSalt: string;
@@ -24,6 +28,8 @@ export interface PublicUser {
displayName: string;
roles: string[];
picture: string;
kakaoVerified: boolean;
kakaoGraceStartedAt: string;
createdAt: string;
}
@@ -51,6 +57,8 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
displayName: user.displayName,
roles: user.roles,
picture: user.picture,
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
createdAt: user.createdAt,
});
@@ -58,6 +66,9 @@ export interface CreateUserInput {
username: string;
password: string;
displayName?: string;
termsAcceptedAt?: Date;
privacyAcceptedAt?: Date;
thirdPartyUse?: boolean;
oauth?: {
type: 'KAKAO';
id: string;
@@ -69,12 +80,22 @@ export interface CreateUserInput {
export interface UserRepository {
findById(id: string): Promise<UserRecord | null>;
findByUsername(username: string): Promise<UserRecord | null>;
findByDisplayName(displayName: string): Promise<UserRecord | null>;
findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null>;
findByEmail(email: string): Promise<UserRecord | null>;
createUser(input: CreateUserInput): Promise<UserRecord>;
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
updatePassword(userId: string, password: string): Promise<void>;
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
linkKakao(
userId: string,
input: {
oauthId: string;
email: string;
oauthInfo: UserOAuthInfo;
verifiedAt: Date;
}
): Promise<UserRecord>;
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;