enhance: by default, passkey is used for 2fa if passkey is used to login

This commit is contained in:
Jacky 2024-09-16 13:57:31 +08:00
parent 0a6a7693a1
commit f42a6c2d08
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
13 changed files with 263 additions and 192 deletions

View file

@ -1,156 +1,156 @@
package user package user
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"github.com/0xJacky/Nginx-UI/api" "github.com/0xJacky/Nginx-UI/api"
"github.com/0xJacky/Nginx-UI/internal/cache" "github.com/0xJacky/Nginx-UI/internal/cache"
"github.com/0xJacky/Nginx-UI/internal/passkey" "github.com/0xJacky/Nginx-UI/internal/passkey"
"github.com/0xJacky/Nginx-UI/internal/user" "github.com/0xJacky/Nginx-UI/internal/user"
"github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query" "github.com/0xJacky/Nginx-UI/query"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/google/uuid" "github.com/google/uuid"
"net/http" "net/http"
"strings" "strings"
"time" "time"
) )
type Status2FA struct { type Status2FA struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
OTPStatus bool `json:"otp_status"` OTPStatus bool `json:"otp_status"`
PasskeyStatus bool `json:"passkey_status"` PasskeyStatus bool `json:"passkey_status"`
} }
func get2FAStatus(c *gin.Context) (status Status2FA) { func get2FAStatus(c *gin.Context) (status Status2FA) {
// when accessing the node from the main cluster, there is no user in the context // when accessing the node from the main cluster, there is no user in the context
u, ok := c.Get("user") u, ok := c.Get("user")
if ok { if ok {
userPtr := u.(*model.User) userPtr := u.(*model.User)
status.OTPStatus = userPtr.EnabledOTP() status.OTPStatus = userPtr.EnabledOTP()
status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled() status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled()
status.Enabled = status.OTPStatus || status.PasskeyStatus status.Enabled = status.OTPStatus || status.PasskeyStatus
} }
return return
} }
func Get2FAStatus(c *gin.Context) { func Get2FAStatus(c *gin.Context) {
c.JSON(http.StatusOK, get2FAStatus(c)) c.JSON(http.StatusOK, get2FAStatus(c))
} }
func SecureSessionStatus(c *gin.Context) { func SecureSessionStatus(c *gin.Context) {
status2FA := get2FAStatus(c) status2FA := get2FAStatus(c)
if !status2FA.Enabled { if !status2FA.Enabled {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": false, "status": false,
}) })
return return
} }
ssid := c.GetHeader("X-Secure-Session-ID") ssid := c.GetHeader("X-Secure-Session-ID")
if ssid == "" { if ssid == "" {
ssid = c.Query("X-Secure-Session-ID") ssid = c.Query("X-Secure-Session-ID")
} }
if ssid == "" { if ssid == "" {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": false, "status": false,
}) })
return return
} }
u := api.CurrentUser(c) u := api.CurrentUser(c)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": user.VerifySecureSessionID(ssid, u.ID), "status": user.VerifySecureSessionID(ssid, u.ID),
}) })
} }
func Start2FASecureSessionByOTP(c *gin.Context) { func Start2FASecureSessionByOTP(c *gin.Context) {
var json struct { var json struct {
OTP string `json:"otp"` OTP string `json:"otp"`
RecoveryCode string `json:"recovery_code"` RecoveryCode string `json:"recovery_code"`
} }
if !api.BindAndValid(c, &json) { if !api.BindAndValid(c, &json) {
return return
} }
u := api.CurrentUser(c) u := api.CurrentUser(c)
if !u.EnabledOTP() { if !u.EnabledOTP() {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"message": "User has not configured OTP as 2FA", "message": "User has not configured OTP as 2FA",
}) })
return return
} }
if json.OTP == "" && json.RecoveryCode == "" { if json.OTP == "" && json.RecoveryCode == "" {
c.JSON(http.StatusBadRequest, LoginResponse{ c.JSON(http.StatusBadRequest, LoginResponse{
Message: "The user has enabled OTP as 2FA", Message: "The user has enabled OTP as 2FA",
}) })
return return
} }
if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
c.JSON(http.StatusBadRequest, LoginResponse{ c.JSON(http.StatusBadRequest, LoginResponse{
Message: "Invalid OTP or recovery code", Message: "Invalid OTP or recovery code",
}) })
return return
} }
sessionId := user.SetSecureSessionID(u.ID) sessionId := user.SetSecureSessionID(u.ID)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"session_id": sessionId, "session_id": sessionId,
}) })
} }
func BeginStart2FASecureSessionByPasskey(c *gin.Context) { func BeginStart2FASecureSessionByPasskey(c *gin.Context) {
if !passkey.Enabled() { if !passkey.Enabled() {
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured")) api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
return return
} }
webauthnInstance := passkey.GetInstance() webauthnInstance := passkey.GetInstance()
u := api.CurrentUser(c) u := api.CurrentUser(c)
options, sessionData, err := webauthnInstance.BeginLogin(u) options, sessionData, err := webauthnInstance.BeginLogin(u)
if err != nil { if err != nil {
api.ErrHandler(c, err) api.ErrHandler(c, err)
return return
} }
passkeySessionID := uuid.NewString() passkeySessionID := uuid.NewString()
cache.Set(passkeySessionID, sessionData, passkeyTimeout) cache.Set(passkeySessionID, sessionData, passkeyTimeout)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"session_id": passkeySessionID, "session_id": passkeySessionID,
"options": options, "options": options,
}) })
} }
func FinishStart2FASecureSessionByPasskey(c *gin.Context) { func FinishStart2FASecureSessionByPasskey(c *gin.Context) {
if !passkey.Enabled() { if !passkey.Enabled() {
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured")) api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
return return
} }
passkeySessionID := c.GetHeader("X-Passkey-Session-ID") passkeySessionID := c.GetHeader("X-Passkey-Session-ID")
sessionDataBytes, ok := cache.Get(passkeySessionID) sessionDataBytes, ok := cache.Get(passkeySessionID)
if !ok { if !ok {
api.ErrHandler(c, fmt.Errorf("session not found")) api.ErrHandler(c, fmt.Errorf("session not found"))
return return
} }
sessionData := sessionDataBytes.(*webauthn.SessionData) sessionData := sessionDataBytes.(*webauthn.SessionData)
webauthnInstance := passkey.GetInstance() webauthnInstance := passkey.GetInstance()
u := api.CurrentUser(c) u := api.CurrentUser(c)
credential, err := webauthnInstance.FinishLogin(u, *sessionData, c.Request) credential, err := webauthnInstance.FinishLogin(u, *sessionData, c.Request)
if err != nil { if err != nil {
api.ErrHandler(c, err) api.ErrHandler(c, err)
return return
} }
rawID := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=") rawID := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=")
p := query.Passkey p := query.Passkey
_, _ = p.Where(p.RawID.Eq(rawID)).Updates(&model.Passkey{ _, _ = p.Where(p.RawID.Eq(rawID)).Updates(&model.Passkey{
LastUsedAt: time.Now().Unix(), LastUsedAt: time.Now().Unix(),
}) })
sessionId := user.SetSecureSessionID(u.ID) sessionId := user.SetSecureSessionID(u.ID)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"session_id": sessionId, "session_id": sessionId,
}) })
} }

View file

@ -13,6 +13,8 @@ func InitAuthRouter(r *gin.RouterGroup) {
r.GET("/casdoor_uri", GetCasdoorUri) r.GET("/casdoor_uri", GetCasdoorUri)
r.POST("/casdoor_callback", CasdoorCallback) r.POST("/casdoor_callback", CasdoorCallback)
r.GET("/passkeys/config", GetPasskeyConfigStatus)
} }
func InitManageUserRouter(r *gin.RouterGroup) { func InitManageUserRouter(r *gin.RouterGroup) {
@ -38,7 +40,6 @@ func InitUserRouter(r *gin.RouterGroup) {
r.GET("/begin_passkey_register", BeginPasskeyRegistration) r.GET("/begin_passkey_register", BeginPasskeyRegistration)
r.POST("/finish_passkey_register", FinishPasskeyRegistration) r.POST("/finish_passkey_register", FinishPasskeyRegistration)
r.GET("/passkeys/config", GetPasskeyConfigStatus)
r.GET("/passkeys", GetPasskeyList) r.GET("/passkeys", GetPasskeyList)
r.POST("/passkeys/:id", UpdatePasskey) r.POST("/passkeys/:id", UpdatePasskey)
r.DELETE("/passkeys/:id", DeletePasskey) r.DELETE("/passkeys/:id", DeletePasskey)

1
app/components.d.ts vendored
View file

@ -8,6 +8,7 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
2FA2FAAuthorization: typeof import('./src/components/2FA/2FAAuthorization.vue')['default'] 2FA2FAAuthorization: typeof import('./src/components/2FA/2FAAuthorization.vue')['default']
2FAAuthorization: typeof import('./src/components/2FA/Authorization.vue')['default']
2FAOTPAuthorization: typeof import('./src/components/2FA/OTPAuthorization.vue')['default'] 2FAOTPAuthorization: typeof import('./src/components/2FA/OTPAuthorization.vue')['default']
AAlert: typeof import('ant-design-vue/es')['Alert'] AAlert: typeof import('ant-design-vue/es')['Alert']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete'] AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']

View file

@ -5,6 +5,7 @@ import type { ModelBase } from '@/api/curd'
export interface Passkey extends ModelBase { export interface Passkey extends ModelBase {
name: string name: string
user_id: string user_id: string
raw_id: string
} }
const passkey = { const passkey = {
@ -27,8 +28,8 @@ const passkey = {
remove(passkeyId: number) { remove(passkeyId: number) {
return http.delete(`/passkeys/${passkeyId}`) return http.delete(`/passkeys/${passkeyId}`)
}, },
get_passkey_config_status(): Promise<{ status: boolean }> { get_config_status(): Promise<{ status: boolean }> {
return http.get('/passkey/config') return http.get('/passkeys/config')
}, },
} }

View file

@ -3,11 +3,17 @@ import { KeyOutlined } from '@ant-design/icons-vue'
import { startAuthentication } from '@simplewebauthn/browser' import { startAuthentication } from '@simplewebauthn/browser'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import OTPInput from '@/components/OTPInput/OTPInput.vue' import OTPInput from '@/components/OTPInput/OTPInput.vue'
import { $gettext } from '@/gettext' import type { TwoFAStatusResponse } from '@/api/2fa'
import twoFA from '@/api/2fa' import twoFA from '@/api/2fa'
import { useUserStore } from '@/pinia'
defineProps<{
twoFAStatus: TwoFAStatusResponse
}>()
const emit = defineEmits(['submitOTP', 'submitSecureSessionID']) const emit = defineEmits(['submitOTP', 'submitSecureSessionID'])
const user = useUserStore()
const refOTP = ref() const refOTP = ref()
const useRecoveryCode = ref(false) const useRecoveryCode = ref(false)
const passcode = ref('') const passcode = ref('')
@ -55,48 +61,58 @@ async function passkeyAuthenticate() {
} }
passkeyLoading.value = false passkeyLoading.value = false
} }
onMounted(() => {
if (user.passkeyLoginAvailable)
passkeyAuthenticate()
})
</script> </script>
<template> <template>
<div> <div>
<div v-if="!useRecoveryCode"> <div v-if="twoFAStatus.otp_status">
<p>{{ $gettext('Please enter the OTP code:') }}</p> <div v-if="!useRecoveryCode">
<OTPInput <p>{{ $gettext('Please enter the OTP code:') }}</p>
ref="refOTP" <OTPInput
v-model="passcode" ref="refOTP"
class="justify-center mb-6" v-model="passcode"
@on-complete="onSubmit" class="justify-center mb-6"
/> @on-complete="onSubmit"
</div> />
<div </div>
v-else <div
class="mt-2 mb-4"
>
<p>{{ $gettext('Input the recovery code:') }}</p>
<AInputGroup compact>
<AInput v-model:value="recoveryCode" />
<AButton
type="primary"
@click="onSubmit"
>
{{ $gettext('Recovery') }}
</AButton>
</AInputGroup>
</div>
<div class="flex justify-center">
<a
v-if="!useRecoveryCode"
@click="clickUseRecoveryCode"
>{{ $gettext('Use recovery code') }}</a>
<a
v-else v-else
@click="clickUseOTP" class="mt-2 mb-4"
>{{ $gettext('Use OTP') }}</a> >
<p>{{ $gettext('Input the recovery code:') }}</p>
<AInputGroup compact>
<AInput v-model:value="recoveryCode" />
<AButton
type="primary"
@click="onSubmit"
>
{{ $gettext('Recovery') }}
</AButton>
</AInputGroup>
</div>
<div class="flex justify-center">
<a
v-if="!useRecoveryCode"
@click="clickUseRecoveryCode"
>{{ $gettext('Use recovery code') }}</a>
<a
v-else
@click="clickUseOTP"
>{{ $gettext('Use OTP') }}</a>
</div>
</div> </div>
<div class="flex flex-col justify-center"> <div
<ADivider> v-if="twoFAStatus.passkey_status"
class="flex flex-col justify-center"
>
<ADivider v-if="twoFAStatus.otp_status">
<div class="text-sm font-normal opacity-75"> <div class="text-sm font-normal opacity-75">
{{ $gettext('Or') }} {{ $gettext('Or') }}
</div> </div>

View file

@ -1,12 +1,12 @@
import { createVNode, render } from 'vue' import { createVNode, render } from 'vue'
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import { useCookies } from '@vueuse/integrations/useCookies' import { useCookies } from '@vueuse/integrations/useCookies'
import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue' import Authorization from '@/components/2FA/Authorization.vue'
import twoFA from '@/api/2fa' import twoFA from '@/api/2fa'
import { useUserStore } from '@/pinia' import { useUserStore } from '@/pinia'
const use2FAModal = () => { const use2FAModal = () => {
const refOTPAuthorization = ref<typeof OTPAuthorization>() const refOTPAuthorization = ref<typeof Authorization>()
const randomId = Math.random().toString(36).substring(2, 8) const randomId = Math.random().toString(36).substring(2, 8)
const { secureSessionId } = storeToRefs(useUserStore()) const { secureSessionId } = storeToRefs(useUserStore())
@ -22,11 +22,11 @@ const use2FAModal = () => {
} }
const open = async (): Promise<string> => { const open = async (): Promise<string> => {
const { enabled } = await twoFA.status() const twoFAStatus = await twoFA.status()
const { status: secureSessionStatus } = await twoFA.secure_session_status() const { status: secureSessionStatus } = await twoFA.secure_session_status()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!enabled) { if (!twoFAStatus.enabled) {
resolve('') resolve('')
return return
@ -80,9 +80,10 @@ const use2FAModal = () => {
}, },
}, { }, {
default: () => h( default: () => h(
OTPAuthorization, Authorization,
{ {
ref: refOTPAuthorization, ref: refOTPAuthorization,
twoFAStatus,
class: 'mt-3', class: 'mt-3',
onSubmitOTP: verifyOTP, onSubmitOTP: verifyOTP,
onSubmitSecureSessionID: setSessionId, onSubmitSecureSessionID: setSessionId,

View file

@ -29,14 +29,22 @@ const current = computed({
const languageAvailable = gettext.available const languageAvailable = gettext.available
const updateTitle = () => {
const name = route.meta.name as never as () => string
document.title = `${name()} | Nginx UI`
}
watch(current, v => { watch(current, v => {
loadTranslations(route) loadTranslations(route)
settings.set_language(v) settings.set_language(v)
gettext.current = v gettext.current = v
const name = route.meta.name as never as () => string updateTitle()
})
document.title = `${name()} | Nginx UI` onMounted(() => {
updateTitle()
}) })
function init() { function init() {

View file

@ -5,18 +5,29 @@ export const useUserStore = defineStore('user', {
token: '', token: '',
unreadCount: 0, unreadCount: 0,
secureSessionId: '', secureSessionId: '',
passkeyRawId: '',
}), }),
getters: { getters: {
is_login(state): boolean { isLogin(state): boolean {
return !!state.token return !!state.token
}, },
passkeyLoginAvailable(state): boolean {
return !!state.passkeyRawId
},
}, },
actions: { actions: {
passkeyLogin(rawId: string, token: string) {
this.passkeyRawId = rawId
this.login(token)
},
login(token: string) { login(token: string) {
this.token = token this.token = token
}, },
logout() { logout() {
this.token = '' this.token = ''
this.passkeyRawId = ''
this.secureSessionId = ''
this.unreadCount = 0
}, },
}, },
persist: true, persist: true,

View file

@ -325,9 +325,8 @@ router.beforeEach((to, _, next) => {
NProgress.start() NProgress.start()
const user = useUserStore() const user = useUserStore()
const { is_login } = user
if (to.meta.noAuth || is_login) if (to.meta.noAuth || user.isLogin)
next() next()
else else
next({ path: '/login', query: { next: to.fullPath } }) next({ path: '/login', query: { next: to.fullPath } })

View file

@ -8,8 +8,9 @@ import auth from '@/api/auth'
import install from '@/api/install' import install from '@/api/install'
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue' import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue' import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue' import Authorization from '@/components/2FA/Authorization.vue'
import gettext, { $gettext } from '@/gettext' import gettext, { $gettext } from '@/gettext'
import passkey from '@/api/passkey'
const thisYear = new Date().getFullYear() const thisYear = new Date().getFullYear()
@ -26,6 +27,7 @@ const enabled2FA = ref(false)
const refOTP = ref() const refOTP = ref()
const passcode = ref('') const passcode = ref('')
const recoveryCode = ref('') const recoveryCode = ref('')
const passkeyConfigStatus = ref(false)
const modelRef = reactive({ const modelRef = reactive({
username: '', username: '',
@ -49,7 +51,7 @@ const rulesRef = reactive({
const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef) const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
const userStore = useUserStore() const userStore = useUserStore()
const { login } = userStore const { login, passkeyLogin } = userStore
const { secureSessionId } = storeToRefs(userStore) const { secureSessionId } = storeToRefs(userStore)
const onSubmit = () => { const onSubmit = () => {
@ -97,7 +99,7 @@ const onSubmit = () => {
const user = useUserStore() const user = useUserStore()
if (user.is_login) { if (user.isLogin) {
const next = (route.query?.next || '').toString() || '/dashboard' const next = (route.query?.next || '').toString() || '/dashboard'
router.push(next) router.push(next)
@ -147,8 +149,13 @@ function handleOTPSubmit(code: string, recovery: string) {
onSubmit() onSubmit()
}) })
} }
passkey.get_config_status().then(r => {
passkeyConfigStatus.value = r.status
})
const passkeyLoginLoading = ref(false) const passkeyLoginLoading = ref(false)
async function passkeyLogin() { async function handlePasskeyLogin() {
passkeyLoginLoading.value = true passkeyLoginLoading.value = true
try { try {
const begin = await auth.begin_passkey_login() const begin = await auth.begin_passkey_login()
@ -162,7 +169,7 @@ async function passkeyLogin() {
if (r.token) { if (r.token) {
const next = (route.query?.next || '').toString() || '/' const next = (route.query?.next || '').toString() || '/'
login(r.token) passkeyLogin(asseResp.rawId, r.token)
await router.push(next) await router.push(next)
} }
@ -217,9 +224,14 @@ async function passkeyLogin() {
</AButton> </AButton>
</template> </template>
<div v-else> <div v-else>
<OTPAuthorization <Authorization
ref="refOTP" ref="refOTP"
@on-submit="handleOTPSubmit" :two-f-a-status="{
enabled: true,
otp_status: true,
passkey_status: true,
}"
@submit-o-t-p="handleOTPSubmit"
/> />
</div> </div>
@ -235,7 +247,10 @@ async function passkeyLogin() {
{{ $gettext('Login') }} {{ $gettext('Login') }}
</AButton> </AButton>
<div class="flex flex-col justify-center"> <div
v-if="passkeyConfigStatus"
class="flex flex-col justify-center"
>
<ADivider> <ADivider>
<div class="text-sm font-normal opacity-75"> <div class="text-sm font-normal opacity-75">
{{ $gettext('Or') }} {{ $gettext('Or') }}
@ -244,7 +259,7 @@ async function passkeyLogin() {
<AButton <AButton
:loading="passkeyLoginLoading" :loading="passkeyLoginLoading"
@click="passkeyLogin" @click="handlePasskeyLogin"
> >
<KeyOutlined /> <KeyOutlined />
{{ $gettext('Sign in with a passkey') }} {{ $gettext('Sign in with a passkey') }}

View file

@ -2,13 +2,12 @@
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import PasskeyRegistration from './components/Passkey.vue'
import type { BannedIP } from '@/api/settings' import type { BannedIP } from '@/api/settings'
import setting from '@/api/settings' import setting from '@/api/settings'
import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer' import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
import type { Settings } from '@/views/preference/typedef' import type { Settings } from '@/views/preference/typedef'
import TOTP from '@/views/preference/components/TOTP.vue' import TOTP from '@/views/preference/components/TOTP.vue'
import PasskeyRegistration from '@/components/Passkey/PasskeyRegistration.vue'
import { $gettext } from '@/gettext'
const data: Settings = inject('data') as Settings const data: Settings = inject('data') as Settings

View file

@ -8,10 +8,11 @@ import { formatDateTime } from '@/lib/helper'
import type { Passkey } from '@/api/passkey' import type { Passkey } from '@/api/passkey'
import passkey from '@/api/passkey' import passkey from '@/api/passkey'
import ReactiveFromNow from '@/components/ReactiveFromNow/ReactiveFromNow.vue' import ReactiveFromNow from '@/components/ReactiveFromNow/ReactiveFromNow.vue'
import { $gettext } from '@/gettext' import { useUserStore } from '@/pinia'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const user = useUserStore()
const passkeyName = ref('') const passkeyName = ref('')
const addPasskeyModelOpen = ref(false) const addPasskeyModelOpen = ref(false)
@ -29,6 +30,8 @@ async function registerPasskey() {
message.success($gettext('Register passkey successfully')) message.success($gettext('Register passkey successfully'))
addPasskeyModelOpen.value = false addPasskeyModelOpen.value = false
user.passkeyRawId = attestationResponse.rawId
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
catch (e: any) { catch (e: any) {
@ -66,10 +69,14 @@ function update(id: number, record: Passkey) {
}) })
} }
function remove(id: number) { function remove(item: Passkey) {
passkey.remove(id).then(() => { passkey.remove(item.id).then(() => {
getList() getList()
message.success($gettext('Remove successfully')) message.success($gettext('Remove successfully'))
// if current passkey is removed, clear it from user store
if (user.passkeyLoginAvailable && user.passkeyRawId === item.raw_id)
user.passkeyRawId = ''
}).catch((e: { message?: string }) => { }).catch((e: { message?: string }) => {
message.error(e?.message ?? $gettext('Server error')) message.error(e?.message ?? $gettext('Server error'))
}) })
@ -83,19 +90,31 @@ function addPasskey() {
<template> <template>
<div> <div>
<div class="flex justify-between items-center"> <div>
<h3 class="mb-0"> <h3>
{{ $gettext('Passkey') }} {{ $gettext('Passkey') }}
</h3> </h3>
<AButton @click="addPasskey"> <p>
{{ $gettext('Add a passkey') }} {{ $gettext('Passkeys are webauthn credentials that validate your identity using touch, '
</AButton> + 'facial recognition, a device password, or a PIN. '
+ 'They can be used as a password replacement or as a 2FA method.') }}
</p>
</div> </div>
<AList <AList
class="mt-4" class="mt-4"
bordered bordered
:data-source="data" :data-source="data"
> >
<template #header>
<div class="flex items-center justify-between">
<div class="font-bold">
{{ $gettext('Your passkeys') }}
</div>
<AButton @click="addPasskey">
{{ $gettext('Add a passkey') }}
</AButton>
</div>
</template>
<template #renderItem="{ item, index }"> <template #renderItem="{ item, index }">
<AListItem> <AListItem>
<AListItemMeta> <AListItemMeta>
@ -127,7 +146,7 @@ function addPasskey() {
<APopconfirm <APopconfirm
:title="$gettext('Are you sure to delete this passkey immediately?')" :title="$gettext('Are you sure to delete this passkey immediately?')"
@confirm="() => remove(item.id)" @confirm="() => remove(item)"
> >
<AButton <AButton
type="link" type="link"

View file

@ -4,7 +4,7 @@ import { CheckCircleOutlined } from '@ant-design/icons-vue'
import { UseClipboard } from '@vueuse/components' import { UseClipboard } from '@vueuse/components'
import otp from '@/api/otp' import otp from '@/api/otp'
import OTPInput from '@/components/OTPInput/OTPInput.vue' import OTPInput from '@/components/OTPInput/OTPInput.vue'
import { $gettext } from '@/gettext'
import twoFA from '@/api/2fa' import twoFA from '@/api/2fa'
const status = ref(false) const status = ref(false)