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>
|
<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;
|
||||||
|
|||||||
@ -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
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">
|
<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')
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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
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