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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user