feat(OAuth2): 完成Oauth2 BUG的修复

This commit is contained in:
Boen_Shi 2026-05-21 16:43:41 +08:00
parent 5c2c135940
commit 5ec6df4af8
3 changed files with 151 additions and 44 deletions

View File

@ -11,11 +11,11 @@
<el-table-column prop='id' label='ID' width='80' sortable /> <el-table-column prop='id' label='ID' width='80' sortable />
<el-table-column prop='name' label='名称' min-width='160' sortable /> <el-table-column prop='name' label='名称' min-width='160' sortable />
<el-table-column prop='client_id' label='Client ID' min-width='240' /> <el-table-column prop='client_id' label='Client ID' min-width='240' />
<el-table-column label='Scopes' min-width='220'> <el-table-column label='UserInfo 字段' min-width='220'>
<template #default='{ row }'> <template #default='{ row }'>
<div class='btn-gap-8'> <div class='btn-gap-8'>
<el-tag v-for='scope in row.scopes || []' :key='`scope-${row.id}-${scope.id}`'>{{ scope.name }}</el-tag> <el-tag v-for='field in row.allowed_userinfo_fields || []' :key='`userinfo-${row.id}-${field}`' type='info'>{{ field }}</el-tag>
<span v-if='!(row.scopes || []).length' class='text-gray-400'></span> <span v-if='!(row.allowed_userinfo_fields || []).length' class='text-gray-400'></span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>

View File

@ -1,73 +1,93 @@
<template> <template>
<div class='oauth-consent-page'> <div class='oauth-consent-page'>
<el-card class='oauth-consent-card'> <el-card class='oauth-consent-card' shadow='never'>
<div class='brand-line'>OAuth2 安全授权</div>
<div class='header'> <div class='header'>
<img v-if='clientLogo' :src='clientLogo' class='logo' alt='client logo' /> <img v-if='clientLogo' :src='clientLogo' class='logo' alt='client logo' />
<div> <div class='header-copy'>
<h2 class='title'>授权确认</h2> <h2 class='title'>授权 {{ clientName || '第三方应用' }}</h2>
<p class='subtitle'>{{ clientName || '第三方应用' }} 请求访问你的账号信息</p> <p class='subtitle'>该应用将按服务端配置读取你的用户信息</p>
</div> </div>
</div> </div>
<el-alert v-if='errorMessage' :title='errorMessage' type='error' show-icon :closable='false' class='mb-3' /> <el-alert v-if='errorMessage' :title='errorMessage' type='error' show-icon :closable='false' class='mb-3' />
<div class='section'> <div class='section'>
<div class='label'>请求 Scope</div> <div class='label'>将返回给应用的 UserInfo 字段</div>
<div class='value'> <div class='value'>
<el-tag v-for='scope in scopes' :key='scope' class='mr-2 mb-2'>{{ scope }}</el-tag> <el-tag v-for='field in userinfoFields' :key='field' effect='plain' type='info' class='mr-2 mb-2'>{{ field }}</el-tag>
<span v-if='!scopes.length' class='text-gray-500'></span> <span v-if='!userinfoFields.length' class='text-gray-500'>仅返回 `sub`</span>
</div> </div>
</div> </div>
<div class='section'> <div v-if='remapPairs.length' class='section'>
<div class='label'>可能返回的用户字段</div> <div class='label'>字段映射规则</div>
<div class='value'> <div class='value remap-grid'>
<el-tag v-for='item in scopeMeta' :key='item.name' type='info' class='mr-2 mb-2'> <div v-for='item in remapPairs' :key='`${item.from}-${item.to}`' class='remap-item'>
{{ item.display_name || item.name }} <span class='from'>{{ item.from }}</span>
</el-tag> <span class='arrow'></span>
<span v-if='!scopeMeta.length' class='text-gray-500'>仅返回标准 subject 标识</span> <span class='to'>{{ item.to }}</span>
</div>
</div> </div>
</div> </div>
<div class='actions btn-gap-8 btn-gap-8--end'> <div class='actions btn-gap-8 btn-gap-8--end'>
<el-button @click='submitDecision(false)'>拒绝</el-button> <el-button class='reject-btn' @click='submitDecision(false)'>拒绝</el-button>
<el-button type='primary' @click='submitDecision(true)'>同意并继续</el-button> <el-button type='primary' class='approve-btn' @click='submitDecision(true)'>同意并继续</el-button>
</div> </div>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang='ts'> <script setup lang='ts'>
import { computed, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getToken } from '@/composables/token' import { getToken } from '@/composables/token'
interface ScopeMetaItem { interface RemapPair {
name: string from: string
display_name: string to: string
description?: string
} }
const route = useRoute() const route = useRoute()
const router = useRouter()
const errorMessage = ref('') const errorMessage = ref('')
const clientName = computed(() => String(route.query.client_name || '')) const clientName = computed(() => String(route.query.client_name || ''))
const clientLogo = computed(() => String(route.query.client_logo || '')) const clientLogo = computed(() => String(route.query.client_logo || ''))
const clientId = computed(() => String(route.query.client_id || '')) const clientId = computed(() => String(route.query.client_id || ''))
const redirectUri = computed(() => String(route.query.redirect_uri || '')) const redirectUri = computed(() => String(route.query.redirect_uri || ''))
const scopeString = computed(() => String(route.query.scope || '')) const returnTo = computed(() => String(route.query.return_to || ''))
const state = computed(() => String(route.query.state || '')) const state = computed(() => String(route.query.state || ''))
const nonce = computed(() => String(route.query.nonce || '')) const nonce = computed(() => String(route.query.nonce || ''))
const scopes = computed(() => scopeString.value.split(/\s+/).filter((item) => item.length > 0)) const userinfoFields = computed<string[]>(() => {
const scopeMeta = computed<ScopeMetaItem[]>(() => { const encoded = String(route.query.userinfo_fields || '')
const encoded = String(route.query.scope_meta || '')
if (!encoded) { if (!encoded) {
return [] return []
} }
try { try {
const parsed = JSON.parse(atob(encoded)) const parsed = JSON.parse(atob(encoded))
return Array.isArray(parsed) ? parsed : [] return Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter((item) => item.length > 0) : []
} catch (_error) {
return []
}
})
const remapPairs = computed<RemapPair[]>(() => {
const encoded = String(route.query.userinfo_remap || '')
if (!encoded) {
return []
}
try {
const parsed = JSON.parse(atob(encoded))
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return []
}
return Object.entries(parsed)
.map(([from, to]) => ({ from: String(from || '').trim(), to: String(to || '').trim() }))
.filter((item) => item.from.length > 0 && item.to.length > 0)
} catch (_error) { } catch (_error) {
return [] return []
} }
@ -84,7 +104,11 @@ function resolveApiBase(): string {
function submitDecision(approve: boolean): void { function submitDecision(approve: boolean): void {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
errorMessage.value = '登录已过期,请重新登录后再授权。' if (returnTo.value) {
window.location.href = returnTo.value
return
}
errorMessage.value = '登录已过期,请从授权端点重新发起授权。'
return return
} }
if (!clientId.value || !redirectUri.value) { if (!clientId.value || !redirectUri.value) {
@ -102,7 +126,6 @@ function submitDecision(approve: boolean): void {
approve: approve ? '1' : '0', approve: approve ? '1' : '0',
client_id: clientId.value, client_id: clientId.value,
redirect_uri: redirectUri.value, redirect_uri: redirectUri.value,
scope: scopeString.value,
state: state.value, state: state.value,
nonce: nonce.value, nonce: nonce.value,
access_token: token, access_token: token,
@ -119,6 +142,26 @@ function submitDecision(approve: boolean): void {
document.body.appendChild(form) document.body.appendChild(form)
form.submit() form.submit()
} }
onMounted(() => {
const token = getToken()
if (token) {
return
}
const returnToValue = returnTo.value
if (!returnToValue) {
errorMessage.value = '缺少授权上下文,请从 OAuth 授权端点重新发起。'
return
}
router.replace({
path: '/login',
query: {
return_to: returnToValue,
},
}).catch(() => null)
})
</script> </script>
<style scoped> <style scoped>
@ -131,40 +174,100 @@ function submitDecision(approve: boolean): void {
background: #f1f5f9; background: #f1f5f9;
} }
.oauth-consent-card { .oauth-consent-card {
width: min(760px, 100%); width: min(620px, 100%);
border: 1px solid #dbeafe;
border-radius: 16px;
}
.brand-line {
margin-bottom: 12px;
color: #0284c7;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 700;
} }
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
margin-bottom: 18px; margin-bottom: 22px;
}
.header-copy {
min-width: 0;
} }
.logo { .logo {
width: 46px; width: 54px;
height: 46px; height: 54px;
border-radius: 8px; border-radius: 12px;
object-fit: cover; object-fit: cover;
border: 1px solid #e2e8f0; border: 1px solid #bae6fd;
box-shadow: 0 8px 20px rgba(3, 105, 161, 0.18);
} }
.title { .title {
margin: 0 0 4px; margin: 0 0 6px;
font-size: 22px; font-size: 24px;
color: #0f172a;
word-break: break-word;
} }
.subtitle { .subtitle {
margin: 0; margin: 0;
color: #64748b; color: #475569;
} }
.section { .section {
margin-bottom: 14px; margin-bottom: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: rgba(255, 255, 255, 0.75);
padding: 12px;
} }
.label { .label {
font-weight: 600; font-weight: 600;
margin-bottom: 8px; color: #0f172a;
margin-bottom: 10px;
} }
.value { .value {
min-height: 28px; min-height: 28px;
} }
.remap-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.remap-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
background: #ecfeff;
border: 1px solid #bae6fd;
color: #0f172a;
font-size: 13px;
}
.arrow {
color: #0ea5e9;
}
.actions { .actions {
margin-top: 8px; margin-top: 12px;
}
.reject-btn {
border-color: #cbd5e1;
}
.approve-btn {
min-width: 136px;
}
:deep(.el-card__body) {
padding: 24px;
}
@media (max-width: 640px) {
.oauth-consent-page {
padding: 20px 12px;
}
:deep(.el-card__body) {
padding: 18px;
}
.title {
font-size: 20px;
}
} }
</style> </style>

View File

@ -13,6 +13,7 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/oauth-consent', path: '/oauth-consent',
component: () => import('@/pages/OauthConsentPage.vue'), component: () => import('@/pages/OauthConsentPage.vue'),
meta: { guest: true },
}, },
{ {
path: '/', path: '/',
@ -71,6 +72,9 @@ router.beforeEach(async (to) => {
if (to.path === '/login' && returnTo) { if (to.path === '/login' && returnTo) {
return true return true
} }
if (to.path === '/oauth-consent') {
return true
}
const forcePasswordChange = Boolean((authStore.user as any)?.force_password_change) const forcePasswordChange = Boolean((authStore.user as any)?.force_password_change)
if (forcePasswordChange) { if (forcePasswordChange) {
if (to.path === '/login') { if (to.path === '/login') {