115 lines
3.2 KiB
Vue
115 lines
3.2 KiB
Vue
<template>
|
||
<div class="flex items-center justify-center min-h-[70vh]">
|
||
<el-card class="w-full max-w-md shadow-md">
|
||
<template #header>
|
||
<div class="text-center">
|
||
<h1 class="text-xl font-bold text-gray-800">member.ose.tw</h1>
|
||
<p class="text-sm text-gray-500 mt-1">按下按鈕前往身分提供者登入</p>
|
||
</div>
|
||
</template>
|
||
|
||
<el-alert
|
||
v-if="error"
|
||
:title="error"
|
||
type="error"
|
||
show-icon
|
||
:closable="false"
|
||
class="mb-4"
|
||
/>
|
||
|
||
<el-button
|
||
type="primary"
|
||
class="w-full"
|
||
:loading="loginLoading"
|
||
@click="handleLogin"
|
||
>
|
||
前往登入
|
||
</el-button>
|
||
|
||
<div class="mt-4 text-xs text-gray-400 text-center space-y-1">
|
||
<p>登入會統一跳轉到身分提供者登入頁,完成後自動返回。</p>
|
||
<p>登入成功後 access token 會存於本機 localStorage。</p>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { getOidcAuthorizeUrl } from '@/api/auth'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
|
||
const loginLoading = ref(false)
|
||
const error = ref('')
|
||
|
||
function getPostLoginRedirect() {
|
||
const redirect = route.query.redirect || '/me'
|
||
return typeof redirect === 'string' ? redirect : '/me'
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (!authStore.isLoggedIn) return
|
||
try {
|
||
await authStore.fetchMe()
|
||
router.replace(getPostLoginRedirect())
|
||
} catch (_err) {
|
||
authStore.logout()
|
||
}
|
||
})
|
||
|
||
async function handleLogin() {
|
||
loginLoading.value = true
|
||
error.value = ''
|
||
try {
|
||
await redirectToOidc({
|
||
prompt: 'login'
|
||
})
|
||
} catch (err) {
|
||
error.value = err.message || '登入失敗,請稍後再試'
|
||
} finally {
|
||
loginLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function redirectToOidc(options = {}) {
|
||
const pkce = await generatePkcePair()
|
||
sessionStorage.setItem('oidc_pkce_verifier', pkce.codeVerifier)
|
||
sessionStorage.setItem('post_login_redirect', getPostLoginRedirect())
|
||
const callbackUrl = `${window.location.origin}/auth/callback`
|
||
const res = await getOidcAuthorizeUrl(callbackUrl, {
|
||
...options,
|
||
codeChallenge: pkce.codeChallenge,
|
||
codeChallengeMethod: 'S256'
|
||
})
|
||
const authorizeUrl = res.data.authorize_url
|
||
const parsed = new URL(authorizeUrl)
|
||
const state = parsed.searchParams.get('state')
|
||
if (state) {
|
||
sessionStorage.setItem('oidc_expected_state', state)
|
||
}
|
||
window.location.href = authorizeUrl
|
||
}
|
||
|
||
async function generatePkcePair() {
|
||
const randomBytes = new Uint8Array(32)
|
||
window.crypto.getRandomValues(randomBytes)
|
||
const codeVerifier = toBase64Url(randomBytes)
|
||
const digest = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
|
||
const codeChallenge = toBase64Url(new Uint8Array(digest))
|
||
return { codeVerifier, codeChallenge }
|
||
}
|
||
|
||
function toBase64Url(bytes) {
|
||
let binary = ''
|
||
for (let i = 0; i < bytes.length; i += 1) {
|
||
binary += String.fromCharCode(bytes[i])
|
||
}
|
||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||
}
|
||
</script>
|