feat(OAuth2): 完成Oauth2服务器及测试客户端的基本编写
This commit is contained in:
parent
4f774893fa
commit
5c2c135940
23
src/api/oauth.ts
Normal file
23
src/api/oauth.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import request from '@/axios'
|
||||
|
||||
export const oauthApi = {
|
||||
clients(params: Record<string, unknown> = {}) {
|
||||
return request.get('/oauth/clients', { params })
|
||||
},
|
||||
createClient(data: Record<string, unknown>) {
|
||||
return request.post('/oauth/clients', data)
|
||||
},
|
||||
updateClient(id: number, data: Record<string, unknown>) {
|
||||
return request.put(`/oauth/clients/${id}`, data)
|
||||
},
|
||||
removeClient(id: number) {
|
||||
return request.delete(`/oauth/clients/${id}`)
|
||||
},
|
||||
resetClientSecret(id: number) {
|
||||
return request.post(`/oauth/clients/${id}/reset-secret`)
|
||||
},
|
||||
|
||||
authorizeDecision(data: Record<string, unknown>) {
|
||||
return request.post('/oauth/authorize/decision', data)
|
||||
},
|
||||
}
|
||||
@ -19,7 +19,9 @@ const service: AxiosInstance = axios.create({
|
||||
|
||||
service.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
const requestUrl = String(config.url || '')
|
||||
const isLoginRequest = requestUrl.endsWith('/auth/login') || requestUrl === '/auth/login'
|
||||
if (token && !isLoginRequest) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
|
||||
@ -2,15 +2,16 @@ import { useCookies } from "@vueuse/integrations/useCookies"
|
||||
|
||||
const TOKEN_KEY = "token"
|
||||
const cookie = useCookies()
|
||||
const TOKEN_OPTIONS = { path: "/" }
|
||||
|
||||
export function getToken(): string | undefined {
|
||||
return cookie.get(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
cookie.set(TOKEN_KEY, token)
|
||||
cookie.set(TOKEN_KEY, token, TOKEN_OPTIONS)
|
||||
}
|
||||
|
||||
export function removeToken(): void {
|
||||
cookie.remove(TOKEN_KEY)
|
||||
}
|
||||
cookie.remove(TOKEN_KEY, TOKEN_OPTIONS)
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
<el-menu-item v-if='hasPermission("platform.servers.view") || hasPermission("resource.servers.use")' index='/servers'><el-icon><Monitor /></el-icon>服务器资源</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.accounts.view")' index='/accounts'><el-icon><Key /></el-icon>堡垒机账号</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.logs.view")' index='/logs'><el-icon><Document /></el-icon>访问日志</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.oauth_clients.view")' index='/oauth-clients'><el-icon><Key /></el-icon>OAuth 客户端</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main class='main'>
|
||||
|
||||
@ -76,13 +76,14 @@
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { getToken } from '@/composables/token'
|
||||
import { removeToken, setToken } from '@/composables/token'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
@ -157,6 +158,11 @@ async function handleLogin(): Promise<void> {
|
||||
return
|
||||
}
|
||||
ElMessage.success('登录成功')
|
||||
const returnTo = resolveReturnTo()
|
||||
if (returnTo) {
|
||||
redirectToOAuthFlow(returnTo)
|
||||
return
|
||||
}
|
||||
await router.replace('/')
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
@ -247,12 +253,59 @@ onMounted(async () => {
|
||||
forcePasswordDialogVisible.value = true
|
||||
return
|
||||
} else {
|
||||
const returnTo = resolveReturnTo()
|
||||
if (returnTo) {
|
||||
redirectToOAuthFlow(returnTo)
|
||||
return
|
||||
}
|
||||
await router.replace('/')
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore: invalid token will be handled by route guard
|
||||
}
|
||||
})
|
||||
|
||||
function redirectToOAuthFlow(returnTo: string): void {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
router.replace('/').catch(() => null)
|
||||
return
|
||||
}
|
||||
|
||||
let targetUrl = returnTo
|
||||
try {
|
||||
const url = new URL(returnTo, window.location.origin)
|
||||
url.searchParams.set('access_token', token)
|
||||
targetUrl = url.toString()
|
||||
} catch (_error) {
|
||||
// keep original value
|
||||
}
|
||||
|
||||
window.location.href = targetUrl
|
||||
}
|
||||
|
||||
function resolveReturnTo(): string {
|
||||
const fromRoute = typeof route.query.return_to === 'string' ? route.query.return_to : ''
|
||||
if (fromRoute) {
|
||||
return fromRoute
|
||||
}
|
||||
|
||||
const fromSearch = new URLSearchParams(window.location.search).get('return_to') || ''
|
||||
if (fromSearch) {
|
||||
return fromSearch
|
||||
}
|
||||
|
||||
const hash = window.location.hash || ''
|
||||
const queryIndex = hash.indexOf('?')
|
||||
if (queryIndex >= 0) {
|
||||
const fromHash = new URLSearchParams(hash.slice(queryIndex + 1)).get('return_to') || ''
|
||||
if (fromHash) {
|
||||
return fromHash
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
429
src/pages/OauthClientsPage.vue
Normal file
429
src/pages/OauthClientsPage.vue
Normal file
@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-700'>OAuth 客户端管理</span>
|
||||
<el-button v-if='hasPermission("platform.oauth_clients.manage")' type='primary' @click='openCreate'>新增客户端</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data='rows' v-loading='loading'>
|
||||
<el-table-column prop='id' label='ID' width='80' 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 label='Scopes' min-width='220'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8'>
|
||||
<el-tag v-for='scope in row.scopes || []' :key='`scope-${row.id}-${scope.id}`'>{{ scope.name }}</el-tag>
|
||||
<span v-if='!(row.scopes || []).length' class='text-gray-400'>无</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='回调域名' min-width='220'>
|
||||
<template #default='{ row }'>
|
||||
<div>{{ formatDomains(row.redirect_uris || []) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop='is_active' label='状态' width='100' sortable>
|
||||
<template #default='{ row }'>
|
||||
<el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if='hasPermission("platform.oauth_clients.manage")' label='操作' min-width='260'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' @click='openEdit(row)'>编辑</el-button>
|
||||
<el-button size='small' type='warning' @click='resetSecret(row)'>重置密钥</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class='mt-4'
|
||||
layout='total, sizes, prev, pager, next'
|
||||
:total='total'
|
||||
:current-page='page'
|
||||
:page-size='perPage'
|
||||
:page-sizes='[10, 20, 50, 100]'
|
||||
@current-change='handlePageChange'
|
||||
@size-change='handleSizeChange'
|
||||
/>
|
||||
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑 OAuth 客户端" : "新增 OAuth 客户端"' width='760px'>
|
||||
<el-form :model='form' label-width='120px'>
|
||||
<el-form-item label='客户端名称'>
|
||||
<el-input v-model='form.name' />
|
||||
</el-form-item>
|
||||
<el-form-item label='Logo URL'>
|
||||
<el-input v-model='form.logo_url' placeholder='可选' />
|
||||
</el-form-item>
|
||||
<el-form-item label='回调地址'>
|
||||
<el-input
|
||||
v-model='form.redirect_uris_text'
|
||||
type='textarea'
|
||||
:rows='4'
|
||||
placeholder='每行一个 URI,例如 https://app.example.com/callback'
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label='UserInfo 字段'>
|
||||
<el-select v-model='form.allowed_userinfo_fields' multiple filterable allow-create default-first-option class='w-full'>
|
||||
<el-option v-for='field in userinfoFieldOptions' :key='field' :label='field' :value='field' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label='高级设置'>
|
||||
<div class='w-full'>
|
||||
<el-collapse>
|
||||
<el-collapse-item title='字段别名映射(兼容客户端字段名)' name='claim-alias'>
|
||||
<div class='claim-remap-panel'>
|
||||
<div class='claim-remap-tip'>将平台字段映射为客户端需要的字段名,例如 <code>nickname -> username</code>。</div>
|
||||
<div v-if='!form.allowed_userinfo_fields.length' class='claim-remap-empty'>
|
||||
请先在上方选择至少一个 UserInfo 字段。
|
||||
</div>
|
||||
<div v-for='(pair, index) in form.remap_pairs' :key='`remap-${index}`' class='claim-remap-row'>
|
||||
<el-select
|
||||
v-model='pair.from'
|
||||
placeholder='选择原字段'
|
||||
class='claim-remap-from'
|
||||
>
|
||||
<el-option
|
||||
v-for='field in availableFromFields(pair.from)'
|
||||
:key='`from-${index}-${field}`'
|
||||
:label='field'
|
||||
:value='field'
|
||||
/>
|
||||
</el-select>
|
||||
<span class='claim-remap-arrow'>→</span>
|
||||
<el-input v-model='pair.to' placeholder='客户端字段名(如 username)' class='claim-remap-to' />
|
||||
<el-button type='danger' plain class='claim-remap-remove' @click='removeRemapPair(index)'>删除</el-button>
|
||||
</div>
|
||||
<el-button
|
||||
class='claim-remap-add'
|
||||
type='primary'
|
||||
plain
|
||||
:disabled='cannotAddRemap'
|
||||
@click='addRemapPair'
|
||||
>
|
||||
添加映射
|
||||
</el-button>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label='启用'>
|
||||
<el-switch v-model='form.is_active' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='dialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='saving' @click='submit'>提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model='secretDialogVisible' title='客户端密钥' width='560px'>
|
||||
<div class='mb-2'>请立即保存该密钥,关闭后将无法再次查看。</div>
|
||||
<el-input :model-value='latestClientId' readonly class='mb-2'>
|
||||
<template #prepend>Client ID</template>
|
||||
</el-input>
|
||||
<el-input :model-value='latestSecret' readonly>
|
||||
<template #prepend>Client Secret</template>
|
||||
</el-input>
|
||||
<template #footer>
|
||||
<el-button @click='copySecret'>复制密钥</el-button>
|
||||
<el-button type='primary' @click='secretDialogVisible = false'>关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { oauthApi } from '@/api/oauth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface RemapPair {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = authStore
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const secretDialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
const latestClientId = ref('')
|
||||
const latestSecret = ref('')
|
||||
const userinfoFieldOptions = ['sub', 'nickname', 'email', 'phone', 'username']
|
||||
|
||||
const form = reactive<any>({
|
||||
name: '',
|
||||
logo_url: '',
|
||||
redirect_uris_text: '',
|
||||
allowed_userinfo_fields: ['sub', 'nickname', 'email'],
|
||||
remap_pairs: [] as RemapPair[],
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const clientsResponse: any = await oauthApi.clients({ page: page.value, per_page: perPage.value })
|
||||
|
||||
rows.value = clientsResponse.data.data || []
|
||||
total.value = Number(clientsResponse.data.total || 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number): void {
|
||||
page.value = nextPage
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleSizeChange(nextSize: number): void {
|
||||
perPage.value = nextSize
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
logo_url: '',
|
||||
redirect_uris_text: '',
|
||||
allowed_userinfo_fields: ['sub', 'nickname', 'email'],
|
||||
remap_pairs: [],
|
||||
is_active: true,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any): void {
|
||||
editingId.value = row.id
|
||||
const remap = row.userinfo_claim_remap || {}
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
logo_url: row.logo_url || '',
|
||||
redirect_uris_text: (row.redirect_uris || []).join('\n'),
|
||||
allowed_userinfo_fields: row.allowed_userinfo_fields || ['sub'],
|
||||
remap_pairs: Object.entries(remap).map(([from, to]) => ({ from: String(from), to: String(to) })),
|
||||
is_active: Boolean(row.is_active),
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function parseRedirectUris(): string[] {
|
||||
return String(form.redirect_uris_text || '')
|
||||
.split('\n')
|
||||
.map((item: string) => item.trim())
|
||||
.filter((item: string) => item.length > 0)
|
||||
}
|
||||
|
||||
function buildRemapObject(): Record<string, string> {
|
||||
const remap: Record<string, string> = {}
|
||||
for (const pair of form.remap_pairs as RemapPair[]) {
|
||||
const from = String(pair.from || '').trim()
|
||||
const to = String(pair.to || '').trim()
|
||||
if (!from || !to) {
|
||||
continue
|
||||
}
|
||||
remap[from] = to
|
||||
}
|
||||
|
||||
return remap
|
||||
}
|
||||
|
||||
function addRemapPair(): void {
|
||||
const candidates = availableFromFields('')
|
||||
if (!candidates.length) {
|
||||
return
|
||||
}
|
||||
|
||||
;(form.remap_pairs as RemapPair[]).push({ from: candidates[0], to: '' })
|
||||
}
|
||||
|
||||
function removeRemapPair(index: number | string): void {
|
||||
const targetIndex = Number(index)
|
||||
if (!Number.isInteger(targetIndex) || targetIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
;(form.remap_pairs as RemapPair[]).splice(targetIndex, 1)
|
||||
}
|
||||
|
||||
function availableFromFields(currentFrom: string): string[] {
|
||||
const selectedFields = (form.allowed_userinfo_fields || []) as string[]
|
||||
const used = new Set(
|
||||
(form.remap_pairs as RemapPair[])
|
||||
.map((pair) => pair.from)
|
||||
.filter((from) => from && from !== currentFrom),
|
||||
)
|
||||
|
||||
return selectedFields.filter((field) => field === currentFrom || !used.has(field))
|
||||
}
|
||||
|
||||
const cannotAddRemap = computed(() => {
|
||||
const selectedFields = ((form.allowed_userinfo_fields || []) as string[]).filter((field) => field !== 'sub')
|
||||
if (!selectedFields.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
const used = new Set(
|
||||
(form.remap_pairs as RemapPair[])
|
||||
.map((pair) => pair.from)
|
||||
.filter((from) => from && from !== 'sub'),
|
||||
)
|
||||
|
||||
return used.size >= selectedFields.length
|
||||
})
|
||||
|
||||
watch(
|
||||
() => form.allowed_userinfo_fields,
|
||||
(nextFields) => {
|
||||
const allowed = new Set((nextFields || []) as string[])
|
||||
form.remap_pairs = (form.remap_pairs as RemapPair[])
|
||||
.filter((pair) => pair.from && allowed.has(pair.from))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
logo_url: form.logo_url || null,
|
||||
redirect_uris: parseRedirectUris(),
|
||||
allowed_userinfo_fields: form.allowed_userinfo_fields || [],
|
||||
userinfo_claim_remap: buildRemapObject(),
|
||||
is_active: Boolean(form.is_active),
|
||||
}
|
||||
|
||||
if (editingId.value) {
|
||||
await oauthApi.updateClient(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
const response: any = await oauthApi.createClient(payload)
|
||||
latestClientId.value = String(response.data.client?.client_id || '')
|
||||
latestSecret.value = String(response.data.client_secret || '')
|
||||
secretDialogVisible.value = latestSecret.value !== ''
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '提交失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除客户端 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
await oauthApi.removeClient(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
async function resetSecret(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认重置客户端 ${row.name} 的密钥吗?`, '提示', { type: 'warning' })
|
||||
const response: any = await oauthApi.resetClientSecret(row.id)
|
||||
latestClientId.value = String(response.data.client_id || '')
|
||||
latestSecret.value = String(response.data.client_secret || '')
|
||||
secretDialogVisible.value = latestSecret.value !== ''
|
||||
ElMessage.success('密钥已重置')
|
||||
}
|
||||
|
||||
async function copySecret(): Promise<void> {
|
||||
if (!latestSecret.value) {
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(latestSecret.value)
|
||||
ElMessage.success('已复制')
|
||||
}
|
||||
|
||||
function formatDomains(redirectUris: string[]): string {
|
||||
const domains = redirectUris
|
||||
.map((uri) => {
|
||||
try {
|
||||
return new URL(uri).host
|
||||
} catch (_error) {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.filter((host) => host.length > 0)
|
||||
|
||||
return [...new Set(domains)].join(',')
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.claim-remap-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.claim-remap-tip {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.claim-remap-empty {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.claim-remap-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 220px) 28px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.claim-remap-from,
|
||||
.claim-remap-to {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.claim-remap-arrow {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.claim-remap-remove {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.claim-remap-add {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.claim-remap-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.claim-remap-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
170
src/pages/OauthConsentPage.vue
Normal file
170
src/pages/OauthConsentPage.vue
Normal file
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class='oauth-consent-page'>
|
||||
<el-card class='oauth-consent-card'>
|
||||
<div class='header'>
|
||||
<img v-if='clientLogo' :src='clientLogo' class='logo' alt='client logo' />
|
||||
<div>
|
||||
<h2 class='title'>授权确认</h2>
|
||||
<p class='subtitle'>{{ clientName || '第三方应用' }} 请求访问你的账号信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if='errorMessage' :title='errorMessage' type='error' show-icon :closable='false' class='mb-3' />
|
||||
|
||||
<div class='section'>
|
||||
<div class='label'>请求 Scope</div>
|
||||
<div class='value'>
|
||||
<el-tag v-for='scope in scopes' :key='scope' class='mr-2 mb-2'>{{ scope }}</el-tag>
|
||||
<span v-if='!scopes.length' class='text-gray-500'>无</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='section'>
|
||||
<div class='label'>可能返回的用户字段</div>
|
||||
<div class='value'>
|
||||
<el-tag v-for='item in scopeMeta' :key='item.name' type='info' class='mr-2 mb-2'>
|
||||
{{ item.display_name || item.name }}
|
||||
</el-tag>
|
||||
<span v-if='!scopeMeta.length' class='text-gray-500'>仅返回标准 subject 标识</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='actions btn-gap-8 btn-gap-8--end'>
|
||||
<el-button @click='submitDecision(false)'>拒绝</el-button>
|
||||
<el-button type='primary' @click='submitDecision(true)'>同意并继续</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getToken } from '@/composables/token'
|
||||
|
||||
interface ScopeMetaItem {
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const errorMessage = ref('')
|
||||
|
||||
const clientName = computed(() => String(route.query.client_name || ''))
|
||||
const clientLogo = computed(() => String(route.query.client_logo || ''))
|
||||
const clientId = computed(() => String(route.query.client_id || ''))
|
||||
const redirectUri = computed(() => String(route.query.redirect_uri || ''))
|
||||
const scopeString = computed(() => String(route.query.scope || ''))
|
||||
const state = computed(() => String(route.query.state || ''))
|
||||
const nonce = computed(() => String(route.query.nonce || ''))
|
||||
const scopes = computed(() => scopeString.value.split(/\s+/).filter((item) => item.length > 0))
|
||||
const scopeMeta = computed<ScopeMetaItem[]>(() => {
|
||||
const encoded = String(route.query.scope_meta || '')
|
||||
if (!encoded) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(atob(encoded))
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch (_error) {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
function resolveApiBase(): string {
|
||||
if (import.meta.env.VITE_STATE === 'dev') {
|
||||
return String(import.meta.env.VITE_DEV_API_URL || 'api')
|
||||
}
|
||||
|
||||
return String(import.meta.env.VITE_PROD_API_URL || '/')
|
||||
}
|
||||
|
||||
function submitDecision(approve: boolean): void {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
errorMessage.value = '登录已过期,请重新登录后再授权。'
|
||||
return
|
||||
}
|
||||
if (!clientId.value || !redirectUri.value) {
|
||||
errorMessage.value = '授权参数缺失,无法继续。'
|
||||
return
|
||||
}
|
||||
|
||||
const actionBase = resolveApiBase().replace(/\/+$/, '')
|
||||
const action = `${actionBase}/oauth/authorize/decision`
|
||||
const form = document.createElement('form')
|
||||
form.method = 'POST'
|
||||
form.action = action
|
||||
|
||||
const payload: Record<string, string> = {
|
||||
approve: approve ? '1' : '0',
|
||||
client_id: clientId.value,
|
||||
redirect_uri: redirectUri.value,
|
||||
scope: scopeString.value,
|
||||
state: state.value,
|
||||
nonce: nonce.value,
|
||||
access_token: token,
|
||||
}
|
||||
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = key
|
||||
input.value = value
|
||||
form.appendChild(input)
|
||||
})
|
||||
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oauth-consent-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px 16px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.oauth-consent-card {
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.logo {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.value {
|
||||
min-height: 28px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -10,6 +10,10 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/oauth-consent',
|
||||
component: () => import('@/pages/OauthConsentPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
@ -22,6 +26,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'servers', component: () => import('@/pages/ServersPage.vue') },
|
||||
{ path: 'accounts', component: () => import('@/pages/AccountsPage.vue') },
|
||||
{ path: 'logs', component: () => import('@/pages/LogsPage.vue') },
|
||||
{ path: 'oauth-clients', component: () => import('@/pages/OauthClientsPage.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', component: () => import('@/pages/NotFoundPage.vue') },
|
||||
@ -62,6 +67,10 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
if (token && to.meta.guest) {
|
||||
const returnTo = typeof to.query.return_to === 'string' ? to.query.return_to : ''
|
||||
if (to.path === '/login' && returnTo) {
|
||||
return true
|
||||
}
|
||||
const forcePasswordChange = Boolean((authStore.user as any)?.force_password_change)
|
||||
if (forcePasswordChange) {
|
||||
if (to.path === '/login') {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user