feat: integrate Kakao OAuth for user authentication and enhance user repository
- Added Kakao OAuth client implementation for handling authentication flows. - Updated user repository to support OAuth-based user creation and retrieval. - Introduced Redis-based session management for OAuth sessions. - Enhanced in-memory user repository to handle OAuth user data. - Updated environment configuration files to include new OAuth settings. - Modified API routes to support Kakao login and registration flows. - Added Prisma schema updates for user model to accommodate OAuth fields. - Implemented tests for the new authentication flow and user repository methods.
This commit is contained in:
@@ -2,3 +2,17 @@ POSTGRES_DB=sammo_test
|
|||||||
POSTGRES_USER=sammo
|
POSTGRES_USER=sammo
|
||||||
POSTGRES_PASSWORD=ci-postgres
|
POSTGRES_PASSWORD=ci-postgres
|
||||||
REDIS_PASSWORD=ci-redis
|
REDIS_PASSWORD=ci-redis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=16379
|
||||||
|
REDIS_DB=0
|
||||||
|
GAME_TOKEN_SECRET=ci-secret
|
||||||
|
GATEWAY_REDIS_PREFIX=sammo:gateway
|
||||||
|
GATEWAY_API_HOST=127.0.0.1
|
||||||
|
GATEWAY_API_PORT=13000
|
||||||
|
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||||
|
GAME_API_HOST=127.0.0.1
|
||||||
|
GAME_API_PORT=14000
|
||||||
|
PROFILE=che
|
||||||
|
SCENARIO=default
|
||||||
|
KAKAO_REST_KEY=ci-kakao-rest-key
|
||||||
|
KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Database
|
||||||
|
POSTGRES_HOST=127.0.0.1
|
||||||
|
POSTGRES_PORT=15432
|
||||||
|
POSTGRES_DB=sammo
|
||||||
|
POSTGRES_USER=sammo
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
# DATABASE_URL=postgresql://sammo:change-me@127.0.0.1:15432/sammo?schema=public
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=16379
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_PASSWORD=change-me
|
||||||
|
# REDIS_URL=redis://:change-me@127.0.0.1:16379/0
|
||||||
|
|
||||||
|
# Shared secrets
|
||||||
|
GAME_TOKEN_SECRET=change-me
|
||||||
|
|
||||||
|
# Gateway API
|
||||||
|
GATEWAY_API_HOST=0.0.0.0
|
||||||
|
GATEWAY_API_PORT=13000
|
||||||
|
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||||
|
GATEWAY_REDIS_PREFIX=sammo:gateway
|
||||||
|
SESSION_TTL_SECONDS=604800
|
||||||
|
GAME_SESSION_TTL_SECONDS=21600
|
||||||
|
OAUTH_SESSION_TTL_SECONDS=600
|
||||||
|
KAKAO_REST_KEY=your-kakao-rest-key
|
||||||
|
KAKAO_ADMIN_KEY=your-kakao-admin-key
|
||||||
|
KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback
|
||||||
|
|
||||||
|
# Game API
|
||||||
|
GAME_API_HOST=0.0.0.0
|
||||||
|
GAME_API_PORT=14000
|
||||||
|
PROFILE=che
|
||||||
|
SCENARIO=default
|
||||||
|
DAEMON_REQUEST_TIMEOUT_MS=5000
|
||||||
|
TRPC_PATH=/trpc
|
||||||
@@ -2,3 +2,18 @@ POSTGRES_DB=sammo
|
|||||||
POSTGRES_USER=sammo
|
POSTGRES_USER=sammo
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
REDIS_PASSWORD=change-me
|
REDIS_PASSWORD=change-me
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=16379
|
||||||
|
REDIS_DB=0
|
||||||
|
GAME_TOKEN_SECRET=change-me
|
||||||
|
GATEWAY_REDIS_PREFIX=sammo:gateway
|
||||||
|
GATEWAY_API_HOST=0.0.0.0
|
||||||
|
GATEWAY_API_PORT=13000
|
||||||
|
GATEWAY_PUBLIC_URL=https://example.com
|
||||||
|
GAME_API_HOST=0.0.0.0
|
||||||
|
GAME_API_PORT=14000
|
||||||
|
PROFILE=che
|
||||||
|
SCENARIO=default
|
||||||
|
KAKAO_REST_KEY=your-kakao-rest-key
|
||||||
|
KAKAO_ADMIN_KEY=your-kakao-admin-key
|
||||||
|
KAKAO_REDIRECT_URI=https://example.com/oauth/kakao/callback
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@prisma/client": "^7.2.0",
|
||||||
"@sammo-ts/common": "workspace:*",
|
"@sammo-ts/common": "workspace:*",
|
||||||
"@sammo-ts/infra": "workspace:*",
|
"@sammo-ts/infra": "workspace:*",
|
||||||
"@trpc/server": "^11.4.4",
|
"@trpc/server": "^11.4.4",
|
||||||
|
|||||||
@@ -8,31 +8,70 @@ export const createInMemoryUserRepository = (
|
|||||||
hasher: PasswordHasher = createSimplePasswordHasher()
|
hasher: PasswordHasher = createSimplePasswordHasher()
|
||||||
): UserRepository => {
|
): UserRepository => {
|
||||||
const usersByName = new Map<string, UserRecord>();
|
const usersByName = new Map<string, UserRecord>();
|
||||||
|
const usersByOauthId = new Map<string, UserRecord>();
|
||||||
|
const usersByEmail = new Map<string, UserRecord>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||||
return usersByName.get(username) ?? null;
|
return usersByName.get(username) ?? null;
|
||||||
},
|
},
|
||||||
|
async findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null> {
|
||||||
|
return usersByOauthId.get(`${type}:${oauthId}`) ?? null;
|
||||||
|
},
|
||||||
|
async findByEmail(email: string): Promise<UserRecord | null> {
|
||||||
|
return usersByEmail.get(email.toLowerCase()) ?? null;
|
||||||
|
},
|
||||||
async createUser(input: CreateUserInput): Promise<UserRecord> {
|
async createUser(input: CreateUserInput): Promise<UserRecord> {
|
||||||
if (usersByName.has(input.username)) {
|
if (usersByName.has(input.username)) {
|
||||||
throw new Error('User already exists.');
|
throw new Error('User already exists.');
|
||||||
}
|
}
|
||||||
const salt = hasher.createSalt();
|
const salt = hasher.createSalt();
|
||||||
|
const oauthType = input.oauth?.type ?? 'NONE';
|
||||||
const user: UserRecord = {
|
const user: UserRecord = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
username: input.username,
|
username: input.username,
|
||||||
displayName: input.displayName ?? input.username,
|
displayName: input.displayName ?? input.username,
|
||||||
roles: ['user'],
|
roles: ['user'],
|
||||||
sanctions: {},
|
sanctions: {},
|
||||||
|
oauthType,
|
||||||
|
oauthId: input.oauth?.id,
|
||||||
|
email: input.oauth?.email,
|
||||||
|
oauthInfo: input.oauth?.info,
|
||||||
passwordSalt: salt,
|
passwordSalt: salt,
|
||||||
passwordHash: hasher.hash(input.password, salt),
|
passwordHash: hasher.hash(input.password, salt),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
usersByName.set(input.username, user);
|
usersByName.set(input.username, user);
|
||||||
|
if (user.oauthType === 'KAKAO' && user.oauthId) {
|
||||||
|
usersByOauthId.set(`${user.oauthType}:${user.oauthId}`, user);
|
||||||
|
}
|
||||||
|
if (user.email) {
|
||||||
|
usersByEmail.set(user.email.toLowerCase(), user);
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
|
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
|
||||||
return hasher.hash(password, user.passwordSalt) === user.passwordHash;
|
return hasher.hash(password, user.passwordSalt) === user.passwordHash;
|
||||||
},
|
},
|
||||||
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('User not found.');
|
||||||
|
},
|
||||||
|
async updateOAuthInfo(userId: string, oauthInfo: UserRecord['oauthInfo']): Promise<void> {
|
||||||
|
for (const user of usersByName.values()) {
|
||||||
|
if (user.id === userId) {
|
||||||
|
user.oauthInfo = oauthInfo;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('User not found.');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
export interface KakaoOAuthConfig {
|
||||||
|
restKey: string;
|
||||||
|
adminKey?: string;
|
||||||
|
redirectUri: string;
|
||||||
|
oauthHost?: string;
|
||||||
|
apiHost?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KakaoOAuthToken {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
accessTokenExpiresIn: number;
|
||||||
|
refreshTokenExpiresIn?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KakaoAccountInfo {
|
||||||
|
hasEmail: boolean;
|
||||||
|
email?: string;
|
||||||
|
isEmailValid?: boolean;
|
||||||
|
isEmailVerified?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KakaoUserInfo {
|
||||||
|
id: string;
|
||||||
|
kakaoAccount: KakaoAccountInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildForm = (params: Record<string, string>): URLSearchParams => {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
form.append(key, value);
|
||||||
|
}
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseToken = (payload: Record<string, unknown>): KakaoOAuthToken => {
|
||||||
|
return {
|
||||||
|
accessToken: String(payload.access_token ?? ''),
|
||||||
|
refreshToken: payload.refresh_token ? String(payload.refresh_token) : undefined,
|
||||||
|
accessTokenExpiresIn: Number(payload.expires_in ?? 0),
|
||||||
|
refreshTokenExpiresIn: payload.refresh_token_expires_in
|
||||||
|
? Number(payload.refresh_token_expires_in)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class KakaoOAuthClient {
|
||||||
|
private readonly restKey: string;
|
||||||
|
private readonly adminKey?: string;
|
||||||
|
private readonly redirectUri: string;
|
||||||
|
private readonly oauthHost: string;
|
||||||
|
private readonly apiHost: string;
|
||||||
|
|
||||||
|
constructor(config: KakaoOAuthConfig) {
|
||||||
|
this.restKey = config.restKey;
|
||||||
|
this.adminKey = config.adminKey;
|
||||||
|
this.redirectUri = config.redirectUri;
|
||||||
|
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
|
||||||
|
this.apiHost = config.apiHost ?? 'https://kapi.kakao.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAuthUrl(state: string, scopes: string[]): string {
|
||||||
|
const base = new URL('/oauth/authorize', this.oauthHost);
|
||||||
|
base.searchParams.set('client_id', this.restKey);
|
||||||
|
base.searchParams.set('redirect_uri', this.redirectUri);
|
||||||
|
base.searchParams.set('response_type', 'code');
|
||||||
|
base.searchParams.set('state', state);
|
||||||
|
if (scopes.length > 0) {
|
||||||
|
base.searchParams.set('scope', scopes.join(','));
|
||||||
|
}
|
||||||
|
return base.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchangeCode(code: string): Promise<KakaoOAuthToken> {
|
||||||
|
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: buildForm({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: this.restKey,
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as Record<string, unknown>;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
return parseToken(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(refreshToken: string): Promise<KakaoOAuthToken> {
|
||||||
|
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: buildForm({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: this.restKey,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as Record<string, unknown>;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
return parseToken(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signup(accessToken: string): Promise<{ id?: string; msg?: string }> {
|
||||||
|
const response = await fetch(new URL('/v1/user/signup', this.apiHost), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as Record<string, unknown>;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: payload.id ? String(payload.id) : undefined,
|
||||||
|
msg: payload.msg ? String(payload.msg) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe(accessToken: string): Promise<KakaoUserInfo> {
|
||||||
|
const response = await fetch(new URL('/v2/user/me', this.apiHost), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as Record<string, unknown>;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Kakao me error: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
const kakaoAccount = (payload.kakao_account ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
id: String(payload.id ?? ''),
|
||||||
|
kakaoAccount: {
|
||||||
|
hasEmail: Boolean(kakaoAccount.has_email ?? false),
|
||||||
|
email: kakaoAccount.email ? String(kakaoAccount.email) : undefined,
|
||||||
|
isEmailValid: kakaoAccount.is_email_valid
|
||||||
|
? Boolean(kakaoAccount.is_email_valid)
|
||||||
|
: undefined,
|
||||||
|
isEmailVerified: kakaoAccount.is_email_verified
|
||||||
|
? Boolean(kakaoAccount.is_email_verified)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTalkMessage(accessToken: string, message: string, link: string): Promise<void> {
|
||||||
|
const response = await fetch(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: buildForm({
|
||||||
|
template_object: JSON.stringify({
|
||||||
|
object_type: 'text',
|
||||||
|
text: message,
|
||||||
|
link: {
|
||||||
|
web_url: link,
|
||||||
|
mobile_web_url: link,
|
||||||
|
},
|
||||||
|
button_title: '로그인 페이지 열기',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as Record<string, unknown>;
|
||||||
|
const code = Number(payload.code ?? 0);
|
||||||
|
if (!response.ok || code < 0) {
|
||||||
|
throw new Error(`Kakao talk message error: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
export type OAuthMode = 'login' | 'change_pw';
|
||||||
|
|
||||||
|
export interface OAuthPendingState {
|
||||||
|
state: string;
|
||||||
|
mode: OAuthMode;
|
||||||
|
scopes: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthSession {
|
||||||
|
id: string;
|
||||||
|
mode: OAuthMode;
|
||||||
|
kakaoId: string;
|
||||||
|
email: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
accessTokenValidUntil: string;
|
||||||
|
refreshTokenValidUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthSessionStore {
|
||||||
|
createPendingState(mode: OAuthMode, scopes: string[]): Promise<OAuthPendingState>;
|
||||||
|
consumePendingState(state: string): Promise<OAuthPendingState | null>;
|
||||||
|
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
|
||||||
|
consumeSession(sessionId: string): Promise<OAuthSession | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedisPipeline {
|
||||||
|
set(key: string, value: string, options?: { EX?: number }): RedisPipeline;
|
||||||
|
del(key: string): RedisPipeline;
|
||||||
|
exec(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedisClientLike {
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
|
||||||
|
del(key: string): Promise<number>;
|
||||||
|
multi(): RedisPipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseJson = <T>(raw: string | null): T | null => {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||||
|
private readonly client: RedisClientLike;
|
||||||
|
private readonly prefix: string;
|
||||||
|
private readonly ttlSeconds: number;
|
||||||
|
|
||||||
|
constructor(client: RedisClientLike, prefix: string, ttlSeconds: number) {
|
||||||
|
this.client = client;
|
||||||
|
this.prefix = prefix;
|
||||||
|
this.ttlSeconds = ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stateKey(state: string): string {
|
||||||
|
return `${this.prefix}:oauth-state:${state}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sessionKey(sessionId: string): string {
|
||||||
|
return `${this.prefix}:oauth-session:${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPendingState(mode: OAuthMode, scopes: string[]): Promise<OAuthPendingState> {
|
||||||
|
const state: OAuthPendingState = {
|
||||||
|
state: randomUUID(),
|
||||||
|
mode,
|
||||||
|
scopes,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.client.set(this.stateKey(state.state), JSON.stringify(state), {
|
||||||
|
EX: this.ttlSeconds,
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumePendingState(state: string): Promise<OAuthPendingState | null> {
|
||||||
|
const key = this.stateKey(state);
|
||||||
|
const raw = await this.client.get(key);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await this.client.del(key);
|
||||||
|
return parseJson<OAuthPendingState>(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession> {
|
||||||
|
const stored: OAuthSession = {
|
||||||
|
...session,
|
||||||
|
id: randomUUID(),
|
||||||
|
};
|
||||||
|
await this.client.set(this.sessionKey(stored.id), JSON.stringify(stored), {
|
||||||
|
EX: this.ttlSeconds,
|
||||||
|
});
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
||||||
|
const key = this.sessionKey(sessionId);
|
||||||
|
const raw = await this.client.get(key);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await this.client.del(key);
|
||||||
|
return parseJson<OAuthSession>(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테스트용 인메모리 OAuth 세션 저장소.
|
||||||
|
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> {
|
||||||
|
const pending: OAuthPendingState = {
|
||||||
|
state: randomUUID(),
|
||||||
|
mode,
|
||||||
|
scopes,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.pendingStates.set(pending.state, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumePendingState(state: string): Promise<OAuthPendingState | null> {
|
||||||
|
const pending = this.pendingStates.get(state) ?? null;
|
||||||
|
if (pending) {
|
||||||
|
this.pendingStates.delete(state);
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession> {
|
||||||
|
const stored: OAuthSession = {
|
||||||
|
...session,
|
||||||
|
id: randomUUID(),
|
||||||
|
};
|
||||||
|
this.sessions.set(stored.id, stored);
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
||||||
|
const session = this.sessions.get(sessionId) ?? null;
|
||||||
|
if (session) {
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||||
|
import type {
|
||||||
|
CreateUserInput,
|
||||||
|
UserOAuthInfo,
|
||||||
|
UserRecord,
|
||||||
|
UserRepository,
|
||||||
|
UserSanctions,
|
||||||
|
} from './userRepository.js';
|
||||||
|
|
||||||
|
const readStringArray = (value: unknown): string[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.filter((item): item is string => typeof item === 'string');
|
||||||
|
};
|
||||||
|
|
||||||
|
const readObject = <T extends object>(value: unknown, fallback: T): T => {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapUser = (row: {
|
||||||
|
id: string;
|
||||||
|
loginId: string;
|
||||||
|
displayName: string;
|
||||||
|
passwordHash: string;
|
||||||
|
passwordSalt: string;
|
||||||
|
roles: Prisma.JsonValue;
|
||||||
|
sanctions: Prisma.JsonValue;
|
||||||
|
oauthType: 'NONE' | 'KAKAO';
|
||||||
|
oauthId: string | null;
|
||||||
|
email: string | null;
|
||||||
|
oauthInfo: Prisma.JsonValue;
|
||||||
|
createdAt: Date;
|
||||||
|
}): UserRecord => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.loginId,
|
||||||
|
displayName: row.displayName,
|
||||||
|
roles: readStringArray(row.roles),
|
||||||
|
sanctions: readObject<UserSanctions>(row.sanctions, {}),
|
||||||
|
oauthType: row.oauthType,
|
||||||
|
oauthId: row.oauthId ?? undefined,
|
||||||
|
email: row.email ?? undefined,
|
||||||
|
oauthInfo: readObject<UserOAuthInfo>(row.oauthInfo, {}),
|
||||||
|
passwordHash: row.passwordHash,
|
||||||
|
passwordSalt: row.passwordSalt,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPostgresUserRepository = (
|
||||||
|
prisma: PrismaClient,
|
||||||
|
hasher: PasswordHasher = createSimplePasswordHasher()
|
||||||
|
): UserRepository => {
|
||||||
|
return {
|
||||||
|
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||||
|
const row = await prisma.appUser.findUnique({
|
||||||
|
where: {
|
||||||
|
loginId: username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return row ? mapUser(row) : null;
|
||||||
|
},
|
||||||
|
async findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null> {
|
||||||
|
const row = await prisma.appUser.findFirst({
|
||||||
|
where: {
|
||||||
|
oauthType: type,
|
||||||
|
oauthId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return row ? mapUser(row) : null;
|
||||||
|
},
|
||||||
|
async findByEmail(email: string): Promise<UserRecord | null> {
|
||||||
|
const row = await prisma.appUser.findUnique({
|
||||||
|
where: {
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return row ? mapUser(row) : null;
|
||||||
|
},
|
||||||
|
async createUser(input: CreateUserInput): Promise<UserRecord> {
|
||||||
|
const salt = hasher.createSalt();
|
||||||
|
const oauthType = input.oauth?.type ?? 'NONE';
|
||||||
|
const row = await prisma.appUser.create({
|
||||||
|
data: {
|
||||||
|
loginId: input.username,
|
||||||
|
displayName: input.displayName ?? input.username,
|
||||||
|
passwordHash: hasher.hash(input.password, salt),
|
||||||
|
passwordSalt: salt,
|
||||||
|
roles: ['user'] satisfies Prisma.JsonArray,
|
||||||
|
sanctions: {} satisfies Prisma.JsonObject,
|
||||||
|
oauthType,
|
||||||
|
oauthId: input.oauth?.id,
|
||||||
|
email: input.oauth?.email?.toLowerCase(),
|
||||||
|
oauthInfo: (input.oauth?.info ?? {}) as Prisma.JsonObject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapUser(row);
|
||||||
|
},
|
||||||
|
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
|
||||||
|
return hasher.hash(password, user.passwordSalt) === user.passwordHash;
|
||||||
|
},
|
||||||
|
async updatePassword(userId: string, password: string): Promise<void> {
|
||||||
|
const salt = hasher.createSalt();
|
||||||
|
await prisma.appUser.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
passwordHash: hasher.hash(password, salt),
|
||||||
|
passwordSalt: salt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void> {
|
||||||
|
await prisma.appUser.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
oauthInfo: oauthInfo as Prisma.JsonObject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,6 +4,10 @@ export interface UserRecord {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
sanctions: UserSanctions;
|
sanctions: UserSanctions;
|
||||||
|
oauthType: 'NONE' | 'KAKAO';
|
||||||
|
oauthId?: string;
|
||||||
|
email?: string;
|
||||||
|
oauthInfo?: UserOAuthInfo;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
passwordSalt: string;
|
passwordSalt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -38,10 +42,28 @@ export interface CreateUserInput {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
oauth?: {
|
||||||
|
type: 'KAKAO';
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
info: UserOAuthInfo;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserRepository {
|
export interface UserRepository {
|
||||||
findByUsername(username: string): Promise<UserRecord | null>;
|
findByUsername(username: string): Promise<UserRecord | null>;
|
||||||
|
findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null>;
|
||||||
|
findByEmail(email: string): Promise<UserRecord | null>;
|
||||||
createUser(input: CreateUserInput): Promise<UserRecord>;
|
createUser(input: CreateUserInput): Promise<UserRecord>;
|
||||||
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
|
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
|
||||||
|
updatePassword(userId: string, password: string): Promise<void>;
|
||||||
|
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserOAuthInfo {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
accessTokenValidUntil?: string;
|
||||||
|
refreshTokenValidUntil?: string;
|
||||||
|
nextPasswordChange?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface GatewayApiConfig {
|
|||||||
sessionTtlSeconds: number;
|
sessionTtlSeconds: number;
|
||||||
gameSessionTtlSeconds: number;
|
gameSessionTtlSeconds: number;
|
||||||
gameTokenSecret: string;
|
gameTokenSecret: string;
|
||||||
|
oauthSessionTtlSeconds: number;
|
||||||
|
kakaoRestKey: string;
|
||||||
|
kakaoAdminKey?: string;
|
||||||
|
kakaoRedirectUri: string;
|
||||||
|
publicBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
||||||
@@ -27,6 +32,12 @@ export const resolveGatewayApiConfigFromEnv = (
|
|||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('GAME_TOKEN_SECRET is required for gateway token encryption.');
|
throw new Error('GAME_TOKEN_SECRET is required for gateway token encryption.');
|
||||||
}
|
}
|
||||||
|
const kakaoRestKey = env.KAKAO_REST_KEY ?? '';
|
||||||
|
const kakaoRedirectUri = env.KAKAO_REDIRECT_URI ?? '';
|
||||||
|
if (!kakaoRestKey || !kakaoRedirectUri) {
|
||||||
|
throw new Error('KAKAO_REST_KEY and KAKAO_REDIRECT_URI are required.');
|
||||||
|
}
|
||||||
|
const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri;
|
||||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||||
return {
|
return {
|
||||||
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||||
@@ -41,5 +52,14 @@ export const resolveGatewayApiConfigFromEnv = (
|
|||||||
'GAME_SESSION_TTL_SECONDS'
|
'GAME_SESSION_TTL_SECONDS'
|
||||||
),
|
),
|
||||||
gameTokenSecret: secret,
|
gameTokenSecret: secret,
|
||||||
|
oauthSessionTtlSeconds: parseNumber(
|
||||||
|
env.OAUTH_SESSION_TTL_SECONDS,
|
||||||
|
10 * 60,
|
||||||
|
'OAUTH_SESSION_TTL_SECONDS'
|
||||||
|
),
|
||||||
|
kakaoRestKey,
|
||||||
|
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||||
|
kakaoRedirectUri,
|
||||||
|
publicBaseUrl,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
|
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||||
import type { GatewaySessionService } from './auth/sessionService.js';
|
import type { GatewaySessionService } from './auth/sessionService.js';
|
||||||
import type { UserRepository } from './auth/userRepository.js';
|
import type { UserRepository } from './auth/userRepository.js';
|
||||||
|
import type { KakaoOAuthClient } from './auth/kakaoClient.js';
|
||||||
|
import type { OAuthSessionStore } from './auth/oauthSessionStore.js';
|
||||||
|
|
||||||
export interface GatewayApiContext {
|
export interface GatewayApiContext {
|
||||||
users: UserRepository;
|
users: UserRepository;
|
||||||
@@ -8,6 +10,9 @@ export interface GatewayApiContext {
|
|||||||
flushPublisher: GatewayFlushPublisher;
|
flushPublisher: GatewayFlushPublisher;
|
||||||
gameTokenSecret: string;
|
gameTokenSecret: string;
|
||||||
gameSessionTtlSeconds: number;
|
gameSessionTtlSeconds: number;
|
||||||
|
kakaoClient: KakaoOAuthClient;
|
||||||
|
oauthSessions: OAuthSessionStore;
|
||||||
|
publicBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createGatewayApiContext = (options: {
|
export const createGatewayApiContext = (options: {
|
||||||
@@ -16,10 +21,16 @@ export const createGatewayApiContext = (options: {
|
|||||||
flushPublisher: GatewayFlushPublisher;
|
flushPublisher: GatewayFlushPublisher;
|
||||||
gameTokenSecret: string;
|
gameTokenSecret: string;
|
||||||
gameSessionTtlSeconds: number;
|
gameSessionTtlSeconds: number;
|
||||||
|
kakaoClient: KakaoOAuthClient;
|
||||||
|
oauthSessions: OAuthSessionStore;
|
||||||
|
publicBaseUrl: string;
|
||||||
}): GatewayApiContext => ({
|
}): GatewayApiContext => ({
|
||||||
users: options.users,
|
users: options.users,
|
||||||
sessions: options.sessions,
|
sessions: options.sessions,
|
||||||
flushPublisher: options.flushPublisher,
|
flushPublisher: options.flushPublisher,
|
||||||
gameTokenSecret: options.gameTokenSecret,
|
gameTokenSecret: options.gameTokenSecret,
|
||||||
gameSessionTtlSeconds: options.gameSessionTtlSeconds,
|
gameSessionTtlSeconds: options.gameSessionTtlSeconds,
|
||||||
|
kakaoClient: options.kakaoClient,
|
||||||
|
oauthSessions: options.oauthSessions,
|
||||||
|
publicBaseUrl: options.publicBaseUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export * from './auth/inMemorySessionService.js';
|
|||||||
export * from './auth/redisSessionService.js';
|
export * from './auth/redisSessionService.js';
|
||||||
export * from './auth/redisKeys.js';
|
export * from './auth/redisKeys.js';
|
||||||
export * from './auth/flushPublisher.js';
|
export * from './auth/flushPublisher.js';
|
||||||
|
export * from './auth/kakaoClient.js';
|
||||||
|
export * from './auth/oauthSessionStore.js';
|
||||||
|
export * from './auth/postgresUserRepository.js';
|
||||||
|
|
||||||
const isMain = (): boolean => {
|
const isMain = (): boolean => {
|
||||||
if (!process.argv[1]) {
|
if (!process.argv[1]) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -8,10 +10,12 @@ import {
|
|||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
import { toPublicUser } from './auth/userRepository.js';
|
import { toPublicUser } from './auth/userRepository.js';
|
||||||
|
import type { UserOAuthInfo } from './auth/userRepository.js';
|
||||||
|
|
||||||
const zUsername = z.string().min(2).max(32);
|
const zUsername = z.string().min(2).max(32);
|
||||||
const zPassword = z.string().min(6).max(128);
|
const zPassword = z.string().min(6).max(128);
|
||||||
const zProfile = z.string().min(1).max(64);
|
const zProfile = z.string().min(1).max(64);
|
||||||
|
const zOAuthMode = z.enum(['login', 'change_pw']);
|
||||||
|
|
||||||
const parseDate = (value: string): Date | null => {
|
const parseDate = (value: string): Date | null => {
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
@@ -29,15 +33,159 @@ export const appRouter = router({
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
auth: router({
|
auth: router({
|
||||||
|
kakaoStart: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
mode: zOAuthMode.optional(),
|
||||||
|
scopes: z.array(z.string()).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);
|
||||||
|
const authUrl = ctx.kakaoClient.buildAuthUrl(pending.state, pending.scopes);
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
state: pending.state,
|
||||||
|
authUrl,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
kakaoExchange: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
code: z.string().min(1),
|
||||||
|
state: z.string().min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const pending = await ctx.oauthSessions.consumePendingState(input.state);
|
||||||
|
if (!pending) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Invalid OAuth state.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const token = await ctx.kakaoClient.exchangeCode(input.code);
|
||||||
|
const accessTokenValidUntil = new Date(
|
||||||
|
Date.now() + token.accessTokenExpiresIn * 1000
|
||||||
|
).toISOString();
|
||||||
|
const refreshTokenValidUntil = token.refreshTokenExpiresIn
|
||||||
|
? new Date(Date.now() + token.refreshTokenExpiresIn * 1000).toISOString()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const signupResult = await ctx.kakaoClient.signup(token.accessToken);
|
||||||
|
if (!signupResult.id && signupResult.msg !== 'already registered') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '카카오 앱 연결에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const me = await ctx.kakaoClient.getMe(token.accessToken);
|
||||||
|
const kakaoAccount = me.kakaoAccount;
|
||||||
|
if (!kakaoAccount.hasEmail || !kakaoAccount.email) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '이메일 정보 제공에 동의해야 합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!kakaoAccount.isEmailValid || !kakaoAccount.isEmailVerified) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '카카오 계정 이메일이 인증되지 않았습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthInfo: UserOAuthInfo = {
|
||||||
|
accessToken: token.accessToken,
|
||||||
|
refreshToken: token.refreshToken,
|
||||||
|
accessTokenValidUntil,
|
||||||
|
refreshTokenValidUntil,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing =
|
||||||
|
(await ctx.users.findByOauthId('KAKAO', me.id)) ??
|
||||||
|
(await ctx.users.findByEmail(kakaoAccount.email));
|
||||||
|
|
||||||
|
if (pending.mode === 'change_pw') {
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: '카카오 계정에 연결된 사용자를 찾지 못했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const nextPasswordChange = existing.oauthInfo?.nextPasswordChange
|
||||||
|
? parseDate(existing.oauthInfo.nextPasswordChange)
|
||||||
|
: null;
|
||||||
|
if (nextPasswordChange && Date.now() < nextPasswordChange.getTime()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: '비밀번호 초기화는 잠시 후 다시 시도해주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tempPassword = randomBytes(4).toString('hex');
|
||||||
|
await ctx.kakaoClient.sendTalkMessage(
|
||||||
|
token.accessToken,
|
||||||
|
`임시 비밀번호는 ${tempPassword} 입니다. 로그인 후 바로 다른 비밀번호로 변경해주세요.`,
|
||||||
|
ctx.publicBaseUrl
|
||||||
|
);
|
||||||
|
const nextChange = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString();
|
||||||
|
await ctx.users.updatePassword(existing.id, tempPassword);
|
||||||
|
await ctx.users.updateOAuthInfo(existing.id, {
|
||||||
|
...oauthInfo,
|
||||||
|
nextPasswordChange: nextChange,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'change_pw' as const,
|
||||||
|
ok: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.users.updateOAuthInfo(existing.id, oauthInfo);
|
||||||
|
const session = await ctx.sessions.createSession(existing);
|
||||||
|
return {
|
||||||
|
status: 'login' as const,
|
||||||
|
user: toPublicUser(existing),
|
||||||
|
sessionToken: session.sessionToken,
|
||||||
|
issuedAt: session.issuedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = await ctx.oauthSessions.createSession({
|
||||||
|
mode: pending.mode,
|
||||||
|
kakaoId: me.id,
|
||||||
|
email: kakaoAccount.email,
|
||||||
|
accessToken: token.accessToken,
|
||||||
|
refreshToken: token.refreshToken,
|
||||||
|
accessTokenValidUntil,
|
||||||
|
refreshTokenValidUntil,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'join' as const,
|
||||||
|
oauthSessionId: stored.id,
|
||||||
|
email: stored.email,
|
||||||
|
};
|
||||||
|
}),
|
||||||
register: procedure
|
register: procedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
oauthSessionId: z.string().min(1),
|
||||||
username: zUsername,
|
username: zUsername,
|
||||||
password: zPassword,
|
password: zPassword,
|
||||||
displayName: z.string().min(2).max(40).optional(),
|
displayName: z.string().min(2).max(40).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
|
||||||
|
if (!oauthSession) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'OAuth 세션이 만료되었습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
const existing = await ctx.users.findByUsername(input.username);
|
const existing = await ctx.users.findByUsername(input.username);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -45,9 +193,34 @@ export const appRouter = router({
|
|||||||
message: 'Username already exists.',
|
message: 'Username already exists.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const existingOAuth =
|
||||||
|
(await ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId)) ??
|
||||||
|
(await ctx.users.findByEmail(oauthSession.email));
|
||||||
|
if (existingOAuth) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'OAuth account already registered.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const oauthInfo: UserOAuthInfo = {
|
||||||
|
accessToken: oauthSession.accessToken,
|
||||||
|
refreshToken: oauthSession.refreshToken,
|
||||||
|
accessTokenValidUntil: oauthSession.accessTokenValidUntil,
|
||||||
|
refreshTokenValidUntil: oauthSession.refreshTokenValidUntil,
|
||||||
|
};
|
||||||
let created = null;
|
let created = null;
|
||||||
try {
|
try {
|
||||||
created = await ctx.users.createUser(input);
|
created = await ctx.users.createUser({
|
||||||
|
username: input.username,
|
||||||
|
password: input.password,
|
||||||
|
displayName: input.displayName,
|
||||||
|
oauth: {
|
||||||
|
type: 'KAKAO',
|
||||||
|
id: oauthSession.kakaoId,
|
||||||
|
email: oauthSession.email,
|
||||||
|
info: oauthInfo,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'CONFLICT',
|
code: 'CONFLICT',
|
||||||
|
|||||||
@@ -1,27 +1,46 @@
|
|||||||
import fastify from 'fastify';
|
import fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
import {
|
||||||
|
createPostgresConnector,
|
||||||
|
createRedisConnector,
|
||||||
|
resolvePostgresConfigFromEnv,
|
||||||
|
resolveRedisConfigFromEnv,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { resolveGatewayApiConfigFromEnv } from './config.js';
|
import { resolveGatewayApiConfigFromEnv } from './config.js';
|
||||||
import { createGatewayApiContext } from './context.js';
|
import { createGatewayApiContext } from './context.js';
|
||||||
import { RedisGatewayFlushPublisher } from './auth/flushPublisher.js';
|
import { RedisGatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||||
import { createInMemoryUserRepository } from './auth/inMemoryUserRepository.js';
|
import { KakaoOAuthClient } from './auth/kakaoClient.js';
|
||||||
|
import { RedisOAuthSessionStore } from './auth/oauthSessionStore.js';
|
||||||
|
import { createPostgresUserRepository } from './auth/postgresUserRepository.js';
|
||||||
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||||
import { appRouter } from './router.js';
|
import { appRouter } from './router.js';
|
||||||
|
|
||||||
export const createGatewayApiServer = async () => {
|
export const createGatewayApiServer = async () => {
|
||||||
const config = resolveGatewayApiConfigFromEnv();
|
const config = resolveGatewayApiConfigFromEnv();
|
||||||
|
const postgres = createPostgresConnector(resolvePostgresConfigFromEnv());
|
||||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
|
await postgres.connect();
|
||||||
await redis.connect();
|
await redis.connect();
|
||||||
|
|
||||||
const users = createInMemoryUserRepository();
|
const users = createPostgresUserRepository(postgres.prisma);
|
||||||
const sessions = new RedisGatewaySessionService(redis.client, {
|
const sessions = new RedisGatewaySessionService(redis.client, {
|
||||||
keyPrefix: config.redisKeyPrefix,
|
keyPrefix: config.redisKeyPrefix,
|
||||||
sessionTtlSeconds: config.sessionTtlSeconds,
|
sessionTtlSeconds: config.sessionTtlSeconds,
|
||||||
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||||
});
|
});
|
||||||
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
||||||
|
const kakaoClient = new KakaoOAuthClient({
|
||||||
|
restKey: config.kakaoRestKey,
|
||||||
|
adminKey: config.kakaoAdminKey,
|
||||||
|
redirectUri: config.kakaoRedirectUri,
|
||||||
|
});
|
||||||
|
const oauthSessions = new RedisOAuthSessionStore(
|
||||||
|
redis.client,
|
||||||
|
config.redisKeyPrefix,
|
||||||
|
config.oauthSessionTtlSeconds
|
||||||
|
);
|
||||||
|
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
@@ -43,6 +62,9 @@ export const createGatewayApiServer = async () => {
|
|||||||
flushPublisher,
|
flushPublisher,
|
||||||
gameTokenSecret: config.gameTokenSecret,
|
gameTokenSecret: config.gameTokenSecret,
|
||||||
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||||
|
kakaoClient,
|
||||||
|
oauthSessions,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -53,6 +75,7 @@ export const createGatewayApiServer = async () => {
|
|||||||
|
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
await redis.disconnect();
|
await redis.disconnect();
|
||||||
|
await postgres.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||||
|
import { InMemoryOAuthSessionStore } from '../src/auth/oauthSessionStore.js';
|
||||||
import { createGatewayApiContext } from '../src/context.js';
|
import { createGatewayApiContext } from '../src/context.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
@@ -14,21 +15,57 @@ const buildCaller = () => {
|
|||||||
const flushPublisher = {
|
const flushPublisher = {
|
||||||
publishUserFlush: async () => {},
|
publishUserFlush: async () => {},
|
||||||
};
|
};
|
||||||
return appRouter.createCaller(
|
const oauthSessions = new InMemoryOAuthSessionStore();
|
||||||
|
const kakaoClient = {
|
||||||
|
buildAuthUrl: () => '',
|
||||||
|
exchangeCode: async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
},
|
||||||
|
refreshToken: async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
},
|
||||||
|
signup: async () => ({ id: '1' }),
|
||||||
|
getMe: async () => ({
|
||||||
|
id: '1',
|
||||||
|
kakaoAccount: {
|
||||||
|
hasEmail: true,
|
||||||
|
email: 'tester@example.com',
|
||||||
|
isEmailValid: true,
|
||||||
|
isEmailVerified: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sendTalkMessage: async () => {},
|
||||||
|
};
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
createGatewayApiContext({
|
createGatewayApiContext({
|
||||||
users,
|
users,
|
||||||
sessions,
|
sessions,
|
||||||
flushPublisher,
|
flushPublisher,
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
gameSessionTtlSeconds: 600,
|
gameSessionTtlSeconds: 600,
|
||||||
|
kakaoClient,
|
||||||
|
oauthSessions,
|
||||||
|
publicBaseUrl: 'http://localhost',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
return { caller, oauthSessions };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('gateway auth flow', () => {
|
describe('gateway auth flow', () => {
|
||||||
it('registers and issues a game session', async () => {
|
it('registers and issues a game session', async () => {
|
||||||
const caller = buildCaller();
|
const { caller, oauthSessions } = buildCaller();
|
||||||
|
const oauthSession = await oauthSessions.createSession({
|
||||||
|
mode: 'login',
|
||||||
|
kakaoId: '1',
|
||||||
|
email: 'tester@example.com',
|
||||||
|
accessToken: 'token',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
accessTokenValidUntil: new Date().toISOString(),
|
||||||
|
refreshTokenValidUntil: new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
const register = await caller.auth.register({
|
const register = await caller.auth.register({
|
||||||
|
oauthSessionId: oauthSession.id,
|
||||||
username: 'tester',
|
username: 'tester',
|
||||||
password: 'secretpass',
|
password: 'secretpass',
|
||||||
displayName: 'Tester',
|
displayName: 'Tester',
|
||||||
|
|||||||
@@ -22,6 +22,30 @@ enum LogCategory {
|
|||||||
USER
|
USER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum OAuthType {
|
||||||
|
NONE
|
||||||
|
KAKAO
|
||||||
|
}
|
||||||
|
|
||||||
|
model AppUser {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
loginId String @unique @map("login_id")
|
||||||
|
displayName String @map("display_name")
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
passwordSalt String @map("password_salt")
|
||||||
|
roles Json @default(dbgenerated("'[]'::jsonb"))
|
||||||
|
sanctions Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
oauthType OAuthType @default(NONE) @map("oauth_type")
|
||||||
|
oauthId String? @unique @map("oauth_id")
|
||||||
|
email String? @unique
|
||||||
|
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
|
|
||||||
|
@@map("app_user")
|
||||||
|
}
|
||||||
|
|
||||||
model WorldState {
|
model WorldState {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
scenarioCode String @map("scenario_code")
|
scenarioCode String @map("scenario_code")
|
||||||
|
|||||||
Generated
+3
@@ -87,6 +87,9 @@ importers:
|
|||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
|
'@prisma/client':
|
||||||
|
specifier: ^7.2.0
|
||||||
|
version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
'@sammo-ts/common':
|
'@sammo-ts/common':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/common
|
version: link:../../packages/common
|
||||||
|
|||||||
Reference in New Issue
Block a user