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:
2025-12-30 02:19:32 +00:00
parent f01a21f2f0
commit 0c1e27ee4a
17 changed files with 893 additions and 6 deletions
+174 -1
View File
@@ -1,3 +1,5 @@
import { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
@@ -8,10 +10,12 @@ import {
import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo } from './auth/userRepository.js';
const zUsername = z.string().min(2).max(32);
const zPassword = z.string().min(6).max(128);
const zProfile = z.string().min(1).max(64);
const zOAuthMode = z.enum(['login', 'change_pw']);
const parseDate = (value: string): Date | null => {
const parsed = new Date(value);
@@ -29,15 +33,159 @@ export const appRouter = 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
.input(
z.object({
oauthSessionId: z.string().min(1),
username: zUsername,
password: zPassword,
displayName: z.string().min(2).max(40).optional(),
})
)
.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);
if (existing) {
throw new TRPCError({
@@ -45,9 +193,34 @@ export const appRouter = router({
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;
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) {
throw new TRPCError({
code: 'CONFLICT',