feat: add local signup and Kakao verification gate
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.7.0"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -9,9 +9,9 @@ import { z } from 'zod';
|
||||
import type { GatewayApiContext } from '../context.js';
|
||||
import { procedure, router } from '../trpc.js';
|
||||
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
|
||||
import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js';
|
||||
|
||||
const zSessionToken = z.string().min(1);
|
||||
const zPassword = z.string().min(6).max(128);
|
||||
const MAX_ICON_BYTES = 50 * 1024;
|
||||
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
|
||||
|
||||
@@ -82,24 +82,27 @@ export const accountRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
sessionToken: zSessionToken,
|
||||
currentPassword: zPassword,
|
||||
newPassword: zPassword,
|
||||
currentCredential: zPasswordEnvelope,
|
||||
newCredential: zPasswordEnvelope,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
|
||||
const currentPassword = openPassword(ctx.passwordEnvelope, input.currentCredential);
|
||||
if (!(await ctx.users.verifyPassword(user, currentPassword))) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
|
||||
}
|
||||
await ctx.users.updatePassword(user.id, input.newPassword);
|
||||
const newPassword = openPassword(ctx.passwordEnvelope, input.newCredential);
|
||||
await ctx.users.updatePassword(user.id, newPassword);
|
||||
await ctx.flushPublisher.publishUserFlush(user.id, 'password-changed');
|
||||
return { ok: true };
|
||||
}),
|
||||
scheduleDeletion: procedure
|
||||
.input(z.object({ sessionToken: zSessionToken, currentPassword: zPassword }))
|
||||
.input(z.object({ sessionToken: zSessionToken, currentCredential: zPasswordEnvelope }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
|
||||
const currentPassword = openPassword(ctx.passwordEnvelope, input.currentCredential);
|
||||
if (!(await ctx.users.verifyPassword(user, currentPassword))) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
|
||||
}
|
||||
if (user.deleteAfter) {
|
||||
|
||||
@@ -910,6 +910,8 @@ export const adminRouter = router({
|
||||
inGameNotice: z.string().max(4000).nullable().optional(),
|
||||
profileImageUrl: z.string().max(2048).nullable().optional(),
|
||||
nextSeasonIdx: z.number().int().min(0).nullable().optional(),
|
||||
localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(),
|
||||
localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface GatewayApiConfig {
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
localRegistrationEnabled: boolean;
|
||||
localAccountGraceDays: number;
|
||||
passwordEncryptionPrivateKeyFile?: string;
|
||||
legacyPasswordGlobalSalt?: string;
|
||||
orchestratorEnabled: boolean;
|
||||
orchestratorReconcileIntervalMs: number;
|
||||
orchestratorScheduleIntervalMs: number;
|
||||
@@ -86,6 +90,14 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`,
|
||||
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
|
||||
localRegistrationEnabled: parseBooleanWithFallback(env.GATEWAY_LOCAL_REGISTRATION_ENABLED, true),
|
||||
localAccountGraceDays: parseNumberWithFallback(
|
||||
env.GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS,
|
||||
7,
|
||||
'GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS'
|
||||
),
|
||||
passwordEncryptionPrivateKeyFile: env.GATEWAY_PASSWORD_ENCRYPTION_PRIVATE_KEY_FILE,
|
||||
legacyPasswordGlobalSalt: env.GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT,
|
||||
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
||||
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
||||
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { GatewayOrchestratorHandle } from './orchestrator/gatewayOrchestrat
|
||||
import type { GatewayProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import type { AdminAuthContext } from './adminAuth.js';
|
||||
import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js';
|
||||
|
||||
export interface GatewayApiContext {
|
||||
users: UserRepository;
|
||||
@@ -21,6 +22,9 @@ export interface GatewayApiContext {
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
localRegistrationEnabled: boolean;
|
||||
localAccountGraceDays: number;
|
||||
passwordEnvelope: PasswordEnvelopeService;
|
||||
profiles: GatewayProfileRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
@@ -41,6 +45,9 @@ export const createGatewayApiContext = (options: {
|
||||
userIconDir?: string;
|
||||
userIconPublicUrl?: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
localRegistrationEnabled: boolean;
|
||||
localAccountGraceDays: number;
|
||||
passwordEnvelope: PasswordEnvelopeService;
|
||||
profiles: GatewayProfileRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
@@ -58,6 +65,9 @@ export const createGatewayApiContext = (options: {
|
||||
userIconDir: options.userIconDir ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`,
|
||||
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
|
||||
localRegistrationEnabled: options.localRegistrationEnabled,
|
||||
localAccountGraceDays: options.localAccountGraceDays,
|
||||
passwordEnvelope: options.passwordEnvelope,
|
||||
profiles: options.profiles,
|
||||
orchestrator: options.orchestrator,
|
||||
profileStatus: options.profileStatus,
|
||||
|
||||
+255
-16
@@ -11,11 +11,22 @@ import { toPublicUser } from './auth/userRepository.js';
|
||||
import type { UserOAuthInfo } from './auth/userRepository.js';
|
||||
import { adminRouter } from './adminRouter.js';
|
||||
import { accountRouter } from './account/router.js';
|
||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||
import {
|
||||
openPassword,
|
||||
zDisplayName,
|
||||
zPasswordEnvelope,
|
||||
zRegistrationUsername,
|
||||
} from './auth/registrationInput.js';
|
||||
|
||||
const zUsername = z.string().min(2).max(32);
|
||||
const zUsername = z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(64)
|
||||
.transform((value) => value.trim().toLocaleLowerCase('en-US'));
|
||||
const zPassword = z.string().min(6).max(128);
|
||||
const zProfile = z.string().min(1).max(64);
|
||||
const zOAuthMode = z.enum(['login', 'change_pw']);
|
||||
const zOAuthMode = z.enum(['login', 'change_pw', 'verify']);
|
||||
const zBootstrapToken = z.string().min(1);
|
||||
|
||||
const parseDate = (value: string): Date | null => {
|
||||
@@ -54,11 +65,34 @@ export const appRouter = router({
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const sessionToken = input?.sessionToken;
|
||||
const sessionToken =
|
||||
(ctx.requestHeaders['x-session-token'] as string | undefined) ?? input?.sessionToken;
|
||||
const session = sessionToken ? await ctx.sessions.getSession(sessionToken) : null;
|
||||
return ctx.profileStatus.listLobbyProfiles({
|
||||
const profileList = await ctx.profileStatus.listLobbyProfiles({
|
||||
userId: session?.userId,
|
||||
});
|
||||
const user = session ? await ctx.users.findById(session.userId) : null;
|
||||
if (!user) {
|
||||
return profileList.map((profile) => ({
|
||||
...profile,
|
||||
localAccountPolicy: null,
|
||||
}));
|
||||
}
|
||||
return Promise.all(
|
||||
profileList.map(async (profile) => {
|
||||
const record = await ctx.profiles.getProfile(profile.profileName);
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: record?.profile ?? profile.profile,
|
||||
profileMeta: record?.meta,
|
||||
defaultGraceDays: ctx.localAccountGraceDays,
|
||||
user,
|
||||
});
|
||||
return {
|
||||
...profile,
|
||||
localAccountPolicy: policy,
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
}),
|
||||
admin: adminRouter,
|
||||
@@ -119,13 +153,27 @@ export const appRouter = router({
|
||||
.object({
|
||||
mode: zOAuthMode.optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
sessionToken: z.string().min(1).optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const mode = input?.mode ?? 'login';
|
||||
const scopes = input?.scopes ?? ['account_email'];
|
||||
const pending = await ctx.oauthSessions.createPendingState(mode, scopes);
|
||||
let userId: string | undefined;
|
||||
if (mode === 'verify') {
|
||||
const sessionToken =
|
||||
(ctx.requestHeaders['x-session-token'] as string | undefined) ?? input?.sessionToken;
|
||||
const session = sessionToken ? await ctx.sessions.getSession(sessionToken) : null;
|
||||
if (!session) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '카카오 인증을 연결하려면 먼저 로그인해야 합니다.',
|
||||
});
|
||||
}
|
||||
userId = session.userId;
|
||||
}
|
||||
const pending = await ctx.oauthSessions.createPendingState(mode, scopes, userId);
|
||||
const authUrl = ctx.kakaoClient.buildAuthUrl(pending.state, pending.scopes);
|
||||
return {
|
||||
mode,
|
||||
@@ -188,6 +236,57 @@ export const appRouter = router({
|
||||
(await ctx.users.findByOauthId('KAKAO', me.id)) ??
|
||||
(await ctx.users.findByEmail(kakaoAccount.email));
|
||||
|
||||
if (pending.mode === 'verify') {
|
||||
if (!pending.userId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '카카오 인증 연결 세션이 올바르지 않습니다.',
|
||||
});
|
||||
}
|
||||
const localUser = await ctx.users.findById(pending.userId);
|
||||
if (!localUser) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: '연결할 로컬 계정을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
if (existing && existing.id !== localUser.id) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 다른 계정에 연결된 카카오 계정입니다.',
|
||||
});
|
||||
}
|
||||
let verified = localUser;
|
||||
if (existing?.id !== localUser.id) {
|
||||
try {
|
||||
verified = await ctx.users.linkKakao(localUser.id, {
|
||||
oauthId: me.id,
|
||||
email: kakaoAccount.email,
|
||||
oauthInfo,
|
||||
verifiedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 다른 계정에 연결된 카카오 계정입니다.',
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (existing?.id === localUser.id) {
|
||||
await ctx.users.updateOAuthInfo(localUser.id, oauthInfo);
|
||||
}
|
||||
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
|
||||
const session = await ctx.sessions.createSession(refreshed);
|
||||
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
|
||||
return {
|
||||
status: 'verified' as const,
|
||||
user: toPublicUser(refreshed),
|
||||
sessionToken: session.sessionToken,
|
||||
issuedAt: session.issuedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (pending.mode === 'change_pw') {
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
@@ -255,12 +354,22 @@ export const appRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
oauthSessionId: z.string().min(1),
|
||||
username: zUsername,
|
||||
password: zPassword,
|
||||
displayName: z.string().min(2).max(40).optional(),
|
||||
username: zRegistrationUsername,
|
||||
credential: zPasswordEnvelope,
|
||||
displayName: zDisplayName,
|
||||
termsAgreed: z.literal(true),
|
||||
privacyAgreed: z.literal(true),
|
||||
thirdPartyUse: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.localRegistrationEnabled) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '현재는 가입이 금지되어있습니다!',
|
||||
});
|
||||
}
|
||||
const password = openPassword(ctx.passwordEnvelope, input.credential);
|
||||
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
|
||||
if (!oauthSession) {
|
||||
throw new TRPCError({
|
||||
@@ -275,6 +384,13 @@ export const appRouter = router({
|
||||
message: 'Username already exists.',
|
||||
});
|
||||
}
|
||||
const existingDisplayName = await ctx.users.findByDisplayName(input.displayName);
|
||||
if (existingDisplayName) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 사용중인 닉네임입니다.',
|
||||
});
|
||||
}
|
||||
const existingOAuth =
|
||||
(await ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId)) ??
|
||||
(await ctx.users.findByEmail(oauthSession.email));
|
||||
@@ -292,10 +408,14 @@ export const appRouter = router({
|
||||
};
|
||||
let created: Awaited<ReturnType<typeof ctx.users.createUser>>;
|
||||
try {
|
||||
const now = new Date();
|
||||
created = await ctx.users.createUser({
|
||||
username: input.username,
|
||||
password: input.password,
|
||||
password,
|
||||
displayName: input.displayName,
|
||||
termsAcceptedAt: now,
|
||||
privacyAcceptedAt: now,
|
||||
thirdPartyUse: input.thirdPartyUse,
|
||||
oauth: {
|
||||
type: 'KAKAO',
|
||||
id: oauthSession.kakaoId,
|
||||
@@ -317,11 +437,95 @@ export const appRouter = router({
|
||||
issuedAt: session.issuedAt,
|
||||
};
|
||||
}),
|
||||
passwordKey: procedure.query(({ ctx }) => ctx.passwordEnvelope.getPublicKey()),
|
||||
checkRegistrationField: procedure
|
||||
.input(
|
||||
z.discriminatedUnion('field', [
|
||||
z.object({ field: z.literal('username'), value: zRegistrationUsername }),
|
||||
z.object({ field: z.literal('displayName'), value: zDisplayName }),
|
||||
])
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const existing =
|
||||
input.field === 'username'
|
||||
? await ctx.users.findByUsername(input.value)
|
||||
: await ctx.users.findByDisplayName(input.value);
|
||||
return {
|
||||
available: !existing,
|
||||
normalizedValue: input.value,
|
||||
message: existing
|
||||
? input.field === 'username'
|
||||
? '이미 사용중인 계정명입니다.'
|
||||
: '이미 사용중인 닉네임입니다.'
|
||||
: '사용할 수 있습니다.',
|
||||
};
|
||||
}),
|
||||
registerLocal: procedure
|
||||
.input(
|
||||
z.object({
|
||||
username: zRegistrationUsername,
|
||||
credential: zPasswordEnvelope,
|
||||
displayName: zDisplayName,
|
||||
termsAgreed: z.literal(true),
|
||||
privacyAgreed: z.literal(true),
|
||||
thirdPartyUse: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.localRegistrationEnabled) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '현재는 가입이 금지되어있습니다!',
|
||||
});
|
||||
}
|
||||
const [existingUser, existingDisplayName] = await Promise.all([
|
||||
ctx.users.findByUsername(input.username),
|
||||
ctx.users.findByDisplayName(input.displayName),
|
||||
]);
|
||||
if (existingUser) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 사용중인 계정명입니다.',
|
||||
});
|
||||
}
|
||||
if (existingDisplayName) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 사용중인 닉네임입니다.',
|
||||
});
|
||||
}
|
||||
const password = openPassword(ctx.passwordEnvelope, input.credential);
|
||||
const now = new Date();
|
||||
let created: Awaited<ReturnType<typeof ctx.users.createUser>>;
|
||||
try {
|
||||
created = await ctx.users.createUser({
|
||||
username: input.username,
|
||||
password,
|
||||
displayName: input.displayName,
|
||||
termsAcceptedAt: now,
|
||||
privacyAcceptedAt: now,
|
||||
thirdPartyUse: input.thirdPartyUse,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 사용중인 계정명 또는 닉네임입니다.',
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const session = await ctx.sessions.createSession(created);
|
||||
return {
|
||||
user: toPublicUser(created),
|
||||
sessionToken: session.sessionToken,
|
||||
issuedAt: session.issuedAt,
|
||||
requiresKakaoVerification: true,
|
||||
};
|
||||
}),
|
||||
login: procedure
|
||||
.input(
|
||||
z.object({
|
||||
username: zUsername,
|
||||
password: zPassword,
|
||||
credential: zPasswordEnvelope,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -338,7 +542,8 @@ export const appRouter = router({
|
||||
message: 'Account deletion is pending.',
|
||||
});
|
||||
}
|
||||
const ok = await ctx.users.verifyPassword(user, input.password);
|
||||
const password = openPassword(ctx.passwordEnvelope, input.credential);
|
||||
const ok = await ctx.users.verifyPassword(user, password);
|
||||
if (!ok) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
@@ -363,12 +568,12 @@ export const appRouter = router({
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
const user = await ctx.users.findById(session.userId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName,
|
||||
},
|
||||
user: toPublicUser(user),
|
||||
issuedAt: session.issuedAt,
|
||||
};
|
||||
}),
|
||||
@@ -394,6 +599,34 @@ export const appRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gatewaySession = await ctx.sessions.getSession(input.sessionToken);
|
||||
if (!gatewaySession) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Session is not valid.',
|
||||
});
|
||||
}
|
||||
const user = await ctx.users.findById(gatewaySession.userId);
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Session user no longer exists.',
|
||||
});
|
||||
}
|
||||
const profileRecord = await ctx.profiles.getProfile(input.profile);
|
||||
const profile = profileRecord?.profile ?? input.profile.split(':', 1)[0] ?? input.profile;
|
||||
const localAccountPolicy = resolveLocalAccountProfilePolicy({
|
||||
profile,
|
||||
profileMeta: profileRecord?.meta,
|
||||
defaultGraceDays: ctx.localAccountGraceDays,
|
||||
user,
|
||||
});
|
||||
if (!localAccountPolicy.accessAllowed) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '카카오 인증 유예기간이 만료되었습니다. 인증 후 계속 이용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const gameSession = await ctx.sessions.createGameSession(input.sessionToken, input.profile);
|
||||
if (!gameSession) {
|
||||
throw new TRPCError({
|
||||
@@ -416,6 +649,12 @@ export const appRouter = router({
|
||||
createdAt: gameSession.createdAt,
|
||||
},
|
||||
sanctions: gameSession.sanctions,
|
||||
identity: {
|
||||
kakaoVerified: localAccountPolicy.kakaoVerified,
|
||||
canCreateGeneral: localAccountPolicy.canCreateGeneral,
|
||||
requiresKakaoVerification: localAccountPolicy.requiresKakaoVerification,
|
||||
graceEndsAt: localAccountPolicy.graceEndsAt,
|
||||
},
|
||||
} as const;
|
||||
const gameToken = encryptGameSessionToken(payload, ctx.gameTokenSecret);
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,8 @@ import { RedisGatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||
import { KakaoOAuthClient } from './auth/kakaoClient.js';
|
||||
import { RedisOAuthSessionStore } from './auth/oauthSessionStore.js';
|
||||
import { createPostgresUserRepository } from './auth/postgresUserRepository.js';
|
||||
import { createPasswordHasher } from './auth/passwordHasher.js';
|
||||
import { createPasswordEnvelopeService } from './auth/passwordEnvelope.js';
|
||||
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
|
||||
import { appRouter } from './router.js';
|
||||
@@ -30,7 +32,14 @@ export const createGatewayApiServer = async () => {
|
||||
await postgres.connect();
|
||||
await redis.connect();
|
||||
|
||||
const users = createPostgresUserRepository(postgres.prisma as GatewayPrismaClient);
|
||||
const privateKeyPem = config.passwordEncryptionPrivateKeyFile
|
||||
? await fs.readFile(config.passwordEncryptionPrivateKeyFile, 'utf8')
|
||||
: undefined;
|
||||
const passwordEnvelope = createPasswordEnvelopeService(privateKeyPem);
|
||||
const users = createPostgresUserRepository(
|
||||
postgres.prisma as GatewayPrismaClient,
|
||||
createPasswordHasher({ legacyGlobalSalt: config.legacyPasswordGlobalSalt })
|
||||
);
|
||||
const sessions = new RedisGatewaySessionService(redis.client, {
|
||||
keyPrefix: config.redisKeyPrefix,
|
||||
sessionTtlSeconds: config.sessionTtlSeconds,
|
||||
@@ -87,6 +96,9 @@ export const createGatewayApiServer = async () => {
|
||||
userIconDir: path.resolve(process.cwd(), config.userIconDir),
|
||||
userIconPublicUrl: config.userIconPublicUrl,
|
||||
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
|
||||
localRegistrationEnabled: config.localRegistrationEnabled,
|
||||
localAccountGraceDays: config.localAccountGraceDays,
|
||||
passwordEnvelope,
|
||||
profiles,
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { GatewayOperationCreateInput, GatewayProfileRepository } from '../s
|
||||
import { createGatewayApiContext } from '../src/context.js';
|
||||
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const buildCaller = async (createOperation: GatewayProfileRepository['createOperation']) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
@@ -74,6 +75,9 @@ const buildCaller = async (createOperation: GatewayProfileRepository['createOper
|
||||
oauthSessions: {} as never,
|
||||
publicBaseUrl: 'http://localhost',
|
||||
adminLocalAccountEnabled: false,
|
||||
localRegistrationEnabled: true,
|
||||
localAccountGraceDays: 7,
|
||||
passwordEnvelope: createPasswordEnvelopeService(),
|
||||
profiles,
|
||||
orchestrator: {
|
||||
start: () => {},
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { constants, publicEncrypt } from 'node:crypto';
|
||||
|
||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
@@ -13,8 +14,9 @@ import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.
|
||||
import { appRouter } from '../src/router.js';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: number } = {}) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
const sessions = new InMemoryGatewaySessionService({
|
||||
sessionTtlSeconds: 3600,
|
||||
@@ -29,10 +31,13 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
redirectUri: '',
|
||||
oauthHost: '',
|
||||
apiHost: '',
|
||||
buildAuthUrl: () => '',
|
||||
exchangeCode: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
buildAuthUrl: (state: string) => `https://kauth.example.test/authorize?state=${state}`,
|
||||
exchangeCode: async () => ({
|
||||
accessToken: 'access-token',
|
||||
accessTokenExpiresIn: 3600,
|
||||
refreshToken: 'refresh-token',
|
||||
refreshTokenExpiresIn: 86400,
|
||||
}),
|
||||
refreshToken: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
@@ -48,9 +53,33 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
}),
|
||||
sendTalkMessage: async () => {},
|
||||
};
|
||||
const profileRows = [
|
||||
{
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
scenario: 'default',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING' as const,
|
||||
buildStatus: 'SUCCEEDED' as const,
|
||||
meta: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
scenario: 'default',
|
||||
apiPort: 15015,
|
||||
status: 'RUNNING' as const,
|
||||
buildStatus: 'SUCCEEDED' as const,
|
||||
meta: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
const profiles = {
|
||||
listProfiles: async () => [],
|
||||
getProfile: async () => null,
|
||||
listProfiles: async () => profileRows,
|
||||
getProfile: async (profileName: string) => profileRows.find((profile) => profile.profileName === profileName) ?? null,
|
||||
upsertProfile: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
@@ -91,7 +120,40 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
}),
|
||||
listRuntimeStates: async () => [],
|
||||
};
|
||||
const profileStatus = new InMemoryProfileStatusService();
|
||||
const profileStatus = new InMemoryProfileStatusService(
|
||||
profileRows.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
scenario: profile.scenario,
|
||||
status: profile.status,
|
||||
apiPort: profile.apiPort,
|
||||
runtime: {
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
auctionRunning: false,
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
korName: profile.profile,
|
||||
color: '#fff',
|
||||
}))
|
||||
);
|
||||
const passwordEnvelope = createPasswordEnvelopeService();
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
const sealPassword = (password: string) => {
|
||||
const key = passwordEnvelope.getPublicKey();
|
||||
return {
|
||||
keyId: key.keyId,
|
||||
ciphertext: publicEncrypt(
|
||||
{
|
||||
key: key.publicKeyPem,
|
||||
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
Buffer.from(password, 'utf8')
|
||||
).toString('base64'),
|
||||
};
|
||||
};
|
||||
const caller = appRouter.createCaller(
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
@@ -105,10 +167,13 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
userIconDir: options.userIconDir,
|
||||
userIconPublicUrl: 'http://localhost/user-icons',
|
||||
adminLocalAccountEnabled: false,
|
||||
localRegistrationEnabled: true,
|
||||
localAccountGraceDays: options.localAccountGraceDays ?? 7,
|
||||
passwordEnvelope,
|
||||
profiles,
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
requestHeaders: {},
|
||||
requestHeaders,
|
||||
prisma: {
|
||||
appUser: {
|
||||
findFirst: async () => null,
|
||||
@@ -116,10 +181,149 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
} as unknown as GatewayPrismaClient,
|
||||
})
|
||||
);
|
||||
return { caller, oauthSessions, users, sessions };
|
||||
return {
|
||||
caller,
|
||||
oauthSessions,
|
||||
users,
|
||||
sessions,
|
||||
sealPassword,
|
||||
setSessionHeader: (sessionToken: string) => {
|
||||
requestHeaders['x-session-token'] = sessionToken;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('gateway auth flow', () => {
|
||||
it('registers a local account first and accepts an encrypted password login', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'LOCAL-User',
|
||||
credential: sealPassword('비밀번호-password'),
|
||||
displayName: '로컬유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
|
||||
expect(register.user).toMatchObject({
|
||||
username: 'local-user',
|
||||
displayName: '로컬유저',
|
||||
kakaoVerified: false,
|
||||
});
|
||||
const stored = await users.findByUsername('local-user');
|
||||
expect(stored?.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(stored?.thirdPartyUse).toBe(false);
|
||||
expect(stored?.termsAcceptedAt).toBeTruthy();
|
||||
expect(stored?.privacyAcceptedAt).toBeTruthy();
|
||||
|
||||
const login = await caller.auth.login({
|
||||
username: 'LOCAL-USER',
|
||||
credential: sealPassword('비밀번호-password'),
|
||||
});
|
||||
expect(login.user.username).toBe('local-user');
|
||||
});
|
||||
|
||||
it('blocks pre-verification general creation on che but grants the hwe grace period', async () => {
|
||||
const { caller, sealPassword, setSessionHeader } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'policy-user',
|
||||
credential: sealPassword('policy-password'),
|
||||
displayName: '정책유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
|
||||
const che = await caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'che:default',
|
||||
});
|
||||
const hwe = await caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'hwe:default',
|
||||
});
|
||||
const chePayload = decryptGameSessionToken(che.gameToken, 'test-secret');
|
||||
const hwePayload = decryptGameSessionToken(hwe.gameToken, 'test-secret');
|
||||
|
||||
expect(chePayload?.identity).toMatchObject({
|
||||
kakaoVerified: false,
|
||||
canCreateGeneral: false,
|
||||
requiresKakaoVerification: true,
|
||||
});
|
||||
expect(hwePayload?.identity).toMatchObject({
|
||||
kakaoVerified: false,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: true,
|
||||
});
|
||||
|
||||
setSessionHeader(register.sessionToken);
|
||||
const profileList = await caller.lobby.profiles();
|
||||
expect(profileList.find((profile) => profile.profile === 'che')?.localAccountPolicy?.canCreateGeneral).toBe(
|
||||
false
|
||||
);
|
||||
expect(profileList.find((profile) => profile.profile === 'hwe')?.localAccountPolicy?.canCreateGeneral).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects continued game access after the local account grace period', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 7 });
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'expired-user',
|
||||
credential: sealPassword('expired-password'),
|
||||
displayName: '만료유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
const user = await users.findByUsername('expired-user');
|
||||
expect(user).not.toBeNull();
|
||||
if (user) {
|
||||
user.kakaoGraceStartedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
await expect(
|
||||
caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'hwe:default',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: expect.stringContaining('유예기간'),
|
||||
});
|
||||
});
|
||||
|
||||
it('links Kakao to the logged-in local account instead of creating a second user', async () => {
|
||||
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'verify-user',
|
||||
credential: sealPassword('verify-password'),
|
||||
displayName: '인증유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
setSessionHeader(register.sessionToken);
|
||||
const start = await caller.auth.kakaoStart({ mode: 'verify' });
|
||||
const verified = await caller.auth.kakaoExchange({
|
||||
code: 'oauth-code',
|
||||
state: start.state,
|
||||
});
|
||||
|
||||
expect(verified.status).toBe('verified');
|
||||
if (verified.status !== 'verified') {
|
||||
throw new Error('Expected verified result.');
|
||||
}
|
||||
expect(verified.user.kakaoVerified).toBe(true);
|
||||
const stored = await users.findByUsername('verify-user');
|
||||
expect(stored).toMatchObject({
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: '1',
|
||||
email: 'tester@example.com',
|
||||
});
|
||||
expect(stored?.kakaoVerifiedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('carries the bootstrap superuser role into game sessions', async () => {
|
||||
const previousToken = process.env.GATEWAY_BOOTSTRAP_TOKEN;
|
||||
process.env.GATEWAY_BOOTSTRAP_TOKEN = 'bootstrap-test-token';
|
||||
@@ -151,7 +355,7 @@ describe('gateway auth flow', () => {
|
||||
});
|
||||
|
||||
it('registers and issues a game session', async () => {
|
||||
const { caller, oauthSessions } = buildCaller();
|
||||
const { caller, oauthSessions, sealPassword } = buildCaller();
|
||||
const oauthSession = await oauthSessions.createSession({
|
||||
mode: 'login',
|
||||
kakaoId: '1',
|
||||
@@ -165,8 +369,11 @@ describe('gateway auth flow', () => {
|
||||
const register = await caller.auth.register({
|
||||
oauthSessionId: oauthSession.id,
|
||||
username: 'tester',
|
||||
password: 'secretpass',
|
||||
credential: sealPassword('secretpass'),
|
||||
displayName: 'Tester',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
|
||||
expect(register.user.username).toBe('tester');
|
||||
@@ -191,7 +398,7 @@ describe('gateway auth flow', () => {
|
||||
|
||||
describe('account self service', () => {
|
||||
it('changes only the authenticated user password after verifying the current password', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const { caller, users, sessions, sealPassword } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
username: 'self-service',
|
||||
password: 'current-password',
|
||||
@@ -199,17 +406,17 @@ describe('account self service', () => {
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
await expect(
|
||||
caller.account.changePassword({
|
||||
sessionToken: session.sessionToken,
|
||||
currentPassword: 'wrong-password',
|
||||
newPassword: 'next-password',
|
||||
caller.account.changePassword({
|
||||
sessionToken: session.sessionToken,
|
||||
currentCredential: sealPassword('wrong-password'),
|
||||
newCredential: sealPassword('next-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
|
||||
await caller.account.changePassword({
|
||||
sessionToken: session.sessionToken,
|
||||
currentPassword: 'current-password',
|
||||
newPassword: 'next-password',
|
||||
currentCredential: sealPassword('current-password'),
|
||||
newCredential: sealPassword('next-password'),
|
||||
});
|
||||
|
||||
const refreshed = await users.findById(user.id);
|
||||
@@ -217,7 +424,7 @@ describe('account self service', () => {
|
||||
});
|
||||
|
||||
it('revokes the session and schedules deletion after 30 days', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const { caller, users, sessions, sealPassword } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
username: 'delete-self',
|
||||
password: 'current-password',
|
||||
@@ -226,7 +433,7 @@ describe('account self service', () => {
|
||||
|
||||
const result = await caller.account.scheduleDeletion({
|
||||
sessionToken: session.sessionToken,
|
||||
currentPassword: 'current-password',
|
||||
currentCredential: sealPassword('current-password'),
|
||||
});
|
||||
|
||||
expect(new Date(result.deleteAfter).getTime()).toBeGreaterThan(Date.now() + 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveLocalAccountProfilePolicy } from '../src/auth/localAccountPolicy.js';
|
||||
import type { UserRecord } from '../src/auth/userRepository.js';
|
||||
|
||||
const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
|
||||
id: 'local-user',
|
||||
username: 'local-user',
|
||||
displayName: '로컬유저',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
oauthType: 'NONE',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
thirdPartyUse: false,
|
||||
kakaoGraceStartedAt: graceStartedAt.toISOString(),
|
||||
passwordHash: 'unused',
|
||||
passwordSalt: '',
|
||||
createdAt: graceStartedAt.toISOString(),
|
||||
});
|
||||
|
||||
describe('local account profile policy', () => {
|
||||
it.each(['che', 'kwe', 'twe'])('%s blocks general creation before Kakao verification', (profile) => {
|
||||
const now = new Date('2026-07-26T00:00:00.000Z');
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile,
|
||||
defaultGraceDays: 7,
|
||||
user: buildLocalUser(now),
|
||||
now,
|
||||
});
|
||||
|
||||
expect(policy.accessAllowed).toBe(true);
|
||||
expect(policy.canCreateGeneral).toBe(false);
|
||||
expect(policy.generalCreationGraceDays).toBe(0);
|
||||
});
|
||||
|
||||
it.each(['nya', 'pya', 'hwe'])('%s allows general creation during the configured grace period', (profile) => {
|
||||
const start = new Date('2026-07-20T00:00:00.000Z');
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile,
|
||||
defaultGraceDays: 7,
|
||||
user: buildLocalUser(start),
|
||||
now: new Date('2026-07-26T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy.accessAllowed).toBe(true);
|
||||
expect(policy.canCreateGeneral).toBe(true);
|
||||
expect(policy.generalCreationGraceDays).toBe(7);
|
||||
});
|
||||
|
||||
it('honors profile metadata overrides and blocks all access after expiry', () => {
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: 'hwe',
|
||||
profileMeta: {
|
||||
localAccountAccessGraceDays: 3,
|
||||
localAccountGeneralCreationGraceDays: 1,
|
||||
},
|
||||
defaultGraceDays: 7,
|
||||
user: buildLocalUser(new Date('2026-07-20T00:00:00.000Z')),
|
||||
now: new Date('2026-07-26T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy).toMatchObject({
|
||||
accessAllowed: false,
|
||||
canCreateGeneral: false,
|
||||
accessGraceDays: 3,
|
||||
generalCreationGraceDays: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes grace restrictions once Kakao is verified', () => {
|
||||
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
|
||||
user.oauthType = 'KAKAO';
|
||||
user.oauthId = '1';
|
||||
user.email = 'verified@example.test';
|
||||
user.kakaoVerifiedAt = '2026-07-26T00:00:00.000Z';
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: 'che',
|
||||
defaultGraceDays: 0,
|
||||
user,
|
||||
now: new Date('2026-07-26T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy).toMatchObject({
|
||||
kakaoVerified: true,
|
||||
requiresKakaoVerification: false,
|
||||
accessAllowed: true,
|
||||
canCreateGeneral: true,
|
||||
graceEndsAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { constants, createHash, publicEncrypt } from 'node:crypto';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
import { createPasswordHasher } from '../src/auth/passwordHasher.js';
|
||||
|
||||
describe('password credential compatibility', () => {
|
||||
it('seals browser passwords with RSA-OAEP before opening them at the gateway', () => {
|
||||
const service = createPasswordEnvelopeService();
|
||||
const publicKey = service.getPublicKey();
|
||||
const ciphertext = publicEncrypt(
|
||||
{
|
||||
key: publicKey.publicKeyPem,
|
||||
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
Buffer.from('터널에-보이지-않는-비밀번호', 'utf8')
|
||||
).toString('base64');
|
||||
|
||||
expect(ciphertext).not.toContain('비밀번호');
|
||||
expect(service.open({ keyId: publicKey.keyId, ciphertext })).toBe('터널에-보이지-않는-비밀번호');
|
||||
expect(() => service.open({ keyId: 'stale-key', ciphertext })).toThrow(/key has changed/i);
|
||||
expect(() => service.open({ keyId: publicKey.keyId, ciphertext: '*not-base64*' })).toThrow(/base64/i);
|
||||
});
|
||||
|
||||
it('upgrades the former core SHA-256 credential after a successful login', async () => {
|
||||
const hasher = createPasswordHasher();
|
||||
const users = createInMemoryUserRepository(hasher);
|
||||
const user = await users.createUser({
|
||||
username: 'core-legacy',
|
||||
password: 'current-password',
|
||||
});
|
||||
user.passwordSalt = 'core-salt';
|
||||
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(user.passwordSalt).toBe('');
|
||||
});
|
||||
|
||||
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => {
|
||||
const globalSalt = 'ref-global-salt';
|
||||
const hasher = createPasswordHasher({ legacyGlobalSalt: globalSalt });
|
||||
const users = createInMemoryUserRepository(hasher);
|
||||
const user = await users.createUser({
|
||||
username: 'ref-legacy',
|
||||
password: 'current-password',
|
||||
});
|
||||
const userSalt = 'ref-user-salt';
|
||||
const browserHash = createHash('sha512')
|
||||
.update(`${globalSalt}current-password${globalSalt}`)
|
||||
.digest('hex');
|
||||
user.passwordSalt = userSalt;
|
||||
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(user.passwordSalt).toBe('');
|
||||
});
|
||||
|
||||
it('does not accept an imported ref credential without the matching global salt', async () => {
|
||||
const users = createInMemoryUserRepository(createPasswordHasher());
|
||||
const user = await users.createUser({
|
||||
username: 'ref-no-salt',
|
||||
password: 'current-password',
|
||||
});
|
||||
user.passwordSalt = 'ref-user-salt';
|
||||
user.passwordHash = 'a'.repeat(128);
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(false);
|
||||
expect(user.passwordHash).toBe('a'.repeat(128));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user