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:
@@ -8,31 +8,70 @@ export const createInMemoryUserRepository = (
|
||||
hasher: PasswordHasher = createSimplePasswordHasher()
|
||||
): UserRepository => {
|
||||
const usersByName = new Map<string, UserRecord>();
|
||||
const usersByOauthId = new Map<string, UserRecord>();
|
||||
const usersByEmail = new Map<string, UserRecord>();
|
||||
|
||||
return {
|
||||
async findByUsername(username: string): Promise<UserRecord | 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> {
|
||||
if (usersByName.has(input.username)) {
|
||||
throw new Error('User already exists.');
|
||||
}
|
||||
const salt = hasher.createSalt();
|
||||
const oauthType = input.oauth?.type ?? 'NONE';
|
||||
const user: UserRecord = {
|
||||
id: randomUUID(),
|
||||
username: input.username,
|
||||
displayName: input.displayName ?? input.username,
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
oauthType,
|
||||
oauthId: input.oauth?.id,
|
||||
email: input.oauth?.email,
|
||||
oauthInfo: input.oauth?.info,
|
||||
passwordSalt: salt,
|
||||
passwordHash: hasher.hash(input.password, salt),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
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;
|
||||
},
|
||||
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
|
||||
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;
|
||||
roles: string[];
|
||||
sanctions: UserSanctions;
|
||||
oauthType: 'NONE' | 'KAKAO';
|
||||
oauthId?: string;
|
||||
email?: string;
|
||||
oauthInfo?: UserOAuthInfo;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
createdAt: string;
|
||||
@@ -38,10 +42,28 @@ export interface CreateUserInput {
|
||||
username: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
oauth?: {
|
||||
type: 'KAKAO';
|
||||
id: string;
|
||||
email: string;
|
||||
info: UserOAuthInfo;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserRepository {
|
||||
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>;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user