From a7bb352ba2ab8534a2b2e7494324241ff8e48f77 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 10:17:16 +0000 Subject: [PATCH] feat: add local signup and Kakao verification gate --- .env.example | 6 + app/game-api/src/router/join/index.ts | 6 + app/game-api/test/router.test.ts | 47 ++- .../e2e/commandArgumentsLive.spec.ts | 14 +- app/gateway-api/package.json | 3 + app/gateway-api/src/account/router.ts | 17 +- app/gateway-api/src/adminRouter.ts | 2 + .../src/auth/inMemoryUserRepository.ts | 61 ++- .../src/auth/localAccountPolicy.ts | 63 +++ app/gateway-api/src/auth/oauthSessionStore.ts | 11 +- app/gateway-api/src/auth/passwordEnvelope.ts | 87 ++++ app/gateway-api/src/auth/passwordHasher.ts | 100 ++++- .../src/auth/postgresUserRepository.ts | 62 ++- app/gateway-api/src/auth/registrationInput.ts | 79 ++++ app/gateway-api/src/auth/userRepository.ts | 21 + app/gateway-api/src/config.ts | 12 + app/gateway-api/src/context.ts | 10 + app/gateway-api/src/router.ts | 271 +++++++++++- app/gateway-api/src/server.ts | 14 +- app/gateway-api/test/adminOperations.test.ts | 4 + app/gateway-api/test/authFlow.test.ts | 249 ++++++++++- .../test/localAccountPolicy.test.ts | 92 +++++ .../test/passwordCredential.test.ts | 75 ++++ app/gateway-frontend/public/terms.1.html | 151 +++++++ app/gateway-frontend/public/terms.2.html | 30 ++ app/gateway-frontend/src/router/index.ts | 6 + .../src/utils/passwordEnvelope.ts | 46 +++ .../src/views/AccountView.vue | 12 +- app/gateway-frontend/src/views/HomeView.vue | 25 +- app/gateway-frontend/src/views/LobbyView.vue | 79 +++- .../src/views/OAuthCallbackView.vue | 22 +- app/gateway-frontend/src/views/SignupView.vue | 388 ++++++++++++++++++ packages/common/src/auth/gameToken.ts | 20 + .../migration.sql | 26 ++ packages/infra/prisma/gateway.prisma | 6 +- .../visual-parity.spec.ts | 235 ++++++++++- .../integration-tests/src/passwordEnvelope.ts | 19 + .../test/auctionFlow.test.ts | 3 +- .../test/initialization.test.ts | 5 +- .../test/orchestrator.e2e.test.ts | 3 +- .../test/tournamentLifecycle.test.ts | 6 +- 41 files changed, 2293 insertions(+), 95 deletions(-) create mode 100644 app/gateway-api/src/auth/localAccountPolicy.ts create mode 100644 app/gateway-api/src/auth/passwordEnvelope.ts create mode 100644 app/gateway-api/src/auth/registrationInput.ts create mode 100644 app/gateway-api/test/localAccountPolicy.test.ts create mode 100644 app/gateway-api/test/passwordCredential.test.ts create mode 100644 app/gateway-frontend/public/terms.1.html create mode 100644 app/gateway-frontend/public/terms.2.html create mode 100644 app/gateway-frontend/src/utils/passwordEnvelope.ts create mode 100644 app/gateway-frontend/src/views/SignupView.vue create mode 100644 packages/infra/prisma/gateway-migrations/20260726000000_add_local_signup_verification/migration.sql create mode 100644 tools/integration-tests/src/passwordEnvelope.ts diff --git a/.env.example b/.env.example index 88177fc..4335b6e 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,12 @@ GATEWAY_WORKSPACE_ROOT=/path/to/core2026 GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees GATEWAY_USER_ICON_DIR=uploads/user-icons GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons +GATEWAY_LOCAL_REGISTRATION_ENABLED=true +GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7 +# Optional. Keep the PEM private key outside Git; if omitted, a per-process RSA key is generated. +# GATEWAY_PASSWORD_ENCRYPTION_PRIVATE_KEY_FILE=/run/secrets/gateway_password_private_key +# Required only while imported ref SHA-512 credentials remain. +# GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT=replace-with-ref-global-salt SESSION_TTL_SECONDS=604800 GAME_SESSION_TTL_SECONDS=21600 OAUTH_SESSION_TTL_SECONDS=600 diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index a086c0c..66c791a 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -273,6 +273,12 @@ export const joinRouter = router({ if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } + if (ctx.auth?.identity?.canCreateGeneral === false) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', + }); + } const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index d68311b..17a324b 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -80,6 +80,7 @@ const buildContext = (options?: { generalTurnWrites?: unknown[]; nationTurnWrites?: unknown[]; auth?: GameSessionTokenPayload | null; + worldStateReads?: { count: number }; }): GameApiContext => { const transport = options?.transport ?? new InMemoryTurnDaemonTransport(); const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport(); @@ -87,7 +88,12 @@ const buildContext = (options?: { const nationTurns = options?.nationTurns ?? []; const db = { worldState: { - findFirst: async () => options?.state ?? null, + findFirst: async () => { + if (options?.worldStateReads) { + options.worldStateReads.count += 1; + } + return options?.state ?? null; + }, }, general: { findUnique: async ({ where }: { where: { id: number } }) => { @@ -163,6 +169,45 @@ const buildContext = (options?: { }; describe('appRouter', () => { + it('rejects general creation before any game-state read when the signed identity gate denies it', async () => { + const worldStateReads = { count: 0 }; + const auth: GameSessionTokenPayload = { + version: 1, + profile: profile.name, + issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), + expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), + sessionId: 'session-local', + user: { + id: 'local-user', + username: 'local-user', + displayName: '로컬유저', + roles: ['user'], + }, + sanctions: {}, + identity: { + kakaoVerified: false, + canCreateGeneral: false, + requiresKakaoVerification: true, + graceEndsAt: new Date('2026-01-08T00:00:00Z').toISOString(), + }, + }; + const caller = appRouter.createCaller(buildContext({ auth, worldStateReads })); + + await expect( + caller.join.createGeneral({ + name: '미인증', + leadership: 55, + strength: 55, + intel: 55, + character: 'Random', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: expect.stringContaining('카카오 인증'), + }); + expect(worldStateReads.count).toBe(0); + }); + it('queues turn daemon run commands', async () => { const transport = new InMemoryTurnDaemonTransport(); const caller = appRouter.createCaller(buildContext({ transport })); diff --git a/app/game-frontend/e2e/commandArgumentsLive.spec.ts b/app/game-frontend/e2e/commandArgumentsLive.spec.ts index 8134d55..c9328a7 100644 --- a/app/game-frontend/e2e/commandArgumentsLive.spec.ts +++ b/app/game-frontend/e2e/commandArgumentsLive.spec.ts @@ -2,6 +2,7 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; import { expect, test } from '@playwright/test'; +import { constants, publicEncrypt } from 'node:crypto'; const gatewayUrl = process.env.COMMAND_LIVE_GATEWAY_URL ?? 'http://127.0.0.1:13160/trpc'; const gameUrl = process.env.COMMAND_LIVE_GAME_URL ?? 'http://127.0.0.1:14160/trpc'; @@ -15,9 +16,20 @@ test('reserves an argument command in the real game API and reads it back from P const gateway = createTRPCProxyClient({ links: [httpBatchLink({ url: gatewayUrl })], }); + const passwordKey = await gateway.auth.passwordKey.query(); const login = await gateway.auth.login.mutate({ username: 'demo1', - password: 'demo-pass-1', + credential: { + keyId: passwordKey.keyId, + ciphertext: publicEncrypt( + { + key: passwordKey.publicKeyPem, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + Buffer.from('demo-pass-1', 'utf8') + ).toString('base64'), + }, }); const issued = await gateway.auth.issueGameSession.mutate({ sessionToken: login.sessionToken, diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index ac34411..b513b93 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -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": { diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index c3c0fa0..d6c1258 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -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) { diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 08a10c8..936174c 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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(), }), }) ) diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 7515a83..17879a0 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -21,6 +21,14 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp async findByUsername(username: string): Promise { return usersByName.get(username) ?? null; }, + async findByDisplayName(displayName: string): Promise { + for (const user of usersByName.values()) { + if (user.displayName === displayName) { + return user; + } + } + return null; + }, async findByOauthId(type: 'KAKAO', oauthId: string): Promise { 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 { - 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 { 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 { + 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 { for (const user of usersByName.values()) { if (user.id === userId) { diff --git a/app/gateway-api/src/auth/localAccountPolicy.ts b/app/gateway-api/src/auth/localAccountPolicy.ts new file mode 100644 index 0000000..ba48672 --- /dev/null +++ b/app/gateway-api/src/auth/localAccountPolicy.ts @@ -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, 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; + 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, + }; +}; diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index 9bab3c7..3e22e3a 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -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; + createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise; consumePendingState(state: string): Promise; createSession(session: Omit): Promise; consumeSession(sessionId: string): Promise; @@ -61,11 +62,12 @@ export class RedisOAuthSessionStore implements OAuthSessionStore { return `${this.prefix}:oauth-session:${sessionId}`; } - async createPendingState(mode: OAuthMode, scopes: string[]): Promise { + async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise { 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(); private readonly sessions = new Map(); - async createPendingState(mode: OAuthMode, scopes: string[]): Promise { + async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise { const pending: OAuthPendingState = { state: randomUUID(), mode, scopes, + userId, createdAt: new Date().toISOString(), }; this.pendingStates.set(pending.state, pending); diff --git a/app/gateway-api/src/auth/passwordEnvelope.ts b/app/gateway-api/src/auth/passwordEnvelope.ts new file mode 100644 index 0000000..16c9b5e --- /dev/null +++ b/app/gateway-api/src/auth/passwordEnvelope.ts @@ -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); + }, + }; +}; diff --git a/app/gateway-api/src/auth/passwordHasher.ts b/app/gateway-api/src/auth/passwordHasher.ts index 716ff42..43c7a31 100644 --- a/app/gateway-api/src/auth/passwordHasher.ts +++ b/app/gateway-api/src/auth/passwordHasher.ts @@ -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 => + 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; diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 7d56498..7d4e1a3 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -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 { + const row = await prisma.appUser.findUnique({ + where: { + displayName, + }, + }); + return row ? mapUser(row) : null; + }, async findByOauthId(type: 'KAKAO', oauthId: string): Promise { const row = await prisma.appUser.findFirst({ where: { @@ -94,34 +110,53 @@ export const createPostgresUserRepository = ( return row ? mapUser(row) : null; }, async createUser(input: CreateUserInput): Promise { - 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 { - 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 { - 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 { + 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 { await prisma.appUser.update({ where: { id: userId }, diff --git a/app/gateway-api/src/auth/registrationInput.ts b/app/gateway-api/src/auth/registrationInput.ts new file mode 100644 index 0000000..84b764e --- /dev/null +++ b/app/gateway-api/src/auth/registrationInput.ts @@ -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; +}; diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index d8a9ff4..43326cd 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -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; findByUsername(username: string): Promise; + findByDisplayName(displayName: string): Promise; findByOauthId(type: 'KAKAO', oauthId: string): Promise; findByEmail(email: string): Promise; createUser(input: CreateUserInput): Promise; verifyPassword(user: UserRecord, password: string): Promise; updatePassword(userId: string, password: string): Promise; updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise; + linkKakao( + userId: string, + input: { + oauthId: string; + email: string; + oauthInfo: UserOAuthInfo; + verifiedAt: Date; + } + ): Promise; updateRoles(userId: string, roles: string[]): Promise; updateSanctions(userId: string, sanctions: UserSanctions): Promise; updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise; diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index cd1f27e..5de04e4 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -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, diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index d7fd982..b10827f 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -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, diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index f1eb691..7b7149b 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -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>; 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>; + 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 { diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 1b36d66..c4ce72f 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -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, diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 1158993..ad56602 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -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: () => {}, diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 78fc44f..79bc1ce 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -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 = {}; + 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); diff --git a/app/gateway-api/test/localAccountPolicy.test.ts b/app/gateway-api/test/localAccountPolicy.test.ts new file mode 100644 index 0000000..85746f4 --- /dev/null +++ b/app/gateway-api/test/localAccountPolicy.test.ts @@ -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, + }); + }); +}); diff --git a/app/gateway-api/test/passwordCredential.test.ts b/app/gateway-api/test/passwordCredential.test.ts new file mode 100644 index 0000000..2c37546 --- /dev/null +++ b/app/gateway-api/test/passwordCredential.test.ts @@ -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)); + }); +}); diff --git a/app/gateway-frontend/public/terms.1.html b/app/gateway-frontend/public/terms.1.html new file mode 100644 index 0000000..6cfb932 --- /dev/null +++ b/app/gateway-frontend/public/terms.1.html @@ -0,0 +1,151 @@ + + + + + + +
+    HiDCHe서버 이용 약관
+
+    제1조 (목적)
+    이 약관은 HiDCHe서버 서비스 제공자(이하 “서비스 제공자”)가 제공하는 삼국지 모의전투 서비스와 관련하여 서비스 제공자와 이용자의 권리, 의무, 책임사항 및 기타 필요 사항을 규정함을 그 목적으로 한다.
+
+    제2조 (정의)
+    이 약관에서 사용하는 용어의 정의는 다음과 같다.
+      1. “서비스”라 함은 디스플레이가 구현되는 단말 장치(PC, 휴대폰, TV 등 각종 유무선 장치를 포함하나 이에 제한되지 아니한)에서 제공되는 삼국지 모의전투 서비스 및 관련 제반 서비스를 의미한다.
+      2. “이용자”라 함은 서비스에 접속하여 본 약관에 따라 서비스 제공자와 이용계약을 체결하고 서비스 이용자가 제공하는 서비스를 이용하는 자를 의미한다.
+      3. “아이디”라 함은 이용자의 식별과 서비스 이용을 위하여 사용자가 정한 일련의 문자(한글, 영문, 숫자 등) 조합을 의미한다.
+      4. “비밀번호” 또는 “패스워드”란 아이디와 1:1로 매칭되어 이용자가 해당 아이디와 일치되는 이용자임을 확인하고 보호하는 이용자 본인이 지정한 일련의 문자 조합을 의미한다.
+      5. “게시물”이라 함은 이용자가 서비스를 이용함에 있어서 서비스 상에 게시한 문자, 문서, 그림, 영상 등 시청각적 요소를 의미한다.
+      6. “운영자”란 서비스 제공자 및 서비스 제공자에 의하여 권한을 위임받은 자로써, 안정적이고 쾌적한 서비스 제공을 위하여 노력 할 의무를 가지는 자를 의미한다.
+      7. 본 약관에서 정의하지 않은 용어는 서비스 세부 지침(약관) 내지는 관계 법령 등에서 정하는 바에 따르며, 이에 정하지 아니한 것은 사회적 통념상 따르는 관습에 따라 해석한다. 단, 이용자 간의 분쟁에서 명확하게 해석되지 아니하는 용어 및 관습은 운영자 과반 이상의 동의에 따라 해석한다.
+
+    제3조(약관의 개정)
+    ⓵ 서비스 제공자는 “약관의 규제에 관한 법률” 및 “정보통신망 이용촉진 및 정보보호 등에 관한 법률” 등 현행법을 위배하지 않는 범위 내에서 본 약관을 개정할 수 있다.
+    ⓶ 서비스 제공자가 본 약관을 개정할 경우, 적용일자 및 개정 사유를 명시하여 최소 적용일자 30일 전부터 적용일자 전 까지 게시 할 의무를 가진다. 단, 이용자에게 현저하게 불리하게 개정되는 경우 개정의 게시 이외에 전자우편 등의 전자적 수단을 이용하여 별도로 통지 할 의무를 가진다.
+    ⓷ 이용자는 30일 기간 내에 서비스 탈퇴 및 기타 전자적 수단을 통한 명시적 거부의 의사표시를 하지 아니한 경우, 이용자는 개정된 약관에 동의를 한 것으로 간주한다.
+    ⓸ 이용자가 개정된 약관에 동의하지 않는 경우, 서비스 제공자는 개정 약관을 해당 이용자에게 적용할 수 없으며, 기존 약관을 적용하기 현저하게 곤란한 사정이 있거나 적용할 수 없는 특별한 사정이 있는 경우, 서비스 제공자는 이용계약을 해지할 수 있다.
+
+    제4조(약관의 게시)
+    ⓵ 서비스 제공자는 본 약관을 내용을 이용자들이 쉽게 알 수 있도록 제공 할 의무가 있으며, 이용자는 본 약관을 언제든지 열람 할 권리를 가진다.
+    ⓶ 약관은 서비스 초기 화면을 통하여 접근할 수 있으며, 서비스 초기 화면에서 제공되는 약관 이외의 약관의 내용은 효력이 없다.
+
+    제5조(이용계약의 체결)
+    ⓵ 이용계약은 이용자가 되고자 하는 자(이하 “가입희망자”)가 약관에 대하여 동의를 한 다음 회원가입 신청을 하고, 서비스 제공자가 승낙함으로써 체결된다.
+    ⓶ 서비스 제공자는 원칙적으로 가입희망자가 적법하게 한 회원가입에 대해서는 승낙한다. 다만, 서비스 제공자는 아래 각 호에 해당하는 신청에 대하여서는 승낙을 거부하거나, 사후 거부 사유가 발견되는 경우 이용계약을 해지할 수 있다.
+      1. 실명이 아니거나 타인의 명의를 도용하여 가입한 경우
+      2. 14세 미만 아동이 법정대리인의 동의를 얻지 아니한 경우
+      3. 허위 정보 또는 서비스 제공자를 기망하고자 하는 의도를 가지고 서비스 제공자가 제시하는 내용을 기재하지 않은 경우
+      4. 본 약관에서 규정한 제반 사항을 위반한 자로써, 서비스 제공자에 의하여 가입희망자가 그 이용 권한을 영구적으로 박탈당한 경우
+      5. 기타 운영자 과반 이상의 판단에 따라 가입희망자를 승인함으로써 원활한 서비스 제공을 하는데 문제가 있을 것으로 판단되는 사유가 있는 경우
+    ⓷ 이용계약은 서비스 제공자가 회원가입완료를 표시 한 시점으로 한다.
+    ⓸ 서비스 제공자는 “청소년보호법” 등 기타 법령에 따라 등급 및 연령 준수를 위하여 이용제한이나 등급별 제한을 할 수 있다.
+
+    제6조(회원정보)
+    ⓵ 회원정보란 서비스 이용자 개인이 서비스 제공자에게 제공한 이용자의 정보로, 개인정보를 포함하는 정보이다.
+    ⓶ 이용자는 개인정보 관리 화면을 통하여 언제든지 본인의 정보를 열람하고 수정할 수 있다. 단, 원활한 서비스 관리를 위하여 아이디, 생년월일 등은 수정을 제한할 수 있다.
+    ⓷ 이용자는 본인의 회원정보를 관리 할 의무를 가지며, 변경이 발생하는 경우 지체없이 서비스 제공자에게 온라인 수정 등을 통하여 통보 하여야 한다.
+    ⓸ 제3항의 의무를 태만하게 하여 발생한 불이익에 대하여 서비스 제공자는 책임지지 않는다.
+
+    제7조(개인정보보호의 의무)
+    ⓵ 서비스 제공자는 “정보통신망법” 등 관계 법령에 정하는 바에 따라 이용자의 개인정보를 보호 할 의무를 가진다.
+
+    제8조(이용자의 아이디 및 비밀번호 관리 의무)
+    ⓵ 서버에 저장된 개인정보 이외의 개인정보(아이디 및 비밀번호를 포함한다)에 대한 관리책임은 이용자에게 있다.
+    ⓶ 서비스 제공자 및 운영자는 공공의 질서 및 선량한 사회 풍속을 해칠 염려가 있는 아이디, 또는 운영자로 오인할 염려가 큰 아이디에 대해서 이용을 제한할 수 있다.
+    제9조(서비스 제공자의 의무)
+    ⓵ 서비스 제공자는 관련 법령과 본 약관이 금지하는 행위, 또는 사회질서 및 선량한 풍속을 해칠 염려가 있는 행위를 하지 않으며, 계속적이고 안정적으로 서비스를 제공하기 위하여 최선의 노력을 다 할 의무를 가진다.
+    ⓶ 서비스 제공자는 이용자가 안전하게 서비스를 이용할 수 있도록 개인정보를 취급하는데 있어서 적합한 관리를 해야 하며, 개인정보 제공 및 이용에 대한 방침을 공시하고 준주 할 의무를 가진다.
+    ⓷ 서비스 제공자는 서비스 이용과 관련하여 발생하는 이용자의 불만, 불편, 피해구제요청 및 이용자간의 분쟁을 적절하게 처리 할 수 있도록 필요한 인력 및 시스템을 마련하여야 할 의무를 가진다.
+    ⓸ 서비스 제공자는 서비스 이용과 관련하여 이용자로부터 제기된 의견이 정당하다고 인정되는 경우 이를 가능한 조속히 처리 할 의무를 가지며, 처리 과정 및 결과를 통보 할 의무를 가진다.
+    ⓹ 서비스 제공자는 상기 의무를 수행함에 있어서 적합하다고 판단되는 자를 운영자로 선임할 수 있으며, 원활한 서비스 제공을 위하여 적합한 수의 운영자를 선임 할 의무를 가진다.
+
+    제10조(이용자의 의무)
+    ⓵ 이용자는 원활한 서비스를 위하여 다음과 각 호의 행위를 해서는 안 된다.
+      1. 신청 또는 변경 시 허위 정보를 등록하는 행위
+      2. 타인의 정보를 도용하는 행위
+      3. 서비스 제공자의 저작권 등의 산업지식재산권의 침해 행위
+      4. 서비스 제공자의 업무를 방해하는 행위
+      5. 기타 선량한 공공의 질서를 어지럽히는 행위
+      6. 기타 불법적이거나 부당한 행위
+      7. 의도적으로 서비스 이용자들 간의 불화를 일으키는 행위
+      8. 서비스 제공자 및 운영자 과반 이상의 동의에 따라 원활한 서비스 제공을 하는데 곤란한 사유를 발생시키는 행위
+    ⓶ 이용자는 본 약관 및 관련 법령, 서비스 제공자 및 운영자에 의하여 게시된 공지사항 및 통지사항 등을 준수 할 의무를 가진다.
+
+    제11조(서비스의 제공)
+    ⓵ 서비스 제공자는 다음 각 호의 서비스를 이용자에게 제공한다.
+      1. 삼국지 모의전투
+      2. 게시판 커뮤니티
+      3. 삼국지 모의전투 내 채팅
+      4. 기타 서비스 제공자가 추가 개발하거나 제휴/라이센싱 등을 통해 이용자에게 제공하는 서비스
+    ⓶ 서비스 제공자는 서비스 이용가능시간을 별도로 지정할 수 있으며, 해당 시간은 사전에 공지되어야한다.
+    ⓷ 서비스는 연중무휴, 1일 24시간 제공함을 원칙으로 한다.
+    ⓸ 서비스 제공자는 서비스 기능개선, 컴퓨터 등 정보통신장비의 유지보수, 점검, 고장, 통신두절, 천재지변 등 운영상 상당한 이유가 있는 경우 서비스의 제공을 일시적으로 중단할 수 있다. 이 경우, 서비스 제공자는 사전에 전자우편, 공지사항 등 적합한 방법으로 이용자들에게 고지 할 의무가 있다. 단, 서비스 제공자가 사전에 통지할 수 없는 부득이한 사유가 있는 경우 그렇지 않다.
+
+    제12조(계약해제, 해지 등)
+    ⓵ 이용자는 언제든지 “회원탈퇴”기능을 통하여 이용계약 해지신청을 할 수 있으며, 서비스 제공자는 관련 법령이 정하는 바에 따라 지체 없이 처리하여야 한다.
+    ⓶ 이용자가 이용계약을 해지할 경우, 개인정보처리방침에 따라 회사가 이용자의 정보를 보유하는 경우를 제외하고는 지체 없이 모든 데이터를 삭제하여야 한다.
+    ⓷ 이용자가 이용계약을 해지하는 경우, 이용자가 작성한 게시물 중 본인만 조회 가능한 게시물 일체는 삭제된다. 단, 타인에 의하여 스크랩 되거나, 공용게시판, 타인의 쪽지함 등록된 게시물 등 타인에게 노출되는 게시물 등은 삭제되지 않을 수 있다.
+    ⓸ 이용자가 이용계약을 해제하는 경우, 서비스 제공자는 일정 기간 내 동일한 정보로 회원 가입을 제한할 수 있다.
+
+    제13조(이용제한)
+    ⓵ 서비스 제공자는 이용자가 본 약관의 의무를 위반하거나, 서비스의 정상적인 운영을 방해하는 경우 그 경중 및 반복횟수에 따라 경고, 일시정지, 영구정지 등 단계적으로 서비스의 이용을 제한할 수 있다.
+    ⓶ 서비스 제공자는 제1조에도 불구하고, 아래 각 호의 위반이 발견되는 경우 즉시 영구이용정지를 할 수 있으며, 본 항에 따른 이용정지 시 발생하는 손해에 대해서 서비스 제공자는 책임을 지지 않는다.
+      1. 정보통신망법 소정의 불법통신, 해킹, 악성프로그램 배포 등 보안에 중대한 위협을 주는 행위
+      2. 주민등록법 소정의 명의도용을 하는 행위
+      3. 저작권법 소정의 불법프로그램의 제공/배포 및 운영방해 행위
+      4. 기타 서비스 제공에 중대한 지장을 주는 것으로 운영자 과반 이상의 동의를 받는 행위
+
+    제14조(이용제한에 대한 이의신청)
+    ⓵ 이용자는 제13조에 따른 이용제한이 부당하다고 생각되는 경우, 이를 통보받은 날로부터 5일 이내에 서비스 제공자의 이용제한에 대한 불복사유를 기재한 이의신청서를 게시글 또는 전자우편, 혹은 이에 준하는 방법으로 서비스 제공자에게 제출하여야 한다.
+    ⓶ 제1항의 이의신청서를 접수한 서비스 제공자는 지체 없이 이용자에게 접수에 대한 통보를 하여야 하며, 접수 한 날로부터 5일 이내에 불복 사유에 대하여 게시글에 대한 답글, 전자우편, 또는 이에 준하는 방법으로 답변하여야 한다. 단, 5일 이내에 답변이 현저하게 곤란한 경우 그 사유와 처리예정일정을 통보하여야 한다.
+    ⓷ 서비스 제공자는 제2항의 답변에 따라 상응하는 조치를 즉시 취하여야 한다.
+
+    제15조(서비스 제공자의 책임제한)
+    ⓵ 서비스 제공자는 천재지변, 또는 이에 준하는 불가항력으로 인하여 서비스를 제공할 수 없는 경우 서비스 제공에 대한 책임이 면제된다.
+    ⓶ 서비스 제공자는 이용자의 귀책사유로 인한 서비스 이용 장애에 대하여서는 책임지지 않는다.
+    ⓷ 서비스 제공자는 회원이 서비스와 관련하여 게재한 정보에 대해서 그 신뢰성과 정확성에 대해서 책임지지 않으며, 이를 정정 할 의무를 가지지 않는다.
+    ⓸ 서비스 제공자는 이용자 간 서비스의 내용을 매개로 하여 서비스 제공자가 원본 데이터에 접근할 수 없는 방법으로 거래 등 이에 준하는 행위를 한 경우 그 내용에 대해서 책임지지 않는다.
+    ⓹ 서비스 제공자는 무료로 제공되는 서비스 이용과 관련하여 관련 법령에 특별한 규정이 없는 한 책임지지 않는다.
+    ⓺ 서비스 제공자는 기간통신 사업자가 전기통신서비스를 중지하거나, 정상적으로 제공하지 아니하여 이용자에게 유·무형적 손해가 발생한 경우, 서비스 제공자에게 고의 또는 중대한 과실이 없는 한 책임지지 않는다.
+    ⓻ 서비스 제공자는 사전에 공지된 서비스 중단으로 인하여 장애가 발생한 경우 서비스 제공자의 고의 또는 중대한 과실이 없는 한 책임지지 않는다.
+    ⓼ 서비스 제공자는 이용자의 서비스 이용 환경(네트워크, PC설정 등)으로 인하여 발생하는 문제에 대해서 책임지지 않는다.
+
+    제16조(분쟁조정)
+    ⓵ 서비스 제공자는 서비스 내의 이용자 간의 분쟁에 대해서는 개입하지 않고, 이용자간의 합의를 수용하는 것을 원칙으로 한다.
+    ⓶ 제1항에도 불구하고, 아래 각 호의 경우에 대해서 서비스 제공자는 이용자 간의 분쟁 조정을 위하여 개입할 수 있다.
+      1. 분쟁 양 당사자 간의 합의가 이루어지지 않아 양 당사자 모두 서비스 제공자에게 게시물, 전자우편 또는 이에 준하는 방법으로 분쟁조정을 요청하는 경우
+      2. 본 약관 또는 관련 법령을 분쟁 당사자중 일방 또는 양방이 위배하는 경우
+      3. 분쟁이 합리적인 시간 이상 지속되어 원활한 서비스를 방해한다고 판단되는 경우
+      4. 기타 원활하고 쾌적한 서비스를 제공하는데 문제가 될 소지가 있다고 판단되는 경우
+    ⓷ 제2항의 분쟁조정자는 서비스 제공자가 판단하는 경중에 따라서 선임된 1인의 운영자 혹은 운영자 전원합의체에 의하여 분쟁조정을 개시한다. 이 때, 분쟁조정의 결과는 분쟁조정 개시로부터 5일 이내에 게시물에 대한 답글, 공지사항, 전자우편 또는 이에 준하는 공개적인 방법으로 게시되어야 한다.
+    ⓸ 분쟁조정의 판단 결과는 오직 재심청구에 의해서만 번복될 수 있다. 단, 운영자 전원합의체에서 판단된 분쟁조정은 재심청구 할 수 없다.
+    ⓹ 분쟁의 당사자는 운영자 1인 이상에게 제척사유가 있다고 판단되는 경우 제2항 제1호 소정의 분쟁조정 신청 시 서비스 제공자에게 제척신청을 하여야 한다.
+
+    제17조(분쟁조정 재심청구)
+    ⓵ 이용자는 제16조 소정의 분쟁조정에서 운영자 1인의 분쟁조정이 불합리하거나, 문제가 있다고 판단되는 경우 게시물, 전자우편, 또는 이에 준하는 방법으로 서비스 제공자에게 재심청구를 할 수 있다.
+    ⓶ 제1항 소정의 재심청구 시, 서비스 제공자는 반드시 운영자 전원합의체를 구성하여야 하며, 3일 이내에 재심에 대한 결과를 이용자에게 통보하여야 한다. 이 때, 분쟁조정에 참여한 운영자 1인은 중대한 문제가 있다고 판단되지 않는 이상 제척대상이 아니다.
+    ⓷ 재심청구에 대한 판단 결과의 게시는 분쟁조정의 게시를 준용한다.
+
+    제18조(운영자 전원합의체)
+    ⓵ 운영자 전원합의체는 서비스 운영에 있어서 중대한 판단을 수행하는 합의체로, 다음과 같이 구성된다.
+      1. 서비스 제공자(의장)
+      2. 서비스 제공자에 의하여 선임된 1인 이상의 운영자
+    ⓶ 운영자 전원합의체는 원활한 서비스 운영을 유지하기 위한 업무를 담당하며, 다음 각 호의 업무를 수행한다.
+      1. 분쟁조정의 재심청구
+      2. 제16조 제2항 제4호 해당 여부 판단
+      3. 이용자 1인의 제10조 제1항 제3호 내지 제5호, 제7호, 제8호 해당 여부 판단
+      4. 기타 서비스 운영에 있어 서비스 제공자가 중대하다고 판단한 안건에 대한 판단
+    ⓷ 서비스 제공자는 제2호 각호의 사유가 발생한 경우, 지체 없이 운영자 전원합의체를 구성하여 판단을 내려야 한다. 단, 운영자 1인 이상의 부재로 정족수(운영자 전원합의체 구성인원의 2/3 이상)를 만족하지 못 하는 경우, 게시물, 전자우편 또는 이에 준하는 방법으로 소집일을 기재하여 통보하여야 한다.
+    ⓸ 운영자 전원합의체는 반드시 서비스에서 제공하는 채팅, 또는 서비스 제공자가 공인한 공개적 방법을 통해서 진행해야하며, 전원합의체 종료 시 서비스 제공자는 모든 로그 파일을 이용자에게 공개 할 의무를 가진다.
+    ⓹ 전원합의체는 만장일치를 원칙으로 한다. 단, 다음 각 호의 경우 의장은 다수결을 진행할 수 있다. 단, 다수결로 진행된 전원합의체 판단은 사후 전원합의체의 만장일치로 파기될 수 있다.
+      1. 개인 간의 가치판단이 충돌하여 합의에 이르기 불가능하다고 판단되는 경우
+      2. 기타 판단에 시급성을 요하는 경우
+    ⓺ 운영자 전원합의체는 필요하다고 판단되는 경우, 이용자 및 관련자를 증인으로 하여 판단의 참고자료로 이용할 수 있다.
+
+
+    부칙 <2018.4.17.>
+    ⓵ 본 약관은 2018.4.17.부터 적용된다.
+
+ + diff --git a/app/gateway-frontend/public/terms.2.html b/app/gateway-frontend/public/terms.2.html new file mode 100644 index 0000000..2c5d4c0 --- /dev/null +++ b/app/gateway-frontend/public/terms.2.html @@ -0,0 +1,30 @@ + + + + + + +
+개인정보 제공 및 이용에 대한 동의
+
+0. 아래 내용을 자세히 읽으신 후, 개인정보 제공 및 이용, 그리고 제3자 제공에 대한 동의 여부를 결정하여 주십시오.
+
+1. HiDCHe서버 서비스(이하 ‘본 서비스’) 이용에 있어, 서비스 이용자(이하 ‘이용자)개인정보를 다음과 같이 전산망 등에 수집·관리 하고 있습니다.
+  ○ 개인정보의 수집·이용 목적 	: 셧다운제 영향에 따른 이용자 연령 확인, 중복 가입 여부, 개인별 참여 이력 관리 등 서비스 제공에 따른 활용
+  ○ 수집하는 개인정보 항목	: 아이디, 이메일, 생년월일, 전화번호
+  ○ 개인정보의 보유 및 이용기간	: 보유기간은 서비스 이용 기간 및 서비스 탈퇴 후 1개월
+
+2. 원활한 서비스 제공을 위하여, 본 서비스는 위와 같은 개인정보가 필요하며, 「개인정보보호법」에 따라 이용자로부터 제공받는 개인정보를 보호합니다.
+
+3. 서비스 제공자는 개인정보를 처리 목적에 필요한 범위 내에서 적합하게 관리·이용하고, 그 목적 외의 용도로는 사용하지 않으며, 개인정보를 제공한 이용자는 언제나 자신이 입력한 개인정보의 열람 및 수정을 신청할 수 있습니다. 다만, 기타 법령 및 고시 등 공적인 사유에 의거하여 정보의 보존이 필요하다 판단되거나 강제되는 경우 서비스 제공자는 개인정보의 수정을 거부할 수 있습니다.
+
+4. 개인정보 3자 제공 내역
+  * 없음
+
+5. 이용자는 개인정보 제공에 대한 동의를 거부할 권리가 있습니다. 그러나 동의를 거부할 경우, 원활한 서비스 제공에 제한을 받을 수 있습니다.
+
+개인정보 관리자 : 이동수
+연락처 : hided62@gmail.com
+
+ + diff --git a/app/gateway-frontend/src/router/index.ts b/app/gateway-frontend/src/router/index.ts index a77baaa..0666af6 100644 --- a/app/gateway-frontend/src/router/index.ts +++ b/app/gateway-frontend/src/router/index.ts @@ -5,6 +5,7 @@ import AdminView from '../views/AdminView.vue'; import ServerOperationsView from '../views/ServerOperationsView.vue'; import AccountView from '../views/AccountView.vue'; import OAuthCallbackView from '../views/OAuthCallbackView.vue'; +import SignupView from '../views/SignupView.vue'; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -14,6 +15,11 @@ const router = createRouter({ name: 'home', component: HomeView, }, + { + path: '/signup', + name: 'signup', + component: SignupView, + }, { path: '/lobby', name: 'lobby', diff --git a/app/gateway-frontend/src/utils/passwordEnvelope.ts b/app/gateway-frontend/src/utils/passwordEnvelope.ts new file mode 100644 index 0000000..c2ca017 --- /dev/null +++ b/app/gateway-frontend/src/utils/passwordEnvelope.ts @@ -0,0 +1,46 @@ +import { trpc } from './trpc'; + +const pemToBuffer = (pem: string): ArrayBuffer => { + const encoded = pem.replace(/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s/g, ''); + const binary = window.atob(encoded); + const buffer = new ArrayBuffer(binary.length); + const bytes = new Uint8Array(buffer); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return buffer; +}; + +const bytesToBase64 = (bytes: Uint8Array): string => { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return window.btoa(binary); +}; + +export const sealPassword = async (password: string): Promise<{ keyId: string; ciphertext: string }> => { + const encoded = new TextEncoder().encode(password); + if (Array.from(password).length < 6 || encoded.byteLength > 128) { + throw new Error('비밀번호는 6자 이상, UTF-8 기준 128바이트 이하여야 합니다.'); + } + if (!window.crypto?.subtle) { + throw new Error('이 브라우저에서는 안전한 비밀번호 전송을 사용할 수 없습니다.'); + } + const keyInfo = await trpc.auth.passwordKey.query(); + const publicKey = await window.crypto.subtle.importKey( + 'spki', + pemToBuffer(keyInfo.publicKeyPem), + { + name: 'RSA-OAEP', + hash: 'SHA-256', + }, + false, + ['encrypt'] + ); + const ciphertext = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, encoded); + return { + keyId: keyInfo.keyId, + ciphertext: bytesToBase64(new Uint8Array(ciphertext)), + }; +}; diff --git a/app/gateway-frontend/src/views/AccountView.vue b/app/gateway-frontend/src/views/AccountView.vue index 85cf8ec..3309bcb 100644 --- a/app/gateway-frontend/src/views/AccountView.vue +++ b/app/gateway-frontend/src/views/AccountView.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'; import DefaultLayout from '../layouts/DefaultLayout.vue'; import { trpc } from '../utils/trpc'; +import { sealPassword } from '../utils/passwordEnvelope'; type Account = Awaited>; @@ -65,10 +66,14 @@ const changePassword = async (): Promise => { await runAction(async () => { const token = sessionToken(); if (!token) throw new Error('로그인이 필요합니다.'); + const [currentCredential, newCredential] = await Promise.all([ + sealPassword(currentPassword.value), + sealPassword(newPassword.value), + ]); await trpc.account.changePassword.mutate({ sessionToken: token, - currentPassword: currentPassword.value, - newPassword: newPassword.value, + currentCredential, + newCredential, }); currentPassword.value = ''; newPassword.value = ''; @@ -92,9 +97,10 @@ const scheduleDeletion = async (): Promise => { await runAction(async () => { const token = sessionToken(); if (!token) throw new Error('로그인이 필요합니다.'); + const currentCredential = await sealPassword(deletePassword.value); const result = await trpc.account.scheduleDeletion.mutate({ sessionToken: token, - currentPassword: deletePassword.value, + currentCredential, }); window.localStorage.removeItem('sammo-session-token'); successMessage.value = `${new Date(result.deleteAfter).toLocaleDateString('ko-KR')}까지 정보가 보존됩니다.`; diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 34e6c41..1aa8e3f 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -9,6 +9,7 @@ import DefaultLayout from '../layouts/DefaultLayout.vue'; import { createGameTrpc, type GameRouter } from '../utils/gameTrpc'; import { trpc } from '../utils/trpc'; import { formatLog } from '../utils/formatLog'; +import { sealPassword } from '../utils/passwordEnvelope'; type GatewayOutput = inferRouterOutputs; type GameOutput = inferRouterOutputs; @@ -84,9 +85,10 @@ const handleLogin = async (): Promise => { loginError.value = ''; loginLoading.value = true; try { + const credential = await sealPassword(password.value); const result = await trpc.auth.login.mutate({ username: username.value, - password: password.value, + credential, }); window.localStorage.setItem('sammo-session-token', result.sessionToken); await router.push('/lobby'); @@ -146,7 +148,7 @@ const handlePasswordReset = async (): Promise => { required /> + @@ -261,6 +264,24 @@ const handlePasswordReset = async (): Promise => { cursor: pointer; } +.signup-button { + grid-column: 1 / -1; + min-height: 30px; + border: 1px solid #375a7f; + border-radius: 4px; + background: #223851; + color: #fff; + font-weight: 700; + line-height: 30px; + text-align: center; + text-decoration: none; +} + +.signup-button:hover, +.signup-button:focus { + background: #2f4d6c; +} + .reset-button { grid-column: 1 / -1; min-height: 30px; diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index e53ad4f..0af3ac2 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -38,6 +38,10 @@ const canAccessAdmin = computed( role.startsWith('admin.') ) ?? false ); +const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified); + +const formatGraceEndsAt = (value: string | null | undefined): string => + value ? new Date(value).toLocaleString('ko-KR') : ''; onMounted(async () => { try { @@ -82,11 +86,30 @@ onMounted(async () => { }); const handleLogout = async () => { - // TODO: Implement logout mutation in gateway-api - // await trpc.auth.logout.mutation(); + const sessionToken = window.localStorage.getItem('sammo-session-token'); + if (sessionToken) { + await trpc.auth.logout.mutate({ sessionToken }); + window.localStorage.removeItem('sammo-session-token'); + } await router.push('/'); }; +const handleKakaoVerification = async (): Promise => { + const sessionToken = window.localStorage.getItem('sammo-session-token'); + if (!sessionToken) { + await router.push('/'); + return; + } + try { + const result = await trpc.auth.kakaoStart.query({ + mode: 'verify', + }); + window.location.assign(result.authUrl); + } catch (error) { + alert(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.'); + } +}; + const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => { const profile = profileName.split(':', 1)[0] ?? profileName; const baseUrl = @@ -127,7 +150,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { window.location.href = url; } catch (e) { console.error('Failed to issue game session', e); - alert('게임 서버 접속에 실패했습니다.'); + alert(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.'); } finally { entryLoading.value[profile.profileName] = false; } @@ -143,6 +166,30 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { +
+
+
+ 카카오 인증이 필요합니다. + + 유예기간이 지나면 게임에 입장할 수 없습니다. che·kwe·twe는 인증 전 장수 생성도 + 제한됩니다. + +
+ +
+
+
{
<{{ profileDetails[profile.profileName]?.nationCnt }}국 경쟁중>
+
+ 인증 전 생성 불가 +
+
+ {{ formatGraceEndsAt(profile.localAccountPolicy.graceEndsAt) }}까지 유예 +
@@ -251,10 +313,17 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {