feat(OAuth2): 完成Oauth2 BUG的修复
This commit is contained in:
parent
5c2c135940
commit
5ec6df4af8
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user