feat: 프론트 인증 연동 (소셜 로그인 + Bearer 세션)

- 로그인 화면: 구글(GIS) 버튼 + 애플 버튼 + 개발용 로그인(local)
- 인증 스토어(Pinia): 토큰 복구/로그인/로그아웃, 회원 티어를 광고·매칭 제어에 반영
- HTTP 클라이언트: Authorization Bearer 자동 첨부, 401 시 토큰 폐기 후 로그인 이동
- 라우터 가드: 미로그인 시 보호 경로 → /login (소셜 로그인 전용 앱)
- 헤더 회원 메뉴/로그아웃, 체크인 주체를 로그인 회원으로 전환
- 토큰 localStorage 저장(웹/Capacitor 공용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 08:35:00 +09:00
parent f089ba2dfe
commit 0c538e0658
10 changed files with 352 additions and 9 deletions
+3
View File
@@ -5,3 +5,6 @@ VITE_API_BASE_URL=http://localhost:8080
VITE_NAVER_MAP_CLIENT_ID=your_ncp_key_id VITE_NAVER_MAP_CLIENT_ID=your_ncp_key_id
# 신규 키는 ncpKeyId(기본), 구형 키면 ncpClientId 로 변경 # 신규 키는 ncpKeyId(기본), 구형 키면 ncpClientId 로 변경
# VITE_NAVER_MAP_AUTH_PARAM=ncpKeyId # VITE_NAVER_MAP_AUTH_PARAM=ncpKeyId
# 개발용 로그인 토큰 (백엔드 local 프로파일 시드와 일치). dev 빌드에서만 노출.
VITE_DEV_LOGIN_TOKEN=dev-local-token
+29
View File
@@ -0,0 +1,29 @@
import { http } from './http'
export interface Member {
id: number
email: string
nickname: string
role: string
provider: string
subscriptionTier: 'FREE' | 'AD_FREE' | 'PREMIUM'
status: string
profileImage: string | null
createdAt: string
}
export interface LoginResponse {
token: string
expiresInSeconds: number
member: Member
}
export const authApi = {
googleClientId: () => http.get<{ clientId: string }>('/api/auth/google-client-id'),
google: (idToken: string, rememberMe = true) =>
http.post<LoginResponse>('/api/auth/google', { idToken, rememberMe }),
apple: (identityToken: string, name: string | null, rememberMe = true) =>
http.post<LoginResponse>('/api/auth/apple', { identityToken, name, rememberMe }),
me: () => http.get<Member>('/api/auth/me'),
logout: () => http.post<void>('/api/auth/logout'),
}
+19 -2
View File
@@ -1,4 +1,6 @@
// 공통 HTTP 클라이언트 — VITE_API_BASE_URL 기준 import { tokenStore } from '@/lib/token'
// 공통 HTTP 클라이언트 — VITE_API_BASE_URL 기준, Bearer 토큰 자동 첨부
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080' const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
export class ApiError extends Error { export class ApiError extends Error {
@@ -12,13 +14,28 @@ export class ApiError extends Error {
} }
} }
// 401 발생 시 호출될 핸들러(로그인 화면 이동 등). 순환참조 방지용 주입.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(fn: () => void): void {
onUnauthorized = fn
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> { async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const token = tokenStore.get()
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(`${BASE_URL}${path}`, { const res = await fetch(`${BASE_URL}${path}`, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers,
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
}) })
if (res.status === 401) {
tokenStore.clear()
onUnauthorized?.()
}
const text = await res.text() const text = await res.text()
const data = text ? JSON.parse(text) : null const data = text ? JSON.parse(text) : null
+26
View File
@@ -8,6 +8,23 @@
<q-chip dense color="white" text-color="primary" icon="bolt"> <q-chip dense color="white" text-color="primary" icon="bolt">
{{ tierLabel }} {{ tierLabel }}
</q-chip> </q-chip>
<q-btn flat round dense icon="account_circle">
<q-menu>
<q-list style="min-width: 160px">
<q-item>
<q-item-section>
<q-item-label class="text-weight-medium">{{ auth.member?.nickname }}</q-item-label>
<q-item-label caption>{{ auth.member?.email }}</q-item-label>
</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="onLogout">
<q-item-section avatar><q-icon name="logout" /></q-item-section>
<q-item-section>로그아웃</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
@@ -34,11 +51,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AdBanner from '@/components/AdBanner.vue' import AdBanner from '@/components/AdBanner.vue'
import { useSubscriptionStore } from '@/stores/subscription' import { useSubscriptionStore } from '@/stores/subscription'
import { useAuthStore } from '@/stores/auth'
const tab = ref('home') const tab = ref('home')
const subscription = useSubscriptionStore() const subscription = useSubscriptionStore()
const auth = useAuthStore()
const router = useRouter()
const tierLabel = computed(() => { const tierLabel = computed(() => {
switch (subscription.tier) { switch (subscription.tier) {
@@ -50,4 +71,9 @@ const tierLabel = computed(() => {
return 'FREE' return 'FREE'
} }
}) })
async function onLogout() {
await auth.logout()
router.replace({ name: 'login' })
}
</script> </script>
+9
View File
@@ -0,0 +1,9 @@
// 세션 토큰 저장소. localStorage 기반 (Capacitor 웹뷰/웹 공용).
// http 클라이언트와 인증 스토어의 순환참조를 피하기 위해 분리.
const KEY = 'dognation.token'
export const tokenStore = {
get: (): string | null => localStorage.getItem(KEY),
set: (token: string): void => localStorage.setItem(KEY, token),
clear: (): void => localStorage.removeItem(KEY),
}
+15 -6
View File
@@ -10,19 +10,28 @@ import 'quasar/src/css/index.sass'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './css/app.scss' import './css/app.scss'
import { setUnauthorizedHandler } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(Quasar, { app.use(Quasar, {
plugins: { Notify }, plugins: { Notify },
config: { config: {
brand: { brand: { primary: '#6DB3E8' },
// quasar-variables.scss 와 일치 (런타임 참고용)
primary: '#6DB3E8',
},
}, },
}) })
app.use(createPinia())
app.use(router) app.use(router)
app.mount('#app') // 401 응답 시 로그인 화면으로
setUnauthorizedHandler(() => {
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' })
}
})
// 저장된 토큰으로 세션 복구 후 마운트 (라우터 가드가 올바른 상태를 보도록)
const auth = useAuthStore(pinia)
auth.init().finally(() => app.mount('#app'))
+6 -1
View File
@@ -75,10 +75,15 @@ import { spotsApi, type Mate, type Review, type Spot } from '@/api/spots'
import { matchesApi } from '@/api/matches' import { matchesApi } from '@/api/matches'
import { ApiError } from '@/api/http' import { ApiError } from '@/api/http'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { useAuthStore } from '@/stores/auth'
import NaverMap from '@/components/NaverMap.vue' import NaverMap from '@/components/NaverMap.vue'
const $q = useQuasar() const $q = useQuasar()
const session = useSessionStore() const session = useSessionStore()
const auth = useAuthStore()
// 체크인/매칭 주체: 로그인한 회원. 강아지 ID 는 아직 견 관리 API 전이라 세션 기본값 사용.
const currentUserId = () => auth.member?.id ?? session.userId
const spots = ref<Spot[]>([]) const spots = ref<Spot[]>([])
const currentSpot = ref<Spot | null>(null) const currentSpot = ref<Spot | null>(null)
@@ -123,7 +128,7 @@ async function onCheckIn() {
if (!currentSpot.value) return if (!currentSpot.value) return
checkingIn.value = true checkingIn.value = true
try { try {
await spotsApi.checkIn(currentSpot.value.id, session.userId, session.dogId) await spotsApi.checkIn(currentSpot.value.id, currentUserId(), session.dogId)
checkedIn.value = true checkedIn.value = true
await refreshSpot(currentSpot.value.id) await refreshSpot(currentSpot.value.id)
$q.notify({ color: 'primary', message: '체크인 완료! 스팟 채팅이 열렸어요.', icon: 'place', position: 'top' }) $q.notify({ color: 'primary', message: '체크인 완료! 스팟 채팅이 열렸어요.', icon: 'place', position: 'top' })
+166
View File
@@ -0,0 +1,166 @@
<template>
<q-page class="flex flex-center column q-pa-lg bg-brand">
<div class="column items-center q-mb-xl">
<q-avatar size="88px" color="white" text-color="primary" icon="pets" />
<div class="text-h4 text-weight-bold text-white q-mt-md">산책갈개</div>
<div class="text-white q-mt-xs" style="opacity: 0.9">
우리 성향에 맞는 동네 산책 메이트
</div>
</div>
<q-card flat class="login-card q-pa-lg">
<!-- 구글 로그인 버튼(GIS 렌더링 영역) -->
<div ref="googleBtn" class="flex flex-center q-mb-md"></div>
<div v-if="googleError" class="text-caption text-negative text-center q-mb-md">
{{ googleError }}
</div>
<!-- 애플 로그인 -->
<q-btn
class="full-width q-py-sm"
color="black"
text-color="white"
unelevated
icon="mdi-apple"
label="Apple로 계속하기"
:loading="appleLoading"
@click="onApple"
/>
<!-- 개발용 로그인 (dev 빌드 전용) -->
<template v-if="isDev">
<q-separator class="q-my-md" />
<q-btn
class="full-width"
color="secondary"
text-color="accent"
flat
icon="build"
label="개발용 로그인 (local)"
:loading="devLoading"
@click="onDevLogin"
/>
</template>
</q-card>
<div class="text-caption text-white q-mt-lg" style="opacity: 0.8">
계속 진행하면 이용약관 개인정보 처리방침에 동의하게 됩니다.
</div>
</q-page>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/auth'
import { ApiError } from '@/api/http'
const router = useRouter()
const route = useRoute()
const $q = useQuasar()
const auth = useAuthStore()
const googleBtn = ref<HTMLElement | null>(null)
const googleError = ref('')
const appleLoading = ref(false)
const devLoading = ref(false)
const isDev = import.meta.env.DEV
onMounted(initGoogle)
async function initGoogle() {
try {
const { clientId } = await authApi.googleClientId()
if (!clientId) {
googleError.value = '구글 로그인이 아직 설정되지 않았습니다.'
return
}
await loadGsi()
const google = (window as unknown as { google: any }).google
google.accounts.id.initialize({
client_id: clientId,
callback: async (res: { credential: string }) => {
try {
await auth.loginGoogle(res.credential)
redirectAfterLogin()
} catch (e) {
notifyError(e)
}
},
})
if (googleBtn.value) {
google.accounts.id.renderButton(googleBtn.value, {
theme: 'outline',
size: 'large',
width: 260,
text: 'continue_with',
locale: 'ko',
})
}
} catch {
googleError.value = '구글 로그인 초기화에 실패했습니다. (서버 연결 확인)'
}
}
function loadGsi(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as unknown as { google?: unknown }).google) return resolve()
const s = document.createElement('script')
s.src = 'https://accounts.google.com/gsi/client'
s.async = true
s.onload = () => resolve()
s.onerror = () => reject(new Error('GSI load failed'))
document.head.appendChild(s)
})
}
async function onApple() {
// 네이티브(iOS)에서는 Capacitor Sign in with Apple 플러그인으로 identityToken 을 받아
// auth.loginApple(identityToken, name) 을 호출하도록 연동 예정.
appleLoading.value = true
$q.notify({
color: 'grey-8',
message: '애플 로그인은 네이티브(iOS) 플러그인 연동이 필요합니다.',
icon: 'mdi-apple',
position: 'top',
})
appleLoading.value = false
}
async function onDevLogin() {
devLoading.value = true
try {
const token = import.meta.env.VITE_DEV_LOGIN_TOKEN || 'dev-local-token'
await auth.devLogin(token)
redirectAfterLogin()
} catch (e) {
notifyError(e)
} finally {
devLoading.value = false
}
}
function redirectAfterLogin() {
const redirect = (route.query.redirect as string) || '/'
router.replace(redirect)
}
function notifyError(e: unknown) {
const message = e instanceof ApiError ? e.message : '로그인에 실패했습니다.'
$q.notify({ color: 'negative', message, icon: 'error', position: 'top' })
}
</script>
<style scoped>
.bg-brand {
background: linear-gradient(160deg, #6db3e8 0%, #3e92cc 100%);
min-height: 100vh;
}
.login-card {
width: 100%;
max-width: 320px;
border-radius: 16px;
}
</style>
+18
View File
@@ -1,10 +1,17 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue' import MainLayout from '@/layouts/MainLayout.vue'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/pages/LoginPage.vue'),
},
{ {
path: '/', path: '/',
component: MainLayout, component: MainLayout,
meta: { requiresAuth: true },
children: [ children: [
{ path: '', name: 'home', component: () => import('@/pages/HomePage.vue') }, { path: '', name: 'home', component: () => import('@/pages/HomePage.vue') },
{ path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') }, { path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') },
@@ -17,4 +24,15 @@ const router = createRouter({
routes, routes,
}) })
// 소셜 로그인 전용 앱: 미로그인 시 보호 경로 접근 → 로그인으로
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'home' }
}
})
export default router export default router
+61
View File
@@ -0,0 +1,61 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { authApi, type LoginResponse, type Member } from '@/api/auth'
import { tokenStore } from '@/lib/token'
import { useSubscriptionStore } from '@/stores/subscription'
export const useAuthStore = defineStore('auth', () => {
const member = ref<Member | null>(null)
const isAuthenticated = computed(() => member.value !== null)
/** 앱 시작 시 저장된 토큰으로 세션 복구. 실패 시 토큰 폐기. */
async function init(): Promise<void> {
if (!tokenStore.get()) return
try {
member.value = await authApi.me()
syncTier()
} catch {
tokenStore.clear()
member.value = null
}
}
function apply(res: LoginResponse): void {
tokenStore.set(res.token)
member.value = res.member
syncTier()
}
async function loginGoogle(idToken: string, rememberMe = true): Promise<void> {
apply(await authApi.google(idToken, rememberMe))
}
async function loginApple(identityToken: string, name: string | null, rememberMe = true): Promise<void> {
apply(await authApi.apple(identityToken, name, rememberMe))
}
/** 개발용: 시드된 세션 토큰으로 바로 로그인 (local 환경 검증용). */
async function devLogin(token: string): Promise<void> {
tokenStore.set(token)
member.value = await authApi.me()
syncTier()
}
async function logout(): Promise<void> {
try {
await authApi.logout()
} finally {
tokenStore.clear()
member.value = null
}
}
/** 회원의 실제 구독 티어를 광고/매칭 제어 스토어에 반영. */
function syncTier(): void {
if (member.value) {
useSubscriptionStore().setTier(member.value.subscriptionTier)
}
}
return { member, isAuthenticated, init, apply, loginGoogle, loginApple, devLogin, logout }
})