feat: 구글 로그인/가입 — 웹(GIS)·앱(네이티브 Credential Manager)
CI / build (push) Failing after 13m30s

- LoginModal: 서버에서 구글 클라이언트 ID 수신 시에만 버튼 노출
  - 웹/PC: Google Identity Services 렌더 버튼
  - 앱(안드로이드): @capawesome/capacitor-google-sign-in 네이티브 버튼
- 네이티브/웹 모두 ID 토큰을 /api/auth/google 로 전달(aud=웹 클라이언트 ID)
- auth 스토어/authApi 에 googleLogin·googleClientId 추가
- src/native/googleAuth.js 네이티브 래퍼 신규

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-27 18:02:19 +09:00
parent 1ca09b7439
commit a310c7a471
8 changed files with 235 additions and 2 deletions
+1
View File
@@ -12,6 +12,7 @@ dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-camera') implementation project(':capacitor-camera')
implementation project(':capacitor-preferences') implementation project(':capacitor-preferences')
implementation project(':capawesome-capacitor-google-sign-in')
} }
+3
View File
@@ -10,3 +10,6 @@ project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/c
include ':capacitor-preferences' include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
include ':capawesome-capacitor-google-sign-in'
project(':capawesome-capacitor-google-sign-in').projectDir = new File('../node_modules/@capawesome/capacitor-google-sign-in/android')
+20
View File
@@ -14,6 +14,7 @@
"@capacitor/cli": "^8.3.4", "@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4", "@capacitor/core": "^8.3.4",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capawesome/capacitor-google-sign-in": "^0.1.2",
"@toast-ui/editor": "^3.2.2", "@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
"axios": "^1.16.1", "axios": "^1.16.1",
@@ -1195,6 +1196,25 @@
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }
}, },
"node_modules/@capawesome/capacitor-google-sign-in": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.2.tgz",
"integrity": "sha512-41Z5LBVZJQ/20lEbzQJUtjfm7rCUhiq0EsfKmytTfKjqUjQB4zy0zlDMowe/z5CtetL8u7WGvERtyRYil2exww==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/capawesome-team/"
},
{
"type": "opencollective",
"url": "https://opencollective.com/capawesome"
}
],
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+1
View File
@@ -46,6 +46,7 @@
"@capacitor/cli": "^8.3.4", "@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4", "@capacitor/core": "^8.3.4",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capawesome/capacitor-google-sign-in": "^0.1.2",
"@toast-ui/editor": "^3.2.2", "@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
"axios": "^1.16.1", "axios": "^1.16.1",
+7
View File
@@ -11,6 +11,13 @@ export const authApi = {
login(payload) { login(payload) {
return http.post('/auth/login', payload) return http.post('/auth/login', payload)
}, },
// 구글 로그인/가입 — GIS에서 받은 ID 토큰 전달
googleLogin(payload) {
return http.post('/auth/google', payload)
},
googleClientId() {
return http.get('/auth/google-client-id')
},
logout() { logout() {
return http.post('/auth/logout') return http.post('/auth/logout')
}, },
+170 -1
View File
@@ -1,8 +1,10 @@
<script setup> <script setup>
import { reactive, ref, watch, onMounted, onUnmounted } from 'vue' import { reactive, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { authApi } from '@/api/authApi'
import { isNativeGoogle, nativeGoogleIdToken } from '@/native/googleAuth'
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
@@ -20,6 +22,103 @@ const form = reactive({ loginId: '', password: '', rememberMe: true })
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
// ===== 구글 로그인 =====
// 웹/Electron: GIS(웹) 렌더 버튼. 앱(안드로이드): 네이티브 Credential Manager 버튼.
const isNative = isNativeGoogle()
const GIS_SRC = 'https://accounts.google.com/gsi/client'
const googleClientId = ref('') // 백엔드에서 받은 OAuth 웹 클라이언트 ID (없으면 버튼 숨김)
const googleBtn = ref(null) // 구글 버튼이 그려질 컨테이너(웹 GIS)
let gisScriptPromise = null // GIS 스크립트는 한 번만 로드
// ID 토큰으로 세션 발급 → 닫기/이동 (웹·네이티브 공통)
async function finishGoogleLogin(idToken) {
if (!idToken) return
error.value = null
loading.value = true
try {
await auth.googleLogin(idToken, form.rememberMe)
const redirect = ui.redirectPath
ui.closeLogin()
router.push(redirect || '/')
} catch (e) {
error.value = e.response?.data?.message || '구글 로그인에 실패했습니다.'
} finally {
loading.value = false
}
}
// 앱(안드로이드) 네이티브 구글 로그인 버튼 클릭
async function handleNativeGoogle() {
if (loading.value || !googleClientId.value) return
loading.value = true
try {
const idToken = await nativeGoogleIdToken(googleClientId.value)
loading.value = false
await finishGoogleLogin(idToken)
} catch (e) {
loading.value = false
// 사용자가 취소한 경우(메시지 없음)는 조용히 무시
const msg = e?.message || ''
if (msg && !/cancel/i.test(msg)) error.value = '구글 로그인에 실패했습니다.'
}
}
// GIS 스크립트 로드 (한 번만). 실패하면 reject.
function loadGisScript() {
if (window.google?.accounts?.id) return Promise.resolve()
if (gisScriptPromise) return gisScriptPromise
gisScriptPromise = new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = GIS_SRC
s.async = true
s.defer = true
s.onload = () => resolve()
s.onerror = () => {
gisScriptPromise = null
reject(new Error('GIS load failed'))
}
document.head.appendChild(s)
})
return gisScriptPromise
}
// 웹 GIS 자격증명(ID 토큰) 수신 → 공통 처리
function onGoogleCredential(response) {
finishGoogleLogin(response?.credential)
}
// 모달 열릴 때 구글 버튼 준비. 클라이언트 ID 미설정/스크립트 실패 시 조용히 숨김.
async function setupGoogle() {
try {
if (!googleClientId.value) {
const { clientId } = await authApi.googleClientId()
googleClientId.value = clientId || ''
}
if (!googleClientId.value) return // 서버에 미설정 → 구글 버튼 미노출
if (isNative) return // 앱: 네이티브 버튼(템플릿)으로 처리 — GIS 스크립트 불필요
await loadGisScript()
await nextTick()
if (!ui.loginOpen || !googleBtn.value) return
window.google.accounts.id.initialize({
client_id: googleClientId.value,
callback: onGoogleCredential,
})
googleBtn.value.innerHTML = ''
window.google.accounts.id.renderButton(googleBtn.value, {
type: 'standard',
theme: 'outline',
size: 'large',
shape: 'rectangular',
text: 'continue_with',
logo_alignment: 'center',
width: 300,
})
} catch {
// 구글 로그인 준비 실패 — 일반 로그인은 그대로 사용 가능하므로 버튼만 숨김
googleClientId.value = ''
}
}
// 팝업이 열릴 때마다 입력값 초기화 (로그인 상태 유지는 기본 켜둠) // 팝업이 열릴 때마다 입력값 초기화 (로그인 상태 유지는 기본 켜둠)
watch( watch(
() => ui.loginOpen, () => ui.loginOpen,
@@ -29,6 +128,7 @@ watch(
form.password = '' form.password = ''
form.rememberMe = true form.rememberMe = true
error.value = null error.value = null
setupGoogle()
} }
}, },
) )
@@ -85,6 +185,28 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<!-- 구글 로그인 서버에 클라이언트 ID 설정된 경우에만 노출 -->
<div v-show="googleClientId" class="google-wrap">
<div class="divider"><span>또는</span></div>
<!-- (안드로이드): 네이티브 버튼 / ·PC: GIS 렌더 버튼 -->
<button
v-if="isNative"
type="button"
class="google-native"
:disabled="loading"
@click="handleNativeGoogle"
>
<svg class="g-logo" viewBox="0 0 18 18" width="18" height="18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.97 10.72a5.41 5.41 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
</svg>
Google 계정으로 계속하기
</button>
<div v-else ref="googleBtn" class="google-btn"></div>
</div>
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin"> <button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
네이버로 로그인 (준비 ) 네이버로 로그인 (준비 )
</button> </button>
@@ -185,6 +307,53 @@ h2 {
border-color: #03c75a; border-color: #03c75a;
color: #03c75a; color: #03c75a;
} }
.google-wrap {
margin-top: 1rem;
}
.google-btn {
display: flex;
justify-content: center;
}
.google-native {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
width: 100%;
max-width: 300px;
margin: 0 auto;
padding: 0.6rem 1rem;
border: 1px solid #747775;
border-radius: 4px;
background: #fff;
color: #1f1f1f;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
}
.google-native:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.google-native .g-logo {
flex: none;
}
.divider {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0.75rem 0;
color: var(--color-text);
opacity: 0.55;
font-size: 0.8rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
.links { .links {
margin-top: 1.25rem; margin-top: 1.25rem;
text-align: center; text-align: center;
+23
View File
@@ -0,0 +1,23 @@
// 네이티브(안드로이드) 구글 로그인 래퍼 — Capacitor Credential Manager 기반.
// 웹/Electron 에서는 GIS(웹) 방식을 쓰므로 여기선 네이티브 전용. 플러그인은 지연 import.
import { Capacitor } from '@capacitor/core'
// 네이티브 플랫폼(안드로이드 APK)에서만 네이티브 구글 로그인을 사용한다.
export function isNativeGoogle() {
return Capacitor.isNativePlatform()
}
let initializedClientId = null
// 웹 클라이언트 ID(서버에서 받은 값)로 네이티브 구글 로그인을 수행하고 ID 토큰을 반환한다.
// 반환된 ID 토큰의 aud 는 웹 클라이언트 ID 라서 백엔드 /api/auth/google 가 그대로 검증한다.
export async function nativeGoogleIdToken(clientId) {
const { GoogleSignIn } = await import('@capawesome/capacitor-google-sign-in')
// initialize 는 한 번만(같은 clientId 면 재호출 생략)
if (initializedClientId !== clientId) {
await GoogleSignIn.initialize({ clientId })
initializedClientId = clientId
}
const res = await GoogleSignIn.signIn()
return res?.idToken || null
}
+10 -1
View File
@@ -63,6 +63,15 @@ export const useAuthStore = defineStore('auth', () => {
return res return res
} }
// 구글 로그인 — GIS ID 토큰으로 세션 발급
async function googleLogin(idToken, rememberMe = true) {
const res = await authApi.googleLogin({ idToken, rememberMe })
token.value = res.token
user.value = res.member
await persist()
return res
}
async function signup(payload) { async function signup(payload) {
return authApi.signup(payload) return authApi.signup(payload)
} }
@@ -96,7 +105,7 @@ export const useAuthStore = defineStore('auth', () => {
await persist() await persist()
} }
return { token, user, ready, isAuthenticated, restore, login, signup, fetchMe, applyUser, logout, clear } return { token, user, ready, isAuthenticated, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear }
}) })
function safeParse(value) { function safeParse(value) {