mirror of
https://github.com/jumpserver/lina.git
synced 2025-07-24 03:49:11 +00:00
perf: 修改 passkey (#3382)
* perf: 修改 passkey * perf: 完成 passkey * perf: 修改 passkey icon --------- Co-authored-by: ibuler <ibuler@qq.com>
This commit is contained in:
parent
95831c3ff7
commit
ca3a99a5cf
@ -465,6 +465,7 @@
|
|||||||
"AfterChange": "变更后"
|
"AfterChange": "变更后"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
"AddPassKey": "添加 Passkey(通行密钥)",
|
||||||
"LoginRequiredMsg": "账号已退出,请重新登录",
|
"LoginRequiredMsg": "账号已退出,请重新登录",
|
||||||
"ReLogin": "重新登录",
|
"ReLogin": "重新登录",
|
||||||
"ReLoginTitle": "当前三方登录用户(CAS/SAML),未绑定 MFA 且不支持密码校验,请重新登录。",
|
"ReLoginTitle": "当前三方登录用户(CAS/SAML),未绑定 MFA 且不支持密码校验,请重新登录。",
|
||||||
@ -777,6 +778,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"TempPassword": "临时密码",
|
"TempPassword": "临时密码",
|
||||||
"ConnectionToken": "连接令牌",
|
"ConnectionToken": "连接令牌",
|
||||||
|
"PassKey": "Passkey",
|
||||||
"APIKey": "API Key",
|
"APIKey": "API Key",
|
||||||
"Workbench": "工作台",
|
"Workbench": "工作台",
|
||||||
"Navigation": "导航",
|
"Navigation": "导航",
|
||||||
@ -1525,6 +1527,7 @@
|
|||||||
"PublishStatus": "发布状态"
|
"PublishStatus": "发布状态"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
|
"Passkey": "Passkey",
|
||||||
"BlockedIPS": "已锁定的 IP",
|
"BlockedIPS": "已锁定的 IP",
|
||||||
"ViewBlockedIPSHelpText": "查看已被锁定的 IP 列表",
|
"ViewBlockedIPSHelpText": "查看已被锁定的 IP 列表",
|
||||||
"Unblock": "解锁",
|
"Unblock": "解锁",
|
||||||
|
1
src/icons/svg/passkey.svg
Normal file
1
src/icons/svg/passkey.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1694426459089" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8414" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M510.952965 0L78.178596 164.73347v286.887525a505.36878 505.36878 0 0 0 209.406953 408.343559l223.367416 164.035446 225.461486-164.035446a505.36878 505.36878 0 0 0 209.406953-408.343559V164.73347z m360.877983 451.620995a432.774369 432.774369 0 0 1-177.995911 349.011589L510.952965 934.653033l-182.184049-133.322426A431.378323 431.378323 0 0 1 147.980913 451.620995v-237.32788l360.877983-139.604635 360.877982 139.604635z" p-id="8415"></path><path d="M510.952965 258.966599a144.490798 144.490798 0 0 0-34.901159 284.095432V732.924335a32.109066 32.109066 0 0 0 34.901159 34.901159 32.109066 32.109066 0 0 0 36.297205-36.297205V677.082481h36.297206a32.109066 32.109066 0 0 0 36.297205-36.297205 32.109066 32.109066 0 0 0-36.297205-35.599182h-36.297206v-57.2379-4.886163a144.490798 144.490798 0 0 0-36.297205-284.095432z m69.802318 144.490797a69.802318 69.802318 0 1 1-69.802318-69.802318 69.802318 69.802318 0 0 1 72.594411 69.802318z" p-id="8416"></path></svg>
|
After Width: | Height: | Size: 1.3 KiB |
@ -76,6 +76,17 @@ export default {
|
|||||||
permissions: ['authentication.view_connectiontoken']
|
permissions: ['authentication.view_connectiontoken']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/profile/passkeys',
|
||||||
|
component: () => import('@/views/profile/PassKey.vue'),
|
||||||
|
name: 'Passkey',
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('common.nav.PassKey'),
|
||||||
|
icon: 'passkey',
|
||||||
|
hidden: ({ settings }) => !settings['AUTH_PASSKEY'],
|
||||||
|
permissions: ['authentication.view_connectiontoken']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/profile/user/setting',
|
path: '/profile/user/setting',
|
||||||
name: 'UserSetting',
|
name: 'UserSetting',
|
||||||
|
80
src/utils/passkey.js
Normal file
80
src/utils/passkey.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
||||||
|
|
||||||
|
// Use a lookup table to find the index.
|
||||||
|
const lookup = new Uint8Array(256)
|
||||||
|
for (let i = 0; i < chars.length; i++) {
|
||||||
|
lookup[chars.charCodeAt(i)] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
const encode = function(arraybuffer) {
|
||||||
|
const bytes = new Uint8Array(arraybuffer)
|
||||||
|
let i; const len = bytes.length; let base64url = ''
|
||||||
|
|
||||||
|
for (i = 0; i < len; i += 3) {
|
||||||
|
base64url += chars[bytes[i] >> 2]
|
||||||
|
base64url += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]
|
||||||
|
base64url += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]
|
||||||
|
base64url += chars[bytes[i + 2] & 63]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((len % 3) === 2) {
|
||||||
|
base64url = base64url.substring(0, base64url.length - 1)
|
||||||
|
} else if (len % 3 === 1) {
|
||||||
|
base64url = base64url.substring(0, base64url.length - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64url
|
||||||
|
}
|
||||||
|
|
||||||
|
const decode = function(base64string) {
|
||||||
|
const bufferLength = base64string.length * 0.75
|
||||||
|
const len = base64string.length; let i; let p = 0
|
||||||
|
let encoded1; let encoded2; let encoded3; let encoded4
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(bufferLength)
|
||||||
|
|
||||||
|
for (i = 0; i < len; i += 4) {
|
||||||
|
encoded1 = lookup[base64string.charCodeAt(i)]
|
||||||
|
encoded2 = lookup[base64string.charCodeAt(i + 1)]
|
||||||
|
encoded3 = lookup[base64string.charCodeAt(i + 2)]
|
||||||
|
encoded4 = lookup[base64string.charCodeAt(i + 3)]
|
||||||
|
|
||||||
|
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4)
|
||||||
|
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2)
|
||||||
|
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyCredentialToJSON = (pubKeyCred) => {
|
||||||
|
if (pubKeyCred instanceof Array) {
|
||||||
|
const arr = []
|
||||||
|
for (const i of pubKeyCred) { arr.push(publicKeyCredentialToJSON(i)) }
|
||||||
|
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pubKeyCred instanceof ArrayBuffer) {
|
||||||
|
return encode(pubKeyCred)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pubKeyCred instanceof Object) {
|
||||||
|
const obj = {}
|
||||||
|
|
||||||
|
for (const key in pubKeyCred) {
|
||||||
|
obj[key] = publicKeyCredentialToJSON(pubKeyCred[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
return pubKeyCred
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'decode': decode,
|
||||||
|
'encode': encode,
|
||||||
|
'publicKeyCredentialToJSON': publicKeyCredentialToJSON
|
||||||
|
}
|
||||||
|
|
153
src/views/profile/PassKey.vue
Normal file
153
src/views/profile/PassKey.vue
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<GenericListPage
|
||||||
|
ref="GenericListTable"
|
||||||
|
:header-actions="headerActions"
|
||||||
|
:table-config="tableConfig"
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
:show-buttons="false"
|
||||||
|
:title="$tc('auth.AddPassKey')"
|
||||||
|
:visible.sync="dialogVisible"
|
||||||
|
>
|
||||||
|
<AutoDataForm v-bind="form" @submit="onAddConfirm" />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { GenericListPage } from '@/layout/components'
|
||||||
|
import { AutoDataForm, Dialog } from '@/components'
|
||||||
|
import passkey from '@/utils/passkey'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
GenericListPage,
|
||||||
|
Dialog,
|
||||||
|
AutoDataForm
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
const ajaxUrl = '/api/v1/authentication/passkeys/'
|
||||||
|
return {
|
||||||
|
dialogVisible: false,
|
||||||
|
form: {
|
||||||
|
url: '',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
label: this.$t('common.Name'),
|
||||||
|
type: 'input',
|
||||||
|
required: true,
|
||||||
|
el: {
|
||||||
|
placeholder: 'Laptop, Phone, etc.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
hasSaveContinue: false,
|
||||||
|
hasReset: false
|
||||||
|
},
|
||||||
|
tableConfig: {
|
||||||
|
hasSelection: true,
|
||||||
|
url: ajaxUrl,
|
||||||
|
columnsShow: {
|
||||||
|
min: ['name', 'actions'],
|
||||||
|
default: ['name', 'is_active', 'date_last_used', 'date_created', 'actions']
|
||||||
|
},
|
||||||
|
columnsMeta: {
|
||||||
|
actions: {
|
||||||
|
formatterArgs: {
|
||||||
|
hasUpdate: false,
|
||||||
|
hasClone: false,
|
||||||
|
onDelete: function({ row }) {
|
||||||
|
this.$axios.delete(`${ajaxUrl}${row.id}/`).then(res => {
|
||||||
|
this.getRefsListTable.reloadTable()
|
||||||
|
this.$message.success(this.$tc('common.deleteSuccessMsg'))
|
||||||
|
}).catch(error => {
|
||||||
|
this.$message.error(this.$tc('common.deleteErrorMsg') + ' ' + error)
|
||||||
|
})
|
||||||
|
}.bind(this),
|
||||||
|
extraActions: [
|
||||||
|
{
|
||||||
|
name: 'Enabled',
|
||||||
|
title: ({ row }) => {
|
||||||
|
return row.is_active ? this.$t('common.Disable') : this.$t('common.Enable')
|
||||||
|
},
|
||||||
|
type: 'info',
|
||||||
|
can: () => this.$hasPerm('authentication.change_passkey'),
|
||||||
|
callback: function({ row }) {
|
||||||
|
this.$axios.patch(`${ajaxUrl}${row.id}/`,
|
||||||
|
{ is_active: !row.is_active }
|
||||||
|
).then(res => {
|
||||||
|
this.getRefsListTable.reloadTable()
|
||||||
|
this.$message.success(this.$tc('common.updateSuccessMsg'))
|
||||||
|
}).catch(error => {
|
||||||
|
this.$message.error(this.$tc('common.updateErrorMsg' + ' ' + error))
|
||||||
|
})
|
||||||
|
}.bind(this)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headerActions: {
|
||||||
|
hasSearch: true,
|
||||||
|
hasRightActions: true,
|
||||||
|
hasRefresh: true,
|
||||||
|
hasExport: false,
|
||||||
|
hasImport: false,
|
||||||
|
hasBulkDelete: false,
|
||||||
|
hasCreate: false,
|
||||||
|
extraActions: [
|
||||||
|
{
|
||||||
|
name: this.$t('setting.Create'),
|
||||||
|
title: this.$t('setting.Create'),
|
||||||
|
type: 'primary',
|
||||||
|
can: () => this.$hasPerm('authentication.add_passkey'),
|
||||||
|
callback: function() {
|
||||||
|
this.dialogVisible = true
|
||||||
|
}.bind(this)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
getRefsListTable() {
|
||||||
|
return this.$refs.GenericListTable.$refs.ListTable.$refs.ListTable || {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onAddConfirm(form) {
|
||||||
|
const url = '/api/v1/authentication/passkeys/register/?name=' + form.name
|
||||||
|
this.$axios.get(url).then(res => {
|
||||||
|
return this.makeCredReq(res)
|
||||||
|
}).then((options) => {
|
||||||
|
return navigator.credentials.create(options)
|
||||||
|
}).then((attestation) => {
|
||||||
|
attestation['key_name'] = form.name
|
||||||
|
const data = passkey.publicKeyCredentialToJSON(attestation)
|
||||||
|
return this.$axios.post('/api/v1/authentication/passkeys/register/', data)
|
||||||
|
}).then((res) => {
|
||||||
|
this.dialogVisible = false
|
||||||
|
this.getRefsListTable.reloadTable()
|
||||||
|
this.$message.success(this.$tc('common.createSuccessMsg'))
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log('Error = ', error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
makeCredReq(makeCredReq) {
|
||||||
|
makeCredReq.publicKey.challenge = passkey.decode(makeCredReq.publicKey.challenge)
|
||||||
|
makeCredReq.publicKey.user.id = passkey.decode(makeCredReq.publicKey.user.id)
|
||||||
|
|
||||||
|
for (const excludeCred of makeCredReq.publicKey.excludeCredentials) {
|
||||||
|
excludeCred.id = passkey.decode(excludeCred.id)
|
||||||
|
}
|
||||||
|
return makeCredReq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
45
src/views/settings/Auth/Passkey.vue
Normal file
45
src/views/settings/Auth/Passkey.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<BaseAuth
|
||||||
|
:config="settings"
|
||||||
|
enable-field="AUTH_PASSKEY"
|
||||||
|
v-on="$listeners"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import BaseAuth from './Base'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Passkey',
|
||||||
|
components: {
|
||||||
|
BaseAuth
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
url: '/api/v1/settings/setting/?category=passkey',
|
||||||
|
hasDetailInMsg: false,
|
||||||
|
fields: [
|
||||||
|
[
|
||||||
|
this.$t('common.BasicInfo'),
|
||||||
|
[
|
||||||
|
'AUTH_PASSKEY', 'FIDO_SERVER_ID', 'FIDO_SERVER_NAME'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
fieldsMeta: {
|
||||||
|
},
|
||||||
|
submitMethod() {
|
||||||
|
return 'patch'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
@ -20,6 +20,7 @@ import WeCom from './WeCom'
|
|||||||
import SSO from './SSO'
|
import SSO from './SSO'
|
||||||
import SAML2 from './SAML2'
|
import SAML2 from './SAML2'
|
||||||
import OAuth2 from './OAuth2'
|
import OAuth2 from './OAuth2'
|
||||||
|
import Passkey from './Passkey.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -35,7 +36,8 @@ export default {
|
|||||||
Radius,
|
Radius,
|
||||||
SSO,
|
SSO,
|
||||||
SAML2,
|
SAML2,
|
||||||
OAuth2
|
OAuth2,
|
||||||
|
Passkey
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
let extraBackends = []
|
let extraBackends = []
|
||||||
@ -96,6 +98,11 @@ export default {
|
|||||||
name: 'CAS',
|
name: 'CAS',
|
||||||
key: 'AUTH_CAS'
|
key: 'AUTH_CAS'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: this.$t('setting.Passkey'),
|
||||||
|
name: 'Passkey',
|
||||||
|
key: 'AUTH_PASSKEY'
|
||||||
|
},
|
||||||
...extraBackends
|
...extraBackends
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user