feat(gateway): verify Kakao account ownership

This commit is contained in:
2026-08-08 06:23:16 +00:00
parent 2d92114aa9
commit 3654adbe13
33 changed files with 2047 additions and 138 deletions
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
const props = defineProps<{
challengeId: string;
}>();
const emit = defineEmits<{
verified: [sessionToken: string, validUntil: string];
cancel: [];
}>();
const code = ref('');
const errorMessage = ref('');
const submitting = ref(false);
const codeInput = ref<HTMLInputElement | null>(null);
watch(
() => props.challengeId,
async () => {
code.value = '';
errorMessage.value = '';
await nextTick();
codeInput.value?.focus();
},
{ immediate: true }
);
const submit = async (): Promise<void> => {
errorMessage.value = '';
submitting.value = true;
try {
const result = await trpc.auth.kakaoOtp.mutate({
challengeId: props.challengeId,
code: code.value,
});
emit('verified', result.sessionToken, result.validUntil);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '인증 코드를 확인하지 못했습니다.';
code.value = '';
await nextTick();
codeInput.value?.focus();
} finally {
submitting.value = false;
}
};
</script>
<template>
<Teleport to="body">
<div class="otp-backdrop" @keydown.esc="emit('cancel')">
<section
class="otp-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="kakao-otp-title"
aria-describedby="kakao-otp-description"
>
<header>
<h2 id="kakao-otp-title">인증 코드 필요</h2>
<button class="close-button" type="button" aria-label="닫기" @click="emit('cancel')">×</button>
</header>
<form @submit.prevent="submit">
<div id="kakao-otp-description" class="otp-copy">
인증 코드가 필요합니다.<br /><br />
카카오톡의 '나와의 채팅'란을 확인해 주세요.<br />
(별도의 알림[소리, 진동, 숫자] 발생하지 않습니다.)
</div>
<label class="otp-input-row" for="kakao-otp-code">
<span>인증 코드</span>
<input
id="kakao-otp-code"
ref="codeInput"
v-model="code"
type="text"
inputmode="numeric"
pattern="[0-9]{4}"
maxlength="4"
autocomplete="one-time-code"
placeholder="인증 코드"
required
/>
</label>
<p v-if="errorMessage" class="otp-error" role="alert">{{ errorMessage }}</p>
<footer>
<button class="cancel-button" type="button" @click="emit('cancel')">취소</button>
<button class="submit-button" type="submit" :disabled="submitting">
{{ submitting ? '확인 중…' : '제출' }}
</button>
</footer>
</form>
</section>
</div>
</Teleport>
</template>
<style scoped>
.otp-backdrop {
position: fixed;
z-index: 1000;
inset: 0;
overflow-y: auto;
background: rgb(0 0 0 / 60%);
}
.otp-dialog {
width: min(calc(100% - 16px), 500px);
margin: 28px auto;
overflow: hidden;
border: 1px solid #444;
border-radius: 5px;
background: #303030;
color: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
}
@media (max-width: 575px) {
.otp-dialog {
margin-top: 8px;
}
}
.otp-dialog header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #555;
padding: 16px;
}
.otp-dialog h2 {
margin: 0;
font-size: 20px;
font-weight: 500;
}
.close-button {
width: 26px;
height: 30px;
border: 1px solid #aaa;
background: #fff;
color: #000;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.otp-copy {
padding: 18px 18px 0;
line-height: 1.5;
}
.otp-input-row {
display: grid;
grid-template-columns: auto 1fr;
margin: 22px 16px 0;
}
.otp-input-row span,
.otp-input-row input {
border: 1px solid #000;
padding: 6px 12px;
}
.otp-input-row span {
border-radius: 4px 0 0 4px;
background: #303030;
color: #adb5bd;
}
.otp-input-row input {
min-width: 0;
border-left: 0;
border-radius: 0 4px 4px 0;
background: #ddd;
color: #303030;
}
.otp-input-row input:focus-visible {
outline: 0;
border-color: #9a9a9a;
box-shadow: 0 0 0 4px rgb(55 90 127 / 25%);
}
.close-button:focus-visible {
outline: 2px solid #375a7f;
outline-offset: 1px;
}
.otp-error {
margin: 8px 18px 0;
font-size: 13px;
}
.otp-error {
color: #ff8a80;
}
.otp-dialog footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
border-top: 1px solid #555;
padding: 15px 16px;
}
.otp-dialog footer button {
min-width: 64px;
border: 1px solid transparent;
border-radius: 4px;
padding: 7px 12px;
color: #fff;
cursor: pointer;
transition:
color 0.15s ease-in-out,
background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.cancel-button {
border-color: #444 !important;
background: #444;
}
.cancel-button:active {
background: #363636;
}
.submit-button {
border-color: #325172 !important;
background: #375a7f;
}
.submit-button:hover {
background: #375a7f;
}
.submit-button:focus-visible {
outline: 0;
box-shadow: 0 0 0 4px rgb(85 115 146 / 50%);
}
.submit-button:active {
background: #2c4866;
}
.submit-button:disabled {
border-color: #375a7f !important;
background: #375a7f;
cursor: pointer;
opacity: 0.65;
}
</style>
@@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api';
import MapPreview from '../components/MapPreview.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
@@ -24,6 +25,7 @@ const username = ref('');
const password = ref('');
const loginError = ref('');
const loginLoading = ref(false);
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
const statusLoading = ref(false);
const statusError = ref('');
const profile = ref<LobbyProfile | null>(null);
@@ -92,6 +94,10 @@ const handleLogin = async (): Promise<void> => {
username: username.value,
credential,
});
if (result.status === 'otp') {
otpChallenge.value = result;
return;
}
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.push('/lobby');
} catch (error) {
@@ -101,6 +107,12 @@ const handleLogin = async (): Promise<void> => {
}
};
const handleOtpVerified = async (sessionToken: string): Promise<void> => {
window.localStorage.setItem('sammo-session-token', sessionToken);
otpChallenge.value = null;
await router.push('/lobby');
};
const handleKakao = async (): Promise<void> => {
loginError.value = '';
try {
@@ -191,6 +203,12 @@ const handlePasswordReset = async (): Promise<void> => {
</section>
</div>
</DefaultLayout>
<KakaoOtpDialog
v-if="otpChallenge"
:challenge-id="otpChallenge.challengeId"
@verified="handleOtpVerified"
@cancel="otpChallenge = null"
/>
</template>
<style scoped>
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
import { trpc } from '../utils/trpc';
import { sealPassword } from '../utils/passwordEnvelope';
@@ -21,6 +22,8 @@ const displayName = ref('');
const termsAgreed = ref(false);
const privacyAgreed = ref(false);
const thirdPartyUse = ref(false);
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
const otpSuccessStatus = ref<'login' | 'verified'>('login');
const appBase = import.meta.env.BASE_URL;
const completeExchange = async (): Promise<void> => {
@@ -33,6 +36,11 @@ const completeExchange = async (): Promise<void> => {
}
try {
const result = await trpc.auth.kakaoExchange.mutate({ code, state });
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');
@@ -78,6 +86,12 @@ const register = async (): Promise<void> => {
privacyAgreed: true,
thirdPartyUse: thirdPartyUse.value,
});
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
oauthSessionId.value = '';
return;
}
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace('/lobby');
} catch (error) {
@@ -87,6 +101,12 @@ const register = async (): Promise<void> => {
}
};
const handleOtpVerified = async (sessionToken: string): Promise<void> => {
window.localStorage.setItem('sammo-session-token', sessionToken);
otpChallenge.value = null;
await router.replace(otpSuccessStatus.value === 'verified' ? '/lobby?verified=1' : '/lobby');
};
onMounted(() => {
void completeExchange();
});
@@ -120,7 +140,13 @@ onMounted(() => {
<div class="form-row">
<label for="oauth-display-name">닉네임</label>
<div>
<input id="oauth-display-name" v-model="displayName" minlength="2" maxlength="40" required />
<input
id="oauth-display-name"
v-model="displayName"
minlength="2"
maxlength="40"
required
/>
<small>깃수가 종료될 공개됩니다. 계속 사용할 이름이므로 신중하게 정해주세요.</small>
</div>
</div>
@@ -154,6 +180,12 @@ onMounted(() => {
</section>
</main>
</DefaultLayout>
<KakaoOtpDialog
v-if="otpChallenge"
:challenge-id="otpChallenge.challengeId"
@verified="handleOtpVerified"
@cancel="otpChallenge = null"
/>
</template>
<style scoped>