feat(gateway): recover orphaned Kakao account links
This commit is contained in:
@@ -42,11 +42,13 @@ Gateway 자체 릴리스는 Gateway 프로세스 밖의 `release-controller`가
|
|||||||
`GatewayReleaseOperation` queue를 처리합니다.
|
`GatewayReleaseOperation` queue를 처리합니다.
|
||||||
|
|
||||||
Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와
|
Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와
|
||||||
현재 인증 이메일을 다시 확인합니다. 새 Kakao ID의 이메일이 기존 계정에 있거나
|
현재 인증 이메일을 다시 확인합니다. 로컬 고유 ID 연결이 없지만 영구 보존된
|
||||||
기존 Kakao ID의 변경 이메일이 다른 계정에 있으면 로그인을 거부합니다. 확인된
|
이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존
|
||||||
이메일은 `AppUser.email`에 동기화하며, 10일마다 `talk_message` scope로
|
계정 연결을 확인하면 이전 Kakao ID와 과거 talk proof를 교체하고 새
|
||||||
“나와의 채팅” 숫자 코드를 보내 소유 증명을 완료한 뒤에만 새 Gateway session을
|
“나와의 채팅” 숫자 코드로 다시 증명한 뒤에만 Gateway session을 발급합니다.
|
||||||
발급합니다.
|
Kakao가 `already registered`를 반환했지만 보존 이메일 계정도 없으면 재가입
|
||||||
|
확인 뒤 신규 가입 form을 엽니다. 이미 로컬에 연결된 stable ID의 변경 이메일이
|
||||||
|
다른 계정에 있으면 기존처럼 충돌을 거부합니다.
|
||||||
|
|
||||||
각 game profile은 별도 PostgreSQL schema를 사용합니다. `game-api`는 인증된
|
각 game profile은 별도 PostgreSQL schema를 사용합니다. `game-api`는 인증된
|
||||||
요청을 검증하고 직접 처리할 mutation 또는 daemon 입력을
|
요청을 검증하고 직접 처리할 mutation 또는 daemon 입력을
|
||||||
|
|||||||
@@ -161,24 +161,58 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
|||||||
throw new Error('User not found.');
|
throw new Error('User not found.');
|
||||||
},
|
},
|
||||||
async linkKakao(userId, input): Promise<UserRecord> {
|
async linkKakao(userId, input): Promise<UserRecord> {
|
||||||
if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) {
|
const normalizedEmail = input.email.toLowerCase();
|
||||||
|
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
|
||||||
|
const emailOwner = usersByEmail.get(normalizedEmail);
|
||||||
|
if ((oauthOwner && oauthOwner.id !== userId) || (emailOwner && emailOwner.id !== userId)) {
|
||||||
throw new Error('Kakao account already linked.');
|
throw new Error('Kakao account already linked.');
|
||||||
}
|
}
|
||||||
for (const user of usersByName.values()) {
|
for (const user of usersByName.values()) {
|
||||||
if (user.id !== userId) {
|
if (user.id !== userId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (user.oauthType === 'KAKAO' && user.oauthId) {
|
||||||
|
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
|
||||||
|
}
|
||||||
|
if (user.email && user.email.toLowerCase() !== normalizedEmail) {
|
||||||
|
usersByEmail.delete(user.email.toLowerCase());
|
||||||
|
}
|
||||||
user.oauthType = 'KAKAO';
|
user.oauthType = 'KAKAO';
|
||||||
user.oauthId = input.oauthId;
|
user.oauthId = input.oauthId;
|
||||||
user.email = input.email.toLowerCase();
|
user.email = normalizedEmail;
|
||||||
user.oauthInfo = input.oauthInfo;
|
user.oauthInfo = input.oauthInfo;
|
||||||
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
|
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
|
||||||
|
user.kakaoTalkVerifiedUntil = undefined;
|
||||||
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
|
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
|
||||||
usersByEmail.set(user.email, user);
|
usersByEmail.set(user.email, user);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
throw new Error('User not found.');
|
throw new Error('User not found.');
|
||||||
},
|
},
|
||||||
|
async relinkKakaoByEmail(userId, input): Promise<UserRecord> {
|
||||||
|
const normalizedEmail = input.email.toLowerCase();
|
||||||
|
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
|
||||||
|
const emailOwner = usersByEmail.get(normalizedEmail);
|
||||||
|
if ((oauthOwner && oauthOwner.id !== userId) || emailOwner?.id !== userId) {
|
||||||
|
throw new Error('Kakao account recovery ownership changed.');
|
||||||
|
}
|
||||||
|
for (const user of usersByName.values()) {
|
||||||
|
if (user.id !== userId || user.email?.toLowerCase() !== normalizedEmail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (user.oauthType === 'KAKAO' && user.oauthId) {
|
||||||
|
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
|
||||||
|
}
|
||||||
|
user.oauthType = 'KAKAO';
|
||||||
|
user.oauthId = input.oauthId;
|
||||||
|
user.oauthInfo = input.oauthInfo;
|
||||||
|
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
|
||||||
|
user.kakaoTalkVerifiedUntil = undefined;
|
||||||
|
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
throw new Error('Kakao account recovery ownership changed.');
|
||||||
|
},
|
||||||
async updateRoles(userId: string, roles: string[]): Promise<void> {
|
async updateRoles(userId: string, roles: string[]): Promise<void> {
|
||||||
for (const user of usersByName.values()) {
|
for (const user of usersByName.values()) {
|
||||||
if (user.id === userId) {
|
if (user.id === userId) {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface OAuthPendingState {
|
|||||||
export interface OAuthSession {
|
export interface OAuthSession {
|
||||||
id: string;
|
id: string;
|
||||||
mode: OAuthMode;
|
mode: OAuthMode;
|
||||||
|
intent?: 'register' | 'link_existing' | 'rejoin';
|
||||||
|
targetUserId?: string;
|
||||||
kakaoId: string;
|
kakaoId: string;
|
||||||
email: string;
|
email: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|||||||
@@ -251,10 +251,32 @@ export const createPostgresUserRepository = (
|
|||||||
email: input.email.toLowerCase(),
|
email: input.email.toLowerCase(),
|
||||||
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||||
kakaoVerifiedAt: input.verifiedAt,
|
kakaoVerifiedAt: input.verifiedAt,
|
||||||
|
kakaoTalkVerifiedUntil: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return mapUser(row);
|
return mapUser(row);
|
||||||
},
|
},
|
||||||
|
async relinkKakaoByEmail(userId, input): Promise<UserRecord> {
|
||||||
|
const normalizedEmail = input.email.toLowerCase();
|
||||||
|
const updated = await prisma.appUser.updateMany({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
email: normalizedEmail,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
oauthType: 'KAKAO',
|
||||||
|
oauthId: input.oauthId,
|
||||||
|
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||||
|
kakaoVerifiedAt: input.verifiedAt,
|
||||||
|
kakaoTalkVerifiedUntil: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (updated.count !== 1) {
|
||||||
|
throw new Error('Kakao account recovery ownership changed.');
|
||||||
|
}
|
||||||
|
const row = await prisma.appUser.findUniqueOrThrow({ where: { id: userId } });
|
||||||
|
return mapUser(row);
|
||||||
|
},
|
||||||
async updateRoles(userId: string, roles: string[]): Promise<void> {
|
async updateRoles(userId: string, roles: string[]): Promise<void> {
|
||||||
await prisma.appUser.update({
|
await prisma.appUser.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ export interface UserRepository {
|
|||||||
verifiedAt: Date;
|
verifiedAt: Date;
|
||||||
}
|
}
|
||||||
): Promise<UserRecord>;
|
): Promise<UserRecord>;
|
||||||
|
relinkKakaoByEmail(
|
||||||
|
userId: string,
|
||||||
|
input: {
|
||||||
|
oauthId: string;
|
||||||
|
email: string;
|
||||||
|
oauthInfo: UserOAuthInfo;
|
||||||
|
verifiedAt: Date;
|
||||||
|
}
|
||||||
|
): Promise<UserRecord>;
|
||||||
updateRoles(userId: string, roles: string[]): Promise<void>;
|
updateRoles(userId: string, roles: string[]): Promise<void>;
|
||||||
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
|
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
|
||||||
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
|
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const zUsername = z
|
|||||||
const zPassword = z.string().min(6).max(128);
|
const zPassword = z.string().min(6).max(128);
|
||||||
const zProfile = z.string().min(1).max(64);
|
const zProfile = z.string().min(1).max(64);
|
||||||
const zOAuthMode = z.enum(['login', 'change_pw', 'verify']);
|
const zOAuthMode = z.enum(['login', 'change_pw', 'verify']);
|
||||||
|
const zKakaoRecoveryAction = z.enum(['link_existing', 'rejoin']);
|
||||||
const zBootstrapToken = z.string().min(1);
|
const zBootstrapToken = z.string().min(1);
|
||||||
|
|
||||||
const parseDate = (value: string): Date | null => {
|
const parseDate = (value: string): Date | null => {
|
||||||
@@ -252,7 +253,8 @@ export const appRouter = router({
|
|||||||
const tokenIssuedAt = new Date();
|
const tokenIssuedAt = new Date();
|
||||||
|
|
||||||
const signupResult = await ctx.kakaoClient.signup(token.accessToken);
|
const signupResult = await ctx.kakaoClient.signup(token.accessToken);
|
||||||
if (!signupResult.id && signupResult.msg !== 'already registered') {
|
const alreadyRegisteredWithKakao = !signupResult.id && signupResult.msg === 'already registered';
|
||||||
|
if (!signupResult.id && !alreadyRegisteredWithKakao) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: '카카오 앱 연결에 실패했습니다.',
|
message: '카카오 앱 연결에 실패했습니다.',
|
||||||
@@ -270,12 +272,6 @@ export const appRouter = router({
|
|||||||
ctx.users.findByOauthId('KAKAO', profile.kakaoId),
|
ctx.users.findByOauthId('KAKAO', profile.kakaoId),
|
||||||
ctx.users.findByEmail(profile.email),
|
ctx.users.findByEmail(profile.email),
|
||||||
]);
|
]);
|
||||||
if (existingByEmail && existingByEmail.id !== existingById?.id) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'CONFLICT',
|
|
||||||
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pending.mode === 'verify') {
|
if (pending.mode === 'verify') {
|
||||||
if (!pending.userId) {
|
if (!pending.userId) {
|
||||||
@@ -416,8 +412,15 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
|
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
|
||||||
|
const recoveryIntent = existingByEmail
|
||||||
|
? ('link_existing' as const)
|
||||||
|
: alreadyRegisteredWithKakao
|
||||||
|
? ('rejoin' as const)
|
||||||
|
: ('register' as const);
|
||||||
const stored = await ctx.oauthSessions.createSession({
|
const stored = await ctx.oauthSessions.createSession({
|
||||||
mode: pending.mode,
|
mode: pending.mode,
|
||||||
|
intent: recoveryIntent,
|
||||||
|
targetUserId: existingByEmail?.id,
|
||||||
kakaoId: profile.kakaoId,
|
kakaoId: profile.kakaoId,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
accessToken: token.accessToken,
|
accessToken: token.accessToken,
|
||||||
@@ -427,12 +430,133 @@ export const appRouter = router({
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (recoveryIntent !== 'register') {
|
||||||
|
return {
|
||||||
|
status: 'account_recovery' as const,
|
||||||
|
action: recoveryIntent,
|
||||||
|
oauthSessionId: stored.id,
|
||||||
|
email: stored.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'join' as const,
|
status: 'join' as const,
|
||||||
oauthSessionId: stored.id,
|
oauthSessionId: stored.id,
|
||||||
email: stored.email,
|
email: stored.email,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
kakaoResolveAccount: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
oauthSessionId: z.string().min(1),
|
||||||
|
action: zKakaoRecoveryAction,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
|
||||||
|
if (!oauthSession) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: '카카오 계정 복구 세션이 만료되었습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (oauthSession.intent !== input.action) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '카카오 계정 복구 선택이 올바르지 않습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'rejoin') {
|
||||||
|
const [oauthOwner, emailOwner] = await Promise.all([
|
||||||
|
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
|
||||||
|
ctx.users.findByEmail(oauthSession.email),
|
||||||
|
]);
|
||||||
|
if (oauthOwner || emailOwner) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: '연결할 기존 계정이 확인되었습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const registrationSession = await ctx.oauthSessions.createSession({
|
||||||
|
mode: oauthSession.mode,
|
||||||
|
intent: 'register',
|
||||||
|
kakaoId: oauthSession.kakaoId,
|
||||||
|
email: oauthSession.email,
|
||||||
|
accessToken: oauthSession.accessToken,
|
||||||
|
refreshToken: oauthSession.refreshToken,
|
||||||
|
accessTokenValidUntil: oauthSession.accessTokenValidUntil,
|
||||||
|
refreshTokenValidUntil: oauthSession.refreshTokenValidUntil,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'join' as const,
|
||||||
|
oauthSessionId: registrationSession.id,
|
||||||
|
email: registrationSession.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oauthSession.targetUserId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: '연결할 기존 계정을 찾지 못했습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [targetUser, emailOwner, oauthOwner] = await Promise.all([
|
||||||
|
ctx.users.findById(oauthSession.targetUserId),
|
||||||
|
ctx.users.findByEmail(oauthSession.email),
|
||||||
|
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
|
||||||
|
]);
|
||||||
|
if (!targetUser || emailOwner?.id !== targetUser.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message:
|
||||||
|
'보존된 이메일의 계정 정보가 변경되었습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (oauthOwner && oauthOwner.id !== targetUser.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: '이미 다른 계정에 연결된 카카오 계정입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (targetUser.deleteAfter) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '탈퇴 처리 중인 계정에는 카카오 계정을 다시 연결할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isLoginBanned(targetUser.sanctions)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Account login is blocked.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthInfo: UserOAuthInfo = {
|
||||||
|
accessToken: oauthSession.accessToken,
|
||||||
|
refreshToken: oauthSession.refreshToken,
|
||||||
|
accessTokenValidUntil: oauthSession.accessTokenValidUntil,
|
||||||
|
refreshTokenValidUntil: oauthSession.refreshTokenValidUntil,
|
||||||
|
};
|
||||||
|
let linked: UserRecord;
|
||||||
|
try {
|
||||||
|
linked = await ctx.users.relinkKakaoByEmail(targetUser.id, {
|
||||||
|
oauthId: oauthSession.kakaoId,
|
||||||
|
email: oauthSession.email,
|
||||||
|
oauthInfo,
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked');
|
||||||
|
return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login');
|
||||||
|
}),
|
||||||
register: procedure
|
register: procedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -460,6 +584,12 @@ export const appRouter = router({
|
|||||||
message: 'OAuth 세션이 만료되었습니다.',
|
message: 'OAuth 세션이 만료되었습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (oauthSession.intent && oauthSession.intent !== 'register') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '카카오 계정 복구 여부를 먼저 선택해 주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
const existing = await ctx.users.findByUsername(input.username);
|
const existing = await ctx.users.findByUsername(input.username);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const buildCaller = (
|
|||||||
profileListError?: Error;
|
profileListError?: Error;
|
||||||
kakaoId?: string;
|
kakaoId?: string;
|
||||||
kakaoEmail?: string;
|
kakaoEmail?: string;
|
||||||
|
kakaoSignupAlreadyRegistered?: boolean;
|
||||||
allowKakaoRefresh?: boolean;
|
allowKakaoRefresh?: boolean;
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
@@ -69,7 +70,8 @@ const buildCaller = (
|
|||||||
accessTokenExpiresIn: 3600,
|
accessTokenExpiresIn: 3600,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
signup: async () => ({ id: '1' }),
|
signup: async () =>
|
||||||
|
options.kakaoSignupAlreadyRegistered ? { msg: 'already registered' as const } : { id: kakaoProfile.id },
|
||||||
getMe: async () => ({
|
getMe: async () => ({
|
||||||
id: kakaoProfile.id,
|
id: kakaoProfile.id,
|
||||||
kakaoAccount: {
|
kakaoAccount: {
|
||||||
@@ -472,9 +474,9 @@ describe('gateway auth flow', () => {
|
|||||||
expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message');
|
expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a new Kakao identity when its verified email is already registered', async () => {
|
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
|
||||||
const { caller, users, kakaoProfile } = buildCaller();
|
const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller();
|
||||||
await users.createUser({
|
const emailOwner = await users.createUser({
|
||||||
username: 'email-owner',
|
username: 'email-owner',
|
||||||
password: 'owner-password',
|
password: 'owner-password',
|
||||||
oauth: {
|
oauth: {
|
||||||
@@ -484,12 +486,100 @@ describe('gateway auth flow', () => {
|
|||||||
info: {},
|
info: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
|
||||||
kakaoProfile.id = 'different-kakao-id';
|
kakaoProfile.id = 'different-kakao-id';
|
||||||
|
|
||||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||||
await expect(caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state })).rejects.toMatchObject({
|
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||||
code: 'CONFLICT',
|
|
||||||
message: expect.stringContaining('이미 다른 계정에서 사용 중인 카카오 이메일'),
|
expect(recovery).toMatchObject({
|
||||||
|
status: 'account_recovery',
|
||||||
|
action: 'link_existing',
|
||||||
|
email: 'tester@example.com',
|
||||||
|
});
|
||||||
|
if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.');
|
||||||
|
|
||||||
|
const linked = await caller.auth.kakaoResolveAccount({
|
||||||
|
oauthSessionId: recovery.oauthSessionId,
|
||||||
|
action: 'link_existing',
|
||||||
|
});
|
||||||
|
expect(linked.status).toBe('otp');
|
||||||
|
expect(sentTalkMessages).toHaveLength(1);
|
||||||
|
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
|
||||||
|
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
|
||||||
|
id: emailOwner.id,
|
||||||
|
username: 'email-owner',
|
||||||
|
email: 'tester@example.com',
|
||||||
|
});
|
||||||
|
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
|
||||||
|
const { caller, users, sealPassword } = buildCaller({ kakaoSignupAlreadyRegistered: true });
|
||||||
|
|
||||||
|
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||||
|
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||||
|
expect(recovery).toMatchObject({
|
||||||
|
status: 'account_recovery',
|
||||||
|
action: 'rejoin',
|
||||||
|
email: 'tester@example.com',
|
||||||
|
});
|
||||||
|
if (recovery.status !== 'account_recovery') throw new Error('Expected rejoin choice.');
|
||||||
|
|
||||||
|
const confirmed = await caller.auth.kakaoResolveAccount({
|
||||||
|
oauthSessionId: recovery.oauthSessionId,
|
||||||
|
action: 'rejoin',
|
||||||
|
});
|
||||||
|
expect(confirmed).toMatchObject({ status: 'join', email: 'tester@example.com' });
|
||||||
|
if (confirmed.status !== 'join') throw new Error('Expected registration session.');
|
||||||
|
|
||||||
|
const registered = await caller.auth.register({
|
||||||
|
oauthSessionId: confirmed.oauthSessionId,
|
||||||
|
username: 'rejoined-user',
|
||||||
|
credential: sealPassword('rejoined-password'),
|
||||||
|
displayName: '재가입사용자',
|
||||||
|
termsAgreed: true,
|
||||||
|
privacyAgreed: true,
|
||||||
|
thirdPartyUse: false,
|
||||||
|
});
|
||||||
|
expect(registered.status).toBe('otp');
|
||||||
|
expect(await users.findByUsername('rejoined-user')).toMatchObject({
|
||||||
|
oauthType: 'KAKAO',
|
||||||
|
oauthId: '1',
|
||||||
|
email: 'tester@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let the registration mutation bypass the recovery confirmation', async () => {
|
||||||
|
const { caller, users, sealPassword } = buildCaller();
|
||||||
|
await users.createUser({
|
||||||
|
username: 'retained-owner',
|
||||||
|
password: 'owner-password',
|
||||||
|
oauth: {
|
||||||
|
type: 'KAKAO',
|
||||||
|
id: 'former-kakao-id',
|
||||||
|
email: 'tester@example.com',
|
||||||
|
info: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||||
|
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||||
|
if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.auth.register({
|
||||||
|
oauthSessionId: recovery.oauthSessionId,
|
||||||
|
username: 'bypass-user',
|
||||||
|
credential: sealPassword('bypass-password'),
|
||||||
|
displayName: '우회사용자',
|
||||||
|
termsAgreed: true,
|
||||||
|
privacyAgreed: true,
|
||||||
|
thirdPartyUse: false,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: expect.stringContaining('복구 여부를 먼저 선택'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const userId = '3dd08c49-279e-41ad-a12b-51b914cc51c8';
|
||||||
|
|
||||||
|
const assertDedicatedSchema = (): void => {
|
||||||
|
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
|
||||||
|
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||||
|
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
|
||||||
|
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
integration('Kakao account relink PostgreSQL boundary', () => {
|
||||||
|
let db: GatewayPrismaClient;
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertDedicatedSchema();
|
||||||
|
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
initialized = true;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await db.appUser.deleteMany({ where: { id: userId } });
|
||||||
|
await db.appUser.create({
|
||||||
|
data: {
|
||||||
|
id: userId,
|
||||||
|
loginId: 'kakao-relink-integration',
|
||||||
|
displayName: '카카오 재연결 통합',
|
||||||
|
passwordHash: 'not-used',
|
||||||
|
passwordSalt: 'not-used',
|
||||||
|
roles: ['user'],
|
||||||
|
sanctions: {},
|
||||||
|
oauthType: 'KAKAO',
|
||||||
|
oauthId: 'former-kakao-id',
|
||||||
|
email: 'retained@example.test',
|
||||||
|
kakaoVerifiedAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||||
|
kakaoTalkVerifiedUntil: new Date('2026-08-20T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (initialized) {
|
||||||
|
await db.appUser.deleteMany({ where: { id: userId } });
|
||||||
|
}
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relinks the new provider identity while preserving email ownership and resetting the old talk proof', async () => {
|
||||||
|
const users = createPostgresUserRepository(db);
|
||||||
|
const linked = await users.relinkKakaoByEmail(userId, {
|
||||||
|
oauthId: 'replacement-kakao-id',
|
||||||
|
email: 'retained@example.test',
|
||||||
|
oauthInfo: {
|
||||||
|
accessToken: 'replacement-access-token',
|
||||||
|
accessTokenValidUntil: '2026-08-08T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
verifiedAt: new Date('2026-08-08T10:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(linked).toMatchObject({
|
||||||
|
id: userId,
|
||||||
|
oauthType: 'KAKAO',
|
||||||
|
oauthId: 'replacement-kakao-id',
|
||||||
|
email: 'retained@example.test',
|
||||||
|
kakaoTalkVerifiedUntil: undefined,
|
||||||
|
});
|
||||||
|
await expect(users.findByOauthId('KAKAO', 'former-kakao-id')).resolves.toBeNull();
|
||||||
|
await expect(users.findByOauthId('KAKAO', 'replacement-kakao-id')).resolves.toMatchObject({ id: userId });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,13 @@ import { RedisOAuthSessionStore } from '../src/auth/oauthSessionStore.js';
|
|||||||
|
|
||||||
const redisUrl = process.env.GATEWAY_OAUTH_REDIS_TEST_URL;
|
const redisUrl = process.env.GATEWAY_OAUTH_REDIS_TEST_URL;
|
||||||
|
|
||||||
describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => {
|
describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
||||||
const prefix = `gateway-oauth-test:${randomUUID()}`;
|
const prefix = `gateway-oauth-test:${randomUUID()}`;
|
||||||
const client = createClient({ url: redisUrl });
|
const client = createClient({ url: redisUrl });
|
||||||
const store = new RedisOAuthSessionStore(client, prefix, 300);
|
const store = new RedisOAuthSessionStore(client, prefix, 300);
|
||||||
const userIds = new Set<string>();
|
const userIds = new Set<string>();
|
||||||
const challengeIds = new Set<string>();
|
const challengeIds = new Set<string>();
|
||||||
|
const sessionIds = new Set<string>();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -22,6 +23,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () =>
|
|||||||
const keys = [
|
const keys = [
|
||||||
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
|
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
|
||||||
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
|
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
|
||||||
|
...[...sessionIds].map((id) => `${prefix}:oauth-session:${id}`),
|
||||||
];
|
];
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await client.del(keys);
|
await client.del(keys);
|
||||||
@@ -43,6 +45,31 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () =>
|
|||||||
return challenge;
|
return challenge;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('preserves and consumes a retained-email recovery target once', async () => {
|
||||||
|
const targetUserId = randomUUID();
|
||||||
|
const session = await store.createSession({
|
||||||
|
mode: 'login',
|
||||||
|
intent: 'link_existing',
|
||||||
|
targetUserId,
|
||||||
|
kakaoId: 'replacement-kakao-id',
|
||||||
|
email: 'retained@example.test',
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
sessionIds.add(session.id);
|
||||||
|
|
||||||
|
await expect(store.consumeSession(session.id)).resolves.toMatchObject({
|
||||||
|
id: session.id,
|
||||||
|
intent: 'link_existing',
|
||||||
|
targetUserId,
|
||||||
|
email: 'retained@example.test',
|
||||||
|
});
|
||||||
|
await expect(store.consumeSession(session.id)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('atomically consumes a successful code once', async () => {
|
it('atomically consumes a successful code once', async () => {
|
||||||
const challenge = await createChallenge();
|
const challenge = await createChallenge();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
|
const operations = operationNames(route);
|
||||||
|
const results = operations.map((operation) => {
|
||||||
|
calls.push(operation);
|
||||||
|
if (operation === 'me') return response(null);
|
||||||
|
if (operation === 'lobby.notice') return response('');
|
||||||
|
if (operation === 'lobby.profiles') return response([]);
|
||||||
|
if (operation === 'auth.kakaoExchange') {
|
||||||
|
return response({
|
||||||
|
status: 'account_recovery',
|
||||||
|
action,
|
||||||
|
oauthSessionId: `${action}-session`,
|
||||||
|
email: 'retained@example.test',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'auth.kakaoResolveAccount') {
|
||||||
|
return action === 'link_existing'
|
||||||
|
? response({
|
||||||
|
status: 'otp',
|
||||||
|
successStatus: 'login',
|
||||||
|
challengeId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
expiresAt: '2026-08-08T12:03:00.000Z',
|
||||||
|
attemptsRemaining: 3,
|
||||||
|
})
|
||||||
|
: response({
|
||||||
|
status: 'join',
|
||||||
|
oauthSessionId: 'confirmed-registration-session',
|
||||||
|
email: 'retained@example.test',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled Kakao account recovery fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyRecoveryChoice = async (page: Page, action: 'link_existing' | 'rejoin') => {
|
||||||
|
const group = page.getByRole('group', { name: '카카오 계정 연결 확인' });
|
||||||
|
const confirm = group.getByRole('button', {
|
||||||
|
name: action === 'link_existing' ? '기존 계정에 연결' : '재가입',
|
||||||
|
});
|
||||||
|
await expect(group).toBeVisible();
|
||||||
|
await expect(group).toContainText('retained@example.test');
|
||||||
|
await expect(group).toContainText(
|
||||||
|
action === 'link_existing' ? '이 계정에 카카오 로그인을 연결해드릴까요?' : '새 계정으로 재가입하시겠습니까?'
|
||||||
|
);
|
||||||
|
const geometry = await group.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.width).toBeGreaterThan(300);
|
||||||
|
expect(geometry.backgroundColor).toBe('rgba(0, 0, 0, 0)');
|
||||||
|
|
||||||
|
await confirm.hover();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
expect(await confirm.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(47, 77, 108)');
|
||||||
|
await confirm.focus();
|
||||||
|
expect(await confirm.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('solid');
|
||||||
|
return { group, confirm, geometry };
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
|
{ name: 'mobile', width: 390, height: 844 },
|
||||||
|
] as const) {
|
||||||
|
test(`links the retained email account only after confirmation on ${viewport.name}`, async ({ page }) => {
|
||||||
|
const calls = await installFixture(page, 'link_existing');
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
|
||||||
|
const { confirm, geometry } = await verifyRecoveryChoice(page, 'link_existing');
|
||||||
|
|
||||||
|
await confirm.click();
|
||||||
|
await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible();
|
||||||
|
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
|
||||||
|
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`continues an orphaned Kakao connection as a new registration on ${viewport.name}`, async ({ page }) => {
|
||||||
|
const calls = await installFixture(page, 'rejoin');
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
|
||||||
|
const { confirm, geometry } = await verifyRecoveryChoice(page, 'rejoin');
|
||||||
|
|
||||||
|
await confirm.click();
|
||||||
|
await expect(page.getByRole('heading', { name: '회원가입' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('카카오 이메일')).toHaveValue('retained@example.test');
|
||||||
|
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
|
||||||
|
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
'legacy-log-html.spec.ts',
|
'legacy-log-html.spec.ts',
|
||||||
'gateway-notice-html.spec.ts',
|
'gateway-notice-html.spec.ts',
|
||||||
'kakao-otp.spec.ts',
|
'kakao-otp.spec.ts',
|
||||||
|
'kakao-account-recovery.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ const displayName = ref('');
|
|||||||
const termsAgreed = ref(false);
|
const termsAgreed = ref(false);
|
||||||
const privacyAgreed = ref(false);
|
const privacyAgreed = ref(false);
|
||||||
const thirdPartyUse = ref(false);
|
const thirdPartyUse = ref(false);
|
||||||
|
const accountRecovery = ref<{
|
||||||
|
action: 'link_existing' | 'rejoin';
|
||||||
|
oauthSessionId: string;
|
||||||
|
email: string;
|
||||||
|
} | null>(null);
|
||||||
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
|
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
|
||||||
const otpSuccessStatus = ref<'login' | 'verified'>('login');
|
const otpSuccessStatus = ref<'login' | 'verified'>('login');
|
||||||
const appBase = import.meta.env.BASE_URL;
|
const appBase = import.meta.env.BASE_URL;
|
||||||
@@ -55,6 +60,11 @@ const completeExchange = async (): Promise<void> => {
|
|||||||
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
|
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.status === 'account_recovery') {
|
||||||
|
accountRecovery.value = result;
|
||||||
|
email.value = result.email;
|
||||||
|
return;
|
||||||
|
}
|
||||||
oauthSessionId.value = result.oauthSessionId;
|
oauthSessionId.value = result.oauthSessionId;
|
||||||
email.value = result.email;
|
email.value = result.email;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -64,6 +74,35 @@ const completeExchange = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveAccount = async (): Promise<void> => {
|
||||||
|
if (!accountRecovery.value) return;
|
||||||
|
errorMessage.value = '';
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const result = await trpc.auth.kakaoResolveAccount.mutate({
|
||||||
|
oauthSessionId: accountRecovery.value.oauthSessionId,
|
||||||
|
action: accountRecovery.value.action,
|
||||||
|
});
|
||||||
|
accountRecovery.value = null;
|
||||||
|
if (result.status === 'otp') {
|
||||||
|
otpChallenge.value = result;
|
||||||
|
otpSuccessStatus.value = result.successStatus;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === 'login') {
|
||||||
|
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||||
|
await router.replace('/lobby');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oauthSessionId.value = result.oauthSessionId;
|
||||||
|
email.value = result.email;
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '카카오 계정 연결을 완료하지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const register = async (): Promise<void> => {
|
const register = async (): Promise<void> => {
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
if (password.value !== confirmPassword.value) {
|
if (password.value !== confirmPassword.value) {
|
||||||
@@ -117,9 +156,31 @@ onMounted(() => {
|
|||||||
<main id="oauth-container">
|
<main id="oauth-container">
|
||||||
<h1>삼국지 모의전투 HiDCHe</h1>
|
<h1>삼국지 모의전투 HiDCHe</h1>
|
||||||
<section class="oauth-card">
|
<section class="oauth-card">
|
||||||
<h2>회원가입</h2>
|
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2>
|
||||||
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
||||||
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
||||||
|
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
|
||||||
|
<p v-if="accountRecovery.action === 'link_existing'">
|
||||||
|
<strong>{{ accountRecovery.email }}</strong> 이메일로 보존된 기존 계정이 있습니다. 이 계정에
|
||||||
|
카카오 로그인을 연결해드릴까요?
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
<strong>{{ accountRecovery.email }}</strong> 이메일은 카카오에 이미 가입된 연결이 있지만 이
|
||||||
|
서비스에서 연결할 계정을 찾지 못했습니다. 새 계정으로 재가입하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div class="recovery-actions">
|
||||||
|
<button class="register-button" type="button" :disabled="submitting" @click="resolveAccount">
|
||||||
|
{{
|
||||||
|
submitting
|
||||||
|
? '처리 중...'
|
||||||
|
: accountRecovery.action === 'link_existing'
|
||||||
|
? '기존 계정에 연결'
|
||||||
|
: '재가입'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="oauth-email">카카오 이메일</label>
|
<label for="oauth-email">카카오 이메일</label>
|
||||||
@@ -224,6 +285,33 @@ onMounted(() => {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recovery-panel {
|
||||||
|
padding: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-panel p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-panel strong {
|
||||||
|
color: #ffd180;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-actions .register-button,
|
||||||
|
.recovery-actions .back-link {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.form-row,
|
.form-row,
|
||||||
.agreement-row {
|
.agreement-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -34,10 +34,22 @@ Gateway API는 다음 저장 경계를 사용합니다.
|
|||||||
|
|
||||||
Kakao 로그인은 URL 이름만으로 사용자를 연결하지 않습니다. `account_email`과
|
Kakao 로그인은 URL 이름만으로 사용자를 연결하지 않습니다. `account_email`과
|
||||||
`talk_message` scope를 항상 요청하고 callback의 `/v2/user/me` 응답에서 고유 ID,
|
`talk_message` scope를 항상 요청하고 callback의 `/v2/user/me` 응답에서 고유 ID,
|
||||||
이메일 보유·유효·인증 상태를 확인합니다. 고유 ID가 다른 계정의 같은 이메일로
|
이메일 보유·유효·인증 상태를 확인합니다. 기존 Kakao 계정이면 stable OAuth ID로
|
||||||
접근하는 경우와 변경 이메일이 이미 다른 `AppUser`에 속한 경우는 `CONFLICT`로
|
사용자를 찾은 뒤 이메일과 갱신된 token metadata를 함께 저장합니다. stable ID가
|
||||||
끝나며 session을 만들지 않습니다. 기존 Kakao 계정이면 stable OAuth ID로
|
로컬에 없지만 같은 `AppUser.email` 소유자가 있으면 일회용 OAuth session에 대상
|
||||||
사용자를 찾은 뒤 이메일과 갱신된 token metadata를 함께 저장합니다.
|
사용자 ID를 묶고 `account_recovery/link_existing` 선택을 반환합니다. 사용자가
|
||||||
|
확인한 경우에만 그 행의 Kakao ID와 token metadata를 교체하며, 과거
|
||||||
|
`kakaoTalkVerifiedUntil`은 무효화하여 새 KakaoTalk OTP를 통과하기 전에는 login
|
||||||
|
session을 발급하지 않습니다. 확인과 저장 사이에 email 또는 OAuth 소유자가
|
||||||
|
바뀌면 다시 시작하도록 거부합니다.
|
||||||
|
|
||||||
|
Kakao `/v1/user/signup`이 `already registered`를 반환했는데 stable ID와 email
|
||||||
|
소유자가 모두 없으면 `account_recovery/rejoin`을 반환합니다. 사용자가 재가입을
|
||||||
|
확인해야 별도의 `register` intent session을 발급하므로 기존 가입 mutation으로
|
||||||
|
확인 단계를 우회할 수 없습니다. 반대로 provider 연결이 이번 요청에서 새로
|
||||||
|
생성됐고 로컬 email 소유자도 없으면 바로 신규 가입 form으로 진행합니다. 이미
|
||||||
|
로컬 stable ID가 있는 상태에서 현재 이메일이 다른 `AppUser`에 속하면 identity를
|
||||||
|
이메일 계정으로 옮기지 않고 기존처럼 `CONFLICT`로 끝냅니다.
|
||||||
|
|
||||||
일반 비밀번호 로그인도 `oauth_type=KAKAO`이면 저장 access token을 사용하고,
|
일반 비밀번호 로그인도 `oauth_type=KAKAO`이면 저장 access token을 사용하고,
|
||||||
필요하면 아직 유효한 refresh token으로 갱신한 뒤 `/v2/user/me`를 호출합니다.
|
필요하면 아직 유효한 refresh token으로 갱신한 뒤 `/v2/user/me`를 호출합니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user