feat(auth): 实现通行密钥注册与凭证管理功能

- 新增获取通行密钥凭证接口 credentials
- 修改注册接口 registration 支持传递名称参数
- 添加 base64ToArrayBuffer 工具函数并移至独立文件
- 新增通行密钥管理页面 Passkey.vue
- 实现通行密钥注册、命名及列表展示功能
- 添加菜单组件 Menu.vue 支持页面导航
- 优化登录流程,支持从 Cookie 恢复登录状态
- 处理 401 状态码时清除认证信息并刷新页面
- 调整 App.vue 样式层级并添加菜单触发逻辑
This commit is contained in:
Grand-cocoa 2025-11-11 18:34:06 +08:00
parent 846c8ce882
commit f5e33a64e7
9 changed files with 254 additions and 54 deletions

View File

@ -1,7 +1,7 @@
<template> <template>
<header :class="{ login: hideLoading }"> <header :class="{ login: hideLoading }">
<div class="login-box"> <div class="login-box">
<div class="logo"> <div class="logo" @click="handleMenu">
<img alt="Logo" :src="avatar" style="object-fit: cover"/> <img alt="Logo" :src="avatar" style="object-fit: cover"/>
</div> </div>
<div class="login-info"> <div class="login-info">
@ -38,14 +38,17 @@
</transition> </transition>
</RouterView> </RouterView>
</div> </div>
<Menu v-model:visible="menuVisible"/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import avatar from '@/assets/avater.jpg' import avatar from '@/assets/avater.jpg'
import LoginProcess from '@/components/login/LoginProcess.vue' import LoginProcess from '@/components/login/LoginProcess.vue'
import { RefreshIcon } from 'tdesign-icons-vue-next' import { RefreshIcon } from 'tdesign-icons-vue-next'
import Menu from '@/components/Menu.vue'
import { getCookie } from '@/utils/cookie.ts'
const login = ref(false) const login = ref(false)
const weCome = ref(false) const weCome = ref(false)
@ -55,41 +58,28 @@ const showTitle = ref(false)
const passkey = ref(true) const passkey = ref(true)
const name = ref('Amico') const name = ref('Amico')
const menuVisible = ref(false)
if (!navigator.credentials) { if (!navigator.credentials) {
passkey.value = false passkey.value = false
} }
function handleLogin() { function handleLogin() {
// navigator.credentials.create({
// publicKey: {
// challenge: new Uint8Array(32),
// timeout: 60000,
// rp: {
// id: window.location.host,
// name: "Animo"
// },
// user: {
// id: new Uint8Array(32),
// name: "Amico",
// displayName: "Amico",
// },
// pubKeyCredParams: [{
// alg: -7, type: "public-key"
// },{
// alg: -257, type: "public-key"
// }],
// excludeCredentials: [],
// authenticatorSelection: {
// authenticatorAttachment: "platform",
// requireResidentKey: true,
// }
// },
// }).then(resp => {
// login.value = true
// })
login.value = true login.value = true
} }
function handleMenu() {
if (weCome.value){
menuVisible.value = true
}
}
onMounted(() => {
if (getCookie('satoken')){
login.value = true
}
})
// //
watch(login, (val) => { watch(login, (val) => {
if (val) { if (val) {
@ -114,7 +104,7 @@ header {
width: 100%; width: 100%;
line-height: 1.5; line-height: 1.5;
max-height: 100vh; max-height: 100vh;
z-index: 99999; z-index: 10;
background: var(--td-mask-background); background: var(--td-mask-background);
box-shadow: var(--td-shadow-4); box-shadow: var(--td-shadow-4);
height: 100vh; height: 100vh;

View File

@ -15,17 +15,27 @@ export function register(data: any) {
}) })
} }
export function credentials(){
return request({
url: '/passkey/credentials',
method: 'get'
})
}
export function registration_options(){ export function registration_options(){
return request({ return request({
url: '/passkey/registration/options', url: '/passkey/registration/options',
method: 'get' method: 'get'
}) })
} }
export function registration(credential: any){ export function registration(credential: any, name: string){
return request({ return request({
url: '/passkey/registration', url: '/passkey/registration',
method: 'post', method: 'post',
data: credential data: credential,
params: {
name: name
}
}) })
} }
export function assertion_options(){ export function assertion_options(){

90
src/components/Menu.vue Normal file
View File

@ -0,0 +1,90 @@
<template>
<t-drawer
:visible="props.visible"
@update:visible="emits('update:visible', $event)"
title="菜单"
placement="left"
close-on-overlay-click
:close-btn="false"
:cancel-btn="false"
:confirm-btn="false"
>
<t-menu v-model="current" @change="handleRouteChange">
<t-menu-item value="home">
<template #icon>
<home-icon :fill-color="'transparent'" :stroke-color="'currentColor'" :stroke-width="2" />
</template>
首页
</t-menu-item>
<t-menu-item value="passkey">
<template #icon>
<user-password-icon
:fill-color="'transparent'"
:stroke-color="'currentColor'"
:stroke-width="2"
/>
</template>
通行密钥
</t-menu-item>
<t-menu-item value="about">
<template #icon>
<info-circle-icon
:fill-color="'transparent'"
:stroke-color="'currentColor'"
:stroke-width="2"
/>
</template>
关于
</t-menu-item>
</t-menu>
<template #footer>
Copyright © 2019 - 2025 Alina-dace.info. All Rights Reserved.
</template>
</t-drawer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { HomeIcon, InfoCircleIcon, UserPasswordIcon } from 'tdesign-icons-vue-next'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
})
const emits = defineEmits(['update:visible'])
const current = ref<any>('home')
const router = useRouter()
const route = useRoute()
function handleRouteChange(value: string) {
const routes = router.getRoutes()
const target = routes.findIndex((route) => route.name === value)
if (routes[target]) {
emits('update:visible', false)
router.push({
path: routes[target].path,
})
}
}
watch(
() => route.path,
(path) => {
const routes = router.getRoutes()
routes.forEach((route) => {
if (route.path === path && route.name) {
current.value = route.name
}
})
},
{ immediate: true },
)
</script>
<style scoped lang="scss"></style>

View File

@ -27,6 +27,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, toRefs } from 'vue' import { reactive, ref, toRefs } from 'vue'
import { assertion, assertion_options, login } from '@/api/auth.ts' import { assertion, assertion_options, login } from '@/api/auth.ts'
import { base64ToArrayBuffer } from '@/utils/base64.ts'
const emits = defineEmits(['complete', 'register']) const emits = defineEmits(['complete', 'register'])
const { username, password } = toRefs( const { username, password } = toRefs(
@ -58,26 +59,6 @@ const passkey = ref(true)
if (!navigator.credentials) { if (!navigator.credentials) {
passkey.value = false passkey.value = false
} }
function base64ToArrayBuffer(base64: string): ArrayBuffer {
// base64url
let normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
//
while (normalizedBase64.length % 4 !== 0) {
normalizedBase64 += '='
}
try {
const binaryString = atob(normalizedBase64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes.buffer
} catch (e) {
console.error('Base64 decoding failed:', e, 'Input was:', base64)
throw e
}
}
function handleUsePasskey() { function handleUsePasskey() {
assertion_options().then((x: any) => { assertion_options().then((x: any) => {
const parse = JSON.parse(x.data) const parse = JSON.parse(x.data)
@ -91,7 +72,6 @@ function handleUsePasskey() {
}) })
}) })
}) })
// emits('complete')
} }
function handleRegister() { function handleRegister() {
emits('register') emits('register')

View File

@ -1,6 +1,8 @@
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { HttpStatus } from '@/enums/RespEnum.ts' import { HttpStatus } from '@/enums/RespEnum.ts'
import { MessagePlugin } from 'tdesign-vue-next' import { MessagePlugin } from 'tdesign-vue-next'
import { getCurrentInstance } from 'vue'
import { removeCookie } from '@/utils/cookie.ts'
const errorCode: any = { const errorCode: any = {
'401': '认证失败,无法访问系统资源', '401': '认证失败,无法访问系统资源',
@ -36,6 +38,9 @@ request.interceptors.response.use((res: AxiosResponse) => {
} else if (code === HttpStatus.WARN) { } else if (code === HttpStatus.WARN) {
MessagePlugin.warning(msg); MessagePlugin.warning(msg);
return Promise.reject(new Error(msg)); return Promise.reject(new Error(msg));
} else if (code === HttpStatus.UNAUTHORIZED) {
removeCookie('satoken')
window.location.reload()
} else if (code !== HttpStatus.SUCCESS) { } else if (code !== HttpStatus.SUCCESS) {
MessagePlugin.error(msg); MessagePlugin.error(msg);
return Promise.reject('error'); return Promise.reject('error');

View File

@ -9,13 +9,18 @@ const router = createRouter({
name: 'home', name: 'home',
component: Home, component: Home,
}, },
{
path: '/passkey',
name: 'passkey',
component: () => import('@/views/Passkey.vue'),
},
{ {
path: '/about', path: '/about',
name: 'about', name: 'about',
// route level code-splitting // route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route // this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited. // which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'), component: () => import('@/views/AboutView.vue'),
}, },
], ],
}) })

20
src/utils/base64.ts Normal file
View File

@ -0,0 +1,20 @@
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
// 处理base64url格式替换字符并添加填充
let normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
// 添加必要的填充
while (normalizedBase64.length % 4 !== 0) {
normalizedBase64 += '='
}
try {
const binaryString = atob(normalizedBase64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes.buffer
} catch (e) {
console.error('Base64 decoding failed:', e, 'Input was:', base64)
throw e
}
}

11
src/utils/cookie.ts Normal file
View File

@ -0,0 +1,11 @@
export function getCookie(name: string) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
export function removeCookie(name: string, path?: string, domain?: string) {
let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:01 GMT;`
if (path) cookieString += ` path=${path};`
if (domain) cookieString += ` domain=${domain};`
document.cookie = cookieString
}

89
src/views/Passkey.vue Normal file
View File

@ -0,0 +1,89 @@
<template>
<div class="content">
<t-space>
<t-popup :visible="passkeyNameVisible">
<template #content>
<t-input v-model="passkeyName" placeholder="请输入通行密钥名称" />
<t-button theme="primary" :loading="passkeyNameSubmit" @click="handleUsePasskey">确定</t-button>
</template>
<t-button theme="primary" :disabled="!passkey" @click="handleRegister">
<template #icon>
<add-icon
:fill-color="'transparent'"
:stroke-color="'currentColor'"
:stroke-width="2"
/>
</template>
添加通行密钥
</t-button>
</t-popup>
</t-space>
<t-base-table :columns="credentialColumns" :data="credentialList"/>
</div>
</template>
<script setup lang="ts">
import { AddIcon } from 'tdesign-icons-vue-next'
import { credentials, registration, registration_options } from '@/api/auth.ts'
import { ref } from 'vue'
import { base64ToArrayBuffer } from '@/utils/base64.ts'
const credentialList = ref<any>([])
const credentialColumns = ref([
{ colKey: 'name', title: '名称'},
{ colKey: 'lastUsed', title: '最后使用时间'},
{ colKey: 'created', title: '创建时间'}
])
const passkey = ref<boolean>(true)
if (!navigator.credentials) {
passkey.value = false
}
const passkeyNameVisible = ref(false)
const passkeyNameSubmit = ref(false)
const passkeyName = ref('')
const passkeyOptions = ref<any>(null)
function handleRegister() {
registration_options().then((resp) => {
const parse = JSON.parse(resp.data)
parse.publicKey.challenge = base64ToArrayBuffer(parse.publicKey?.challenge)
parse.publicKey.user.id = base64ToArrayBuffer(parse.publicKey?.user?.id)
navigator.credentials.create(parse).then((resp) => {
passkeyOptions.value = resp
passkeyNameVisible.value = true
})
})
}
function handleUsePasskey() {
passkeyNameSubmit.value = true
registration(passkeyOptions.value, passkeyName.value).then((resp: any) => {
if (resp.code === 200) {
reload()
}
})
passkeyNameVisible.value = false
}
function reload(){
credentials().then((resp: any) => {
if (resp.code === 200) {
credentialList.value = resp.data
}
})
}
reload()
</script>
<style scoped lang="scss">
.content {
width: 100%;
overflow: hidden;
padding: 2rem;
}
</style>