feat(gateway): verify Kakao account ownership
This commit is contained in:
@@ -23,11 +23,29 @@ export interface OAuthSession {
|
||||
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 {
|
||||
@@ -40,9 +58,39 @@ 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 })
|
||||
`;
|
||||
|
||||
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly prefix: string;
|
||||
@@ -62,6 +110,18 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
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(),
|
||||
@@ -106,12 +166,63 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
await this.client.del(key);
|
||||
return parseJson<OAuthSession>(raw);
|
||||
}
|
||||
|
||||
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 = {
|
||||
@@ -149,4 +260,60 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user