feat(auth): 实现通行密钥注册与凭证管理功能
- 新增获取通行密钥凭证接口 credentials - 修改注册接口 registration 支持传递名称参数 - 添加 base64ToArrayBuffer 工具函数并移至独立文件 - 新增通行密钥管理页面 Passkey.vue - 实现通行密钥注册、命名及列表展示功能 - 添加菜单组件 Menu.vue 支持页面导航 - 优化登录流程,支持从 Cookie 恢复登录状态 - 处理 401 状态码时清除认证信息并刷新页面 - 调整 App.vue 样式层级并添加菜单触发逻辑
This commit is contained in:
parent
846c8ce882
commit
f5e33a64e7
50
src/App.vue
50
src/App.vue
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<header :class="{ login: hideLoading }">
|
||||
<div class="login-box">
|
||||
<div class="logo">
|
||||
<div class="logo" @click="handleMenu">
|
||||
<img alt="Logo" :src="avatar" style="object-fit: cover"/>
|
||||
</div>
|
||||
<div class="login-info">
|
||||
@ -38,14 +38,17 @@
|
||||
</transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
<Menu v-model:visible="menuVisible"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import avatar from '@/assets/avater.jpg'
|
||||
import LoginProcess from '@/components/login/LoginProcess.vue'
|
||||
import { RefreshIcon } from 'tdesign-icons-vue-next'
|
||||
import Menu from '@/components/Menu.vue'
|
||||
import { getCookie } from '@/utils/cookie.ts'
|
||||
|
||||
const login = ref(false)
|
||||
const weCome = ref(false)
|
||||
@ -55,41 +58,28 @@ const showTitle = ref(false)
|
||||
const passkey = ref(true)
|
||||
const name = ref('Amico')
|
||||
|
||||
const menuVisible = ref(false)
|
||||
|
||||
if (!navigator.credentials) {
|
||||
passkey.value = false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function handleMenu() {
|
||||
if (weCome.value){
|
||||
menuVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (getCookie('satoken')){
|
||||
login.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// 登录动画流程
|
||||
watch(login, (val) => {
|
||||
if (val) {
|
||||
@ -114,7 +104,7 @@ header {
|
||||
width: 100%;
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
z-index: 99999;
|
||||
z-index: 10;
|
||||
background: var(--td-mask-background);
|
||||
box-shadow: var(--td-shadow-4);
|
||||
height: 100vh;
|
||||
|
||||
@ -15,17 +15,27 @@ export function register(data: any) {
|
||||
})
|
||||
}
|
||||
|
||||
export function credentials(){
|
||||
return request({
|
||||
url: '/passkey/credentials',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function registration_options(){
|
||||
return request({
|
||||
url: '/passkey/registration/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
export function registration(credential: any){
|
||||
export function registration(credential: any, name: string){
|
||||
return request({
|
||||
url: '/passkey/registration',
|
||||
method: 'post',
|
||||
data: credential
|
||||
data: credential,
|
||||
params: {
|
||||
name: name
|
||||
}
|
||||
})
|
||||
}
|
||||
export function assertion_options(){
|
||||
|
||||
90
src/components/Menu.vue
Normal file
90
src/components/Menu.vue
Normal 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>
|
||||
@ -27,6 +27,7 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, toRefs } from 'vue'
|
||||
import { assertion, assertion_options, login } from '@/api/auth.ts'
|
||||
import { base64ToArrayBuffer } from '@/utils/base64.ts'
|
||||
|
||||
const emits = defineEmits(['complete', 'register'])
|
||||
const { username, password } = toRefs(
|
||||
@ -58,26 +59,6 @@ const passkey = ref(true)
|
||||
if (!navigator.credentials) {
|
||||
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() {
|
||||
assertion_options().then((x: any) => {
|
||||
const parse = JSON.parse(x.data)
|
||||
@ -91,7 +72,6 @@ function handleUsePasskey() {
|
||||
})
|
||||
})
|
||||
})
|
||||
// emits('complete')
|
||||
}
|
||||
function handleRegister() {
|
||||
emits('register')
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { HttpStatus } from '@/enums/RespEnum.ts'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { removeCookie } from '@/utils/cookie.ts'
|
||||
|
||||
const errorCode: any = {
|
||||
'401': '认证失败,无法访问系统资源',
|
||||
@ -36,6 +38,9 @@ request.interceptors.response.use((res: AxiosResponse) => {
|
||||
} else if (code === HttpStatus.WARN) {
|
||||
MessagePlugin.warning(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
} else if (code === HttpStatus.UNAUTHORIZED) {
|
||||
removeCookie('satoken')
|
||||
window.location.reload()
|
||||
} else if (code !== HttpStatus.SUCCESS) {
|
||||
MessagePlugin.error(msg);
|
||||
return Promise.reject('error');
|
||||
|
||||
@ -9,13 +9,18 @@ const router = createRouter({
|
||||
name: 'home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/passkey',
|
||||
name: 'passkey',
|
||||
component: () => import('@/views/Passkey.vue'),
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// 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
20
src/utils/base64.ts
Normal 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
11
src/utils/cookie.ts
Normal 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
89
src/views/Passkey.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user