중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { parseJson } from '@sammo-ts/common';
|
|
|
|
export type OAuthMode = 'login' | 'change_pw' | 'verify';
|
|
|
|
export interface OAuthPendingState {
|
|
state: string;
|
|
mode: OAuthMode;
|
|
scopes: string[];
|
|
userId?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface OAuthSession {
|
|
id: string;
|
|
mode: OAuthMode;
|
|
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
|
|
targetUserId?: string;
|
|
kakaoId: string;
|
|
email: string;
|
|
accessToken: string;
|
|
refreshToken?: string;
|
|
accessTokenValidUntil: string;
|
|
refreshTokenValidUntil?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface KakaoLoginChallenge {
|
|
id: string;
|
|
userId: string;
|
|
code: string;
|
|
attemptsRemaining: number;
|
|
expiresAt: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export type KakaoLoginChallengeResult =
|
|
| { status: 'verified'; userId: string }
|
|
| { status: 'mismatch'; attemptsRemaining: number; expiresAt: string }
|
|
| { status: 'locked'; expiresAt: string }
|
|
| { status: 'expired' };
|
|
|
|
export interface OAuthSessionStore {
|
|
createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState>;
|
|
consumePendingState(state: string): Promise<OAuthPendingState | null>;
|
|
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
|
|
consumeSession(sessionId: string): Promise<OAuthSession | null>;
|
|
getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null>;
|
|
createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge>;
|
|
verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise<KakaoLoginChallengeResult>;
|
|
}
|
|
|
|
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>;
|
|
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
|
multi(): RedisPipeline;
|
|
}
|
|
|
|
const verifyLoginChallengeScript = `
|
|
local raw = redis.call('GET', KEYS[1])
|
|
if not raw then
|
|
return '{"status":"expired"}'
|
|
end
|
|
|
|
local challenge = cjson.decode(raw)
|
|
if challenge.attemptsRemaining <= 0 then
|
|
return cjson.encode({ status = 'locked', expiresAt = challenge.expiresAt })
|
|
end
|
|
|
|
if tostring(challenge.code) ~= ARGV[1] then
|
|
challenge.attemptsRemaining = challenge.attemptsRemaining - 1
|
|
redis.call('SET', KEYS[1], cjson.encode(challenge), 'KEEPTTL')
|
|
return cjson.encode({
|
|
status = 'mismatch',
|
|
attemptsRemaining = challenge.attemptsRemaining,
|
|
expiresAt = challenge.expiresAt
|
|
})
|
|
end
|
|
|
|
redis.call('DEL', KEYS[1])
|
|
local userKey = ARGV[2] .. challenge.userId
|
|
if redis.call('GET', userKey) == challenge.id then
|
|
redis.call('DEL', userKey)
|
|
end
|
|
return cjson.encode({ status = 'verified', userId = challenge.userId })
|
|
`;
|
|
|
|
const consumeOnceScript = `
|
|
local raw = redis.call('GET', KEYS[1])
|
|
if raw then
|
|
redis.call('DEL', KEYS[1])
|
|
end
|
|
return raw
|
|
`;
|
|
|
|
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}`;
|
|
}
|
|
|
|
private loginChallengeKey(challengeId: string): string {
|
|
return `${this.prefix}:kakao-login-challenge:${challengeId}`;
|
|
}
|
|
|
|
private userLoginChallengeKey(userId: string): string {
|
|
return `${this.prefix}:kakao-login-challenge-user:${userId}`;
|
|
}
|
|
|
|
private challengeTtlSeconds(expiresAt: string): number {
|
|
return Math.max(1, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1000));
|
|
}
|
|
|
|
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
|
|
const state: OAuthPendingState = {
|
|
state: randomUUID(),
|
|
mode,
|
|
scopes,
|
|
userId,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
await this.client.set(this.stateKey(state.state), JSON.stringify(state), {
|
|
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.eval(consumeOnceScript, { keys: [key], arguments: [] });
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
return typeof raw === 'string' ? parseJson<OAuthSession>(raw) : null;
|
|
}
|
|
|
|
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
|
const challengeId = await this.client.get(this.userLoginChallengeKey(userId));
|
|
if (!challengeId) {
|
|
return null;
|
|
}
|
|
const raw = await this.client.get(this.loginChallengeKey(challengeId));
|
|
if (!raw) {
|
|
await this.client.del(this.userLoginChallengeKey(userId));
|
|
return null;
|
|
}
|
|
const challenge = parseJson<KakaoLoginChallenge>(raw);
|
|
if (!challenge) {
|
|
await this.client.del(this.userLoginChallengeKey(userId));
|
|
return null;
|
|
}
|
|
if (new Date(challenge.expiresAt).getTime() <= Date.now()) {
|
|
await this.client
|
|
.multi()
|
|
.del(this.loginChallengeKey(challenge.id))
|
|
.del(this.userLoginChallengeKey(userId))
|
|
.exec();
|
|
return null;
|
|
}
|
|
return challenge;
|
|
}
|
|
|
|
async createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge> {
|
|
const stored: KakaoLoginChallenge = {
|
|
...challenge,
|
|
id: randomUUID(),
|
|
};
|
|
const ttlSeconds = this.challengeTtlSeconds(stored.expiresAt);
|
|
await this.client
|
|
.multi()
|
|
.set(this.loginChallengeKey(stored.id), JSON.stringify(stored), { EX: ttlSeconds })
|
|
.set(this.userLoginChallengeKey(stored.userId), stored.id, { EX: ttlSeconds })
|
|
.exec();
|
|
return stored;
|
|
}
|
|
|
|
async verifyLoginChallenge(challengeId: string, code: string): Promise<KakaoLoginChallengeResult> {
|
|
const raw = await this.client.eval(verifyLoginChallengeScript, {
|
|
keys: [this.loginChallengeKey(challengeId)],
|
|
arguments: [code, `${this.prefix}:kakao-login-challenge-user:`],
|
|
});
|
|
const result = typeof raw === 'string' ? parseJson<KakaoLoginChallengeResult>(raw) : null;
|
|
return result ?? { status: 'expired' };
|
|
}
|
|
}
|
|
|
|
// 테스트용 인메모리 OAuth 세션 저장소.
|
|
export class InMemoryOAuthSessionStore implements OAuthSessionStore {
|
|
private readonly pendingStates = new Map<string, OAuthPendingState>();
|
|
private readonly sessions = new Map<string, OAuthSession>();
|
|
private readonly loginChallenges = new Map<string, KakaoLoginChallenge>();
|
|
private readonly userLoginChallenges = new Map<string, string>();
|
|
|
|
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
|
|
const pending: OAuthPendingState = {
|
|
state: randomUUID(),
|
|
mode,
|
|
scopes,
|
|
userId,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
this.pendingStates.set(pending.state, pending);
|
|
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;
|
|
}
|
|
|
|
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
|
const challengeId = this.userLoginChallenges.get(userId);
|
|
const challenge = challengeId ? this.loginChallenges.get(challengeId) : undefined;
|
|
if (!challenge || new Date(challenge.expiresAt).getTime() <= Date.now()) {
|
|
if (challengeId) {
|
|
this.loginChallenges.delete(challengeId);
|
|
}
|
|
this.userLoginChallenges.delete(userId);
|
|
return null;
|
|
}
|
|
return challenge;
|
|
}
|
|
|
|
async createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge> {
|
|
const stored: KakaoLoginChallenge = {
|
|
...challenge,
|
|
id: randomUUID(),
|
|
};
|
|
this.loginChallenges.set(stored.id, stored);
|
|
this.userLoginChallenges.set(stored.userId, stored.id);
|
|
return stored;
|
|
}
|
|
|
|
async verifyLoginChallenge(
|
|
challengeId: string,
|
|
code: string,
|
|
now = new Date()
|
|
): Promise<KakaoLoginChallengeResult> {
|
|
const challenge = this.loginChallenges.get(challengeId);
|
|
if (!challenge || new Date(challenge.expiresAt).getTime() <= now.getTime()) {
|
|
if (challenge) {
|
|
this.loginChallenges.delete(challengeId);
|
|
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
|
|
this.userLoginChallenges.delete(challenge.userId);
|
|
}
|
|
}
|
|
return { status: 'expired' };
|
|
}
|
|
if (challenge.attemptsRemaining <= 0) {
|
|
return { status: 'locked', expiresAt: challenge.expiresAt };
|
|
}
|
|
if (challenge.code !== code) {
|
|
challenge.attemptsRemaining -= 1;
|
|
return {
|
|
status: 'mismatch',
|
|
attemptsRemaining: challenge.attemptsRemaining,
|
|
expiresAt: challenge.expiresAt,
|
|
};
|
|
}
|
|
this.loginChallenges.delete(challengeId);
|
|
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
|
|
this.userLoginChallenges.delete(challenge.userId);
|
|
}
|
|
return { status: 'verified', userId: challenge.userId };
|
|
}
|
|
}
|