feat(OAuth2): 完成Oauth2服务器及测试客户端的基本编写

This commit is contained in:
Boen_Shi 2026-05-21 16:06:50 +08:00
parent 4f774893fa
commit 5c2c135940
8 changed files with 693 additions and 5 deletions

23
src/api/oauth.ts Normal file
View 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)
},
}

View File

@ -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}`
}

View File

@ -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)
}

View File

@ -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'>

View File

@ -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>

View 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>

View 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>

View File

@ -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') {