mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-11 02:15:48 +02:00
enhance: by default, passkey is used for 2fa if passkey is used to login
This commit is contained in:
parent
0a6a7693a1
commit
f42a6c2d08
13 changed files with 263 additions and 192 deletions
244
api/user/2fa.go
244
api/user/2fa.go
|
@ -1,156 +1,156 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/0xJacky/Nginx-UI/api"
|
||||
"github.com/0xJacky/Nginx-UI/internal/cache"
|
||||
"github.com/0xJacky/Nginx-UI/internal/passkey"
|
||||
"github.com/0xJacky/Nginx-UI/internal/user"
|
||||
"github.com/0xJacky/Nginx-UI/model"
|
||||
"github.com/0xJacky/Nginx-UI/query"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/google/uuid"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/0xJacky/Nginx-UI/api"
|
||||
"github.com/0xJacky/Nginx-UI/internal/cache"
|
||||
"github.com/0xJacky/Nginx-UI/internal/passkey"
|
||||
"github.com/0xJacky/Nginx-UI/internal/user"
|
||||
"github.com/0xJacky/Nginx-UI/model"
|
||||
"github.com/0xJacky/Nginx-UI/query"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/google/uuid"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Status2FA struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OTPStatus bool `json:"otp_status"`
|
||||
PasskeyStatus bool `json:"passkey_status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
OTPStatus bool `json:"otp_status"`
|
||||
PasskeyStatus bool `json:"passkey_status"`
|
||||
}
|
||||
|
||||
func get2FAStatus(c *gin.Context) (status Status2FA) {
|
||||
// when accessing the node from the main cluster, there is no user in the context
|
||||
u, ok := c.Get("user")
|
||||
if ok {
|
||||
userPtr := u.(*model.User)
|
||||
status.OTPStatus = userPtr.EnabledOTP()
|
||||
status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled()
|
||||
status.Enabled = status.OTPStatus || status.PasskeyStatus
|
||||
}
|
||||
return
|
||||
// when accessing the node from the main cluster, there is no user in the context
|
||||
u, ok := c.Get("user")
|
||||
if ok {
|
||||
userPtr := u.(*model.User)
|
||||
status.OTPStatus = userPtr.EnabledOTP()
|
||||
status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled()
|
||||
status.Enabled = status.OTPStatus || status.PasskeyStatus
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Get2FAStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, get2FAStatus(c))
|
||||
c.JSON(http.StatusOK, get2FAStatus(c))
|
||||
}
|
||||
|
||||
func SecureSessionStatus(c *gin.Context) {
|
||||
status2FA := get2FAStatus(c)
|
||||
if !status2FA.Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
status2FA := get2FAStatus(c)
|
||||
if !status2FA.Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ssid := c.GetHeader("X-Secure-Session-ID")
|
||||
if ssid == "" {
|
||||
ssid = c.Query("X-Secure-Session-ID")
|
||||
}
|
||||
if ssid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
ssid := c.GetHeader("X-Secure-Session-ID")
|
||||
if ssid == "" {
|
||||
ssid = c.Query("X-Secure-Session-ID")
|
||||
}
|
||||
if ssid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u := api.CurrentUser(c)
|
||||
u := api.CurrentUser(c)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": user.VerifySecureSessionID(ssid, u.ID),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": user.VerifySecureSessionID(ssid, u.ID),
|
||||
})
|
||||
}
|
||||
|
||||
func Start2FASecureSessionByOTP(c *gin.Context) {
|
||||
var json struct {
|
||||
OTP string `json:"otp"`
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
}
|
||||
if !api.BindAndValid(c, &json) {
|
||||
return
|
||||
}
|
||||
u := api.CurrentUser(c)
|
||||
if !u.EnabledOTP() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"message": "User has not configured OTP as 2FA",
|
||||
})
|
||||
return
|
||||
}
|
||||
var json struct {
|
||||
OTP string `json:"otp"`
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
}
|
||||
if !api.BindAndValid(c, &json) {
|
||||
return
|
||||
}
|
||||
u := api.CurrentUser(c)
|
||||
if !u.EnabledOTP() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"message": "User has not configured OTP as 2FA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if json.OTP == "" && json.RecoveryCode == "" {
|
||||
c.JSON(http.StatusBadRequest, LoginResponse{
|
||||
Message: "The user has enabled OTP as 2FA",
|
||||
})
|
||||
return
|
||||
}
|
||||
if json.OTP == "" && json.RecoveryCode == "" {
|
||||
c.JSON(http.StatusBadRequest, LoginResponse{
|
||||
Message: "The user has enabled OTP as 2FA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
|
||||
c.JSON(http.StatusBadRequest, LoginResponse{
|
||||
Message: "Invalid OTP or recovery code",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
|
||||
c.JSON(http.StatusBadRequest, LoginResponse{
|
||||
Message: "Invalid OTP or recovery code",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessionId := user.SetSecureSessionID(u.ID)
|
||||
sessionId := user.SetSecureSessionID(u.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sessionId,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
func BeginStart2FASecureSessionByPasskey(c *gin.Context) {
|
||||
if !passkey.Enabled() {
|
||||
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
|
||||
return
|
||||
}
|
||||
webauthnInstance := passkey.GetInstance()
|
||||
u := api.CurrentUser(c)
|
||||
options, sessionData, err := webauthnInstance.BeginLogin(u)
|
||||
if err != nil {
|
||||
api.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
passkeySessionID := uuid.NewString()
|
||||
cache.Set(passkeySessionID, sessionData, passkeyTimeout)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": passkeySessionID,
|
||||
"options": options,
|
||||
})
|
||||
if !passkey.Enabled() {
|
||||
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
|
||||
return
|
||||
}
|
||||
webauthnInstance := passkey.GetInstance()
|
||||
u := api.CurrentUser(c)
|
||||
options, sessionData, err := webauthnInstance.BeginLogin(u)
|
||||
if err != nil {
|
||||
api.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
passkeySessionID := uuid.NewString()
|
||||
cache.Set(passkeySessionID, sessionData, passkeyTimeout)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": passkeySessionID,
|
||||
"options": options,
|
||||
})
|
||||
}
|
||||
|
||||
func FinishStart2FASecureSessionByPasskey(c *gin.Context) {
|
||||
if !passkey.Enabled() {
|
||||
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
|
||||
return
|
||||
}
|
||||
passkeySessionID := c.GetHeader("X-Passkey-Session-ID")
|
||||
sessionDataBytes, ok := cache.Get(passkeySessionID)
|
||||
if !ok {
|
||||
api.ErrHandler(c, fmt.Errorf("session not found"))
|
||||
return
|
||||
}
|
||||
sessionData := sessionDataBytes.(*webauthn.SessionData)
|
||||
webauthnInstance := passkey.GetInstance()
|
||||
u := api.CurrentUser(c)
|
||||
credential, err := webauthnInstance.FinishLogin(u, *sessionData, c.Request)
|
||||
if err != nil {
|
||||
api.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
rawID := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=")
|
||||
p := query.Passkey
|
||||
_, _ = p.Where(p.RawID.Eq(rawID)).Updates(&model.Passkey{
|
||||
LastUsedAt: time.Now().Unix(),
|
||||
})
|
||||
if !passkey.Enabled() {
|
||||
api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
|
||||
return
|
||||
}
|
||||
passkeySessionID := c.GetHeader("X-Passkey-Session-ID")
|
||||
sessionDataBytes, ok := cache.Get(passkeySessionID)
|
||||
if !ok {
|
||||
api.ErrHandler(c, fmt.Errorf("session not found"))
|
||||
return
|
||||
}
|
||||
sessionData := sessionDataBytes.(*webauthn.SessionData)
|
||||
webauthnInstance := passkey.GetInstance()
|
||||
u := api.CurrentUser(c)
|
||||
credential, err := webauthnInstance.FinishLogin(u, *sessionData, c.Request)
|
||||
if err != nil {
|
||||
api.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
rawID := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=")
|
||||
p := query.Passkey
|
||||
_, _ = p.Where(p.RawID.Eq(rawID)).Updates(&model.Passkey{
|
||||
LastUsedAt: time.Now().Unix(),
|
||||
})
|
||||
|
||||
sessionId := user.SetSecureSessionID(u.ID)
|
||||
sessionId := user.SetSecureSessionID(u.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sessionId,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sessionId,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ func InitAuthRouter(r *gin.RouterGroup) {
|
|||
|
||||
r.GET("/casdoor_uri", GetCasdoorUri)
|
||||
r.POST("/casdoor_callback", CasdoorCallback)
|
||||
|
||||
r.GET("/passkeys/config", GetPasskeyConfigStatus)
|
||||
}
|
||||
|
||||
func InitManageUserRouter(r *gin.RouterGroup) {
|
||||
|
@ -38,7 +40,6 @@ func InitUserRouter(r *gin.RouterGroup) {
|
|||
r.GET("/begin_passkey_register", BeginPasskeyRegistration)
|
||||
r.POST("/finish_passkey_register", FinishPasskeyRegistration)
|
||||
|
||||
r.GET("/passkeys/config", GetPasskeyConfigStatus)
|
||||
r.GET("/passkeys", GetPasskeyList)
|
||||
r.POST("/passkeys/:id", UpdatePasskey)
|
||||
r.DELETE("/passkeys/:id", DeletePasskey)
|
||||
|
|
1
app/components.d.ts
vendored
1
app/components.d.ts
vendored
|
@ -8,6 +8,7 @@ export {}
|
|||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
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']
|
||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
|
||||
|
|
|
@ -5,6 +5,7 @@ import type { ModelBase } from '@/api/curd'
|
|||
export interface Passkey extends ModelBase {
|
||||
name: string
|
||||
user_id: string
|
||||
raw_id: string
|
||||
}
|
||||
|
||||
const passkey = {
|
||||
|
@ -27,8 +28,8 @@ const passkey = {
|
|||
remove(passkeyId: number) {
|
||||
return http.delete(`/passkeys/${passkeyId}`)
|
||||
},
|
||||
get_passkey_config_status(): Promise<{ status: boolean }> {
|
||||
return http.get('/passkey/config')
|
||||
get_config_status(): Promise<{ status: boolean }> {
|
||||
return http.get('/passkeys/config')
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -3,11 +3,17 @@ import { KeyOutlined } from '@ant-design/icons-vue'
|
|||
import { startAuthentication } from '@simplewebauthn/browser'
|
||||
import { message } from 'ant-design-vue'
|
||||
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||
import { $gettext } from '@/gettext'
|
||||
import type { TwoFAStatusResponse } from '@/api/2fa'
|
||||
import twoFA from '@/api/2fa'
|
||||
import { useUserStore } from '@/pinia'
|
||||
|
||||
defineProps<{
|
||||
twoFAStatus: TwoFAStatusResponse
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['submitOTP', 'submitSecureSessionID'])
|
||||
|
||||
const user = useUserStore()
|
||||
const refOTP = ref()
|
||||
const useRecoveryCode = ref(false)
|
||||
const passcode = ref('')
|
||||
|
@ -55,48 +61,58 @@ async function passkeyAuthenticate() {
|
|||
}
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (user.passkeyLoginAvailable)
|
||||
passkeyAuthenticate()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!useRecoveryCode">
|
||||
<p>{{ $gettext('Please enter the OTP code:') }}</p>
|
||||
<OTPInput
|
||||
ref="refOTP"
|
||||
v-model="passcode"
|
||||
class="justify-center mb-6"
|
||||
@on-complete="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
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
|
||||
<div v-if="twoFAStatus.otp_status">
|
||||
<div v-if="!useRecoveryCode">
|
||||
<p>{{ $gettext('Please enter the OTP code:') }}</p>
|
||||
<OTPInput
|
||||
ref="refOTP"
|
||||
v-model="passcode"
|
||||
class="justify-center mb-6"
|
||||
@on-complete="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@click="clickUseOTP"
|
||||
>{{ $gettext('Use OTP') }}</a>
|
||||
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
|
||||
@click="clickUseOTP"
|
||||
>{{ $gettext('Use OTP') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center">
|
||||
<ADivider>
|
||||
<div
|
||||
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">
|
||||
{{ $gettext('Or') }}
|
||||
</div>
|
|
@ -1,12 +1,12 @@
|
|||
import { createVNode, render } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
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 { useUserStore } from '@/pinia'
|
||||
|
||||
const use2FAModal = () => {
|
||||
const refOTPAuthorization = ref<typeof OTPAuthorization>()
|
||||
const refOTPAuthorization = ref<typeof Authorization>()
|
||||
const randomId = Math.random().toString(36).substring(2, 8)
|
||||
const { secureSessionId } = storeToRefs(useUserStore())
|
||||
|
||||
|
@ -22,11 +22,11 @@ const use2FAModal = () => {
|
|||
}
|
||||
|
||||
const open = async (): Promise<string> => {
|
||||
const { enabled } = await twoFA.status()
|
||||
const twoFAStatus = await twoFA.status()
|
||||
const { status: secureSessionStatus } = await twoFA.secure_session_status()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!enabled) {
|
||||
if (!twoFAStatus.enabled) {
|
||||
resolve('')
|
||||
|
||||
return
|
||||
|
@ -80,9 +80,10 @@ const use2FAModal = () => {
|
|||
},
|
||||
}, {
|
||||
default: () => h(
|
||||
OTPAuthorization,
|
||||
Authorization,
|
||||
{
|
||||
ref: refOTPAuthorization,
|
||||
twoFAStatus,
|
||||
class: 'mt-3',
|
||||
onSubmitOTP: verifyOTP,
|
||||
onSubmitSecureSessionID: setSessionId,
|
||||
|
|
|
@ -29,14 +29,22 @@ const current = computed({
|
|||
|
||||
const languageAvailable = gettext.available
|
||||
|
||||
const updateTitle = () => {
|
||||
const name = route.meta.name as never as () => string
|
||||
|
||||
document.title = `${name()} | Nginx UI`
|
||||
}
|
||||
|
||||
watch(current, v => {
|
||||
loadTranslations(route)
|
||||
settings.set_language(v)
|
||||
gettext.current = v
|
||||
|
||||
const name = route.meta.name as never as () => string
|
||||
updateTitle()
|
||||
})
|
||||
|
||||
document.title = `${name()} | Nginx UI`
|
||||
onMounted(() => {
|
||||
updateTitle()
|
||||
})
|
||||
|
||||
function init() {
|
||||
|
|
|
@ -5,18 +5,29 @@ export const useUserStore = defineStore('user', {
|
|||
token: '',
|
||||
unreadCount: 0,
|
||||
secureSessionId: '',
|
||||
passkeyRawId: '',
|
||||
}),
|
||||
getters: {
|
||||
is_login(state): boolean {
|
||||
isLogin(state): boolean {
|
||||
return !!state.token
|
||||
},
|
||||
passkeyLoginAvailable(state): boolean {
|
||||
return !!state.passkeyRawId
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
passkeyLogin(rawId: string, token: string) {
|
||||
this.passkeyRawId = rawId
|
||||
this.login(token)
|
||||
},
|
||||
login(token: string) {
|
||||
this.token = token
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.passkeyRawId = ''
|
||||
this.secureSessionId = ''
|
||||
this.unreadCount = 0
|
||||
},
|
||||
},
|
||||
persist: true,
|
||||
|
|
|
@ -325,9 +325,8 @@ router.beforeEach((to, _, next) => {
|
|||
NProgress.start()
|
||||
|
||||
const user = useUserStore()
|
||||
const { is_login } = user
|
||||
|
||||
if (to.meta.noAuth || is_login)
|
||||
if (to.meta.noAuth || user.isLogin)
|
||||
next()
|
||||
else
|
||||
next({ path: '/login', query: { next: to.fullPath } })
|
||||
|
|
|
@ -8,8 +8,9 @@ import auth from '@/api/auth'
|
|||
import install from '@/api/install'
|
||||
import SetLanguage from '@/components/SetLanguage/SetLanguage.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 passkey from '@/api/passkey'
|
||||
|
||||
const thisYear = new Date().getFullYear()
|
||||
|
||||
|
@ -26,6 +27,7 @@ const enabled2FA = ref(false)
|
|||
const refOTP = ref()
|
||||
const passcode = ref('')
|
||||
const recoveryCode = ref('')
|
||||
const passkeyConfigStatus = ref(false)
|
||||
|
||||
const modelRef = reactive({
|
||||
username: '',
|
||||
|
@ -49,7 +51,7 @@ const rulesRef = reactive({
|
|||
|
||||
const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
|
||||
const userStore = useUserStore()
|
||||
const { login } = userStore
|
||||
const { login, passkeyLogin } = userStore
|
||||
const { secureSessionId } = storeToRefs(userStore)
|
||||
|
||||
const onSubmit = () => {
|
||||
|
@ -97,7 +99,7 @@ const onSubmit = () => {
|
|||
|
||||
const user = useUserStore()
|
||||
|
||||
if (user.is_login) {
|
||||
if (user.isLogin) {
|
||||
const next = (route.query?.next || '').toString() || '/dashboard'
|
||||
|
||||
router.push(next)
|
||||
|
@ -147,8 +149,13 @@ function handleOTPSubmit(code: string, recovery: string) {
|
|||
onSubmit()
|
||||
})
|
||||
}
|
||||
|
||||
passkey.get_config_status().then(r => {
|
||||
passkeyConfigStatus.value = r.status
|
||||
})
|
||||
|
||||
const passkeyLoginLoading = ref(false)
|
||||
async function passkeyLogin() {
|
||||
async function handlePasskeyLogin() {
|
||||
passkeyLoginLoading.value = true
|
||||
try {
|
||||
const begin = await auth.begin_passkey_login()
|
||||
|
@ -162,7 +169,7 @@ async function passkeyLogin() {
|
|||
if (r.token) {
|
||||
const next = (route.query?.next || '').toString() || '/'
|
||||
|
||||
login(r.token)
|
||||
passkeyLogin(asseResp.rawId, r.token)
|
||||
|
||||
await router.push(next)
|
||||
}
|
||||
|
@ -217,9 +224,14 @@ async function passkeyLogin() {
|
|||
</AButton>
|
||||
</template>
|
||||
<div v-else>
|
||||
<OTPAuthorization
|
||||
<Authorization
|
||||
ref="refOTP"
|
||||
@on-submit="handleOTPSubmit"
|
||||
:two-f-a-status="{
|
||||
enabled: true,
|
||||
otp_status: true,
|
||||
passkey_status: true,
|
||||
}"
|
||||
@submit-o-t-p="handleOTPSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -235,7 +247,10 @@ async function passkeyLogin() {
|
|||
{{ $gettext('Login') }}
|
||||
</AButton>
|
||||
|
||||
<div class="flex flex-col justify-center">
|
||||
<div
|
||||
v-if="passkeyConfigStatus"
|
||||
class="flex flex-col justify-center"
|
||||
>
|
||||
<ADivider>
|
||||
<div class="text-sm font-normal opacity-75">
|
||||
{{ $gettext('Or') }}
|
||||
|
@ -244,7 +259,7 @@ async function passkeyLogin() {
|
|||
|
||||
<AButton
|
||||
:loading="passkeyLoginLoading"
|
||||
@click="passkeyLogin"
|
||||
@click="handlePasskeyLogin"
|
||||
>
|
||||
<KeyOutlined />
|
||||
{{ $gettext('Sign in with a passkey') }}
|
||||
|
|
|
@ -2,13 +2,12 @@
|
|||
import { message } from 'ant-design-vue'
|
||||
import type { Ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import PasskeyRegistration from './components/Passkey.vue'
|
||||
import type { BannedIP } from '@/api/settings'
|
||||
import setting from '@/api/settings'
|
||||
import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
|
||||
import type { Settings } from '@/views/preference/typedef'
|
||||
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
|
||||
|
||||
|
|
|
@ -8,10 +8,11 @@ import { formatDateTime } from '@/lib/helper'
|
|||
import type { Passkey } from '@/api/passkey'
|
||||
import passkey from '@/api/passkey'
|
||||
import ReactiveFromNow from '@/components/ReactiveFromNow/ReactiveFromNow.vue'
|
||||
import { $gettext } from '@/gettext'
|
||||
import { useUserStore } from '@/pinia'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const user = useUserStore()
|
||||
const passkeyName = ref('')
|
||||
const addPasskeyModelOpen = ref(false)
|
||||
|
||||
|
@ -29,6 +30,8 @@ async function registerPasskey() {
|
|||
|
||||
message.success($gettext('Register passkey successfully'))
|
||||
addPasskeyModelOpen.value = false
|
||||
|
||||
user.passkeyRawId = attestationResponse.rawId
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
catch (e: any) {
|
||||
|
@ -66,10 +69,14 @@ function update(id: number, record: Passkey) {
|
|||
})
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
passkey.remove(id).then(() => {
|
||||
function remove(item: Passkey) {
|
||||
passkey.remove(item.id).then(() => {
|
||||
getList()
|
||||
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 }) => {
|
||||
message.error(e?.message ?? $gettext('Server error'))
|
||||
})
|
||||
|
@ -83,19 +90,31 @@ function addPasskey() {
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="mb-0">
|
||||
<div>
|
||||
<h3>
|
||||
{{ $gettext('Passkey') }}
|
||||
</h3>
|
||||
<AButton @click="addPasskey">
|
||||
{{ $gettext('Add a passkey') }}
|
||||
</AButton>
|
||||
<p>
|
||||
{{ $gettext('Passkeys are webauthn credentials that validate your identity using touch, '
|
||||
+ 'facial recognition, a device password, or a PIN. '
|
||||
+ 'They can be used as a password replacement or as a 2FA method.') }}
|
||||
</p>
|
||||
</div>
|
||||
<AList
|
||||
class="mt-4"
|
||||
bordered
|
||||
: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 }">
|
||||
<AListItem>
|
||||
<AListItemMeta>
|
||||
|
@ -127,7 +146,7 @@ function addPasskey() {
|
|||
|
||||
<APopconfirm
|
||||
:title="$gettext('Are you sure to delete this passkey immediately?')"
|
||||
@confirm="() => remove(item.id)"
|
||||
@confirm="() => remove(item)"
|
||||
>
|
||||
<AButton
|
||||
type="link"
|
|
@ -4,7 +4,7 @@ import { CheckCircleOutlined } from '@ant-design/icons-vue'
|
|||
import { UseClipboard } from '@vueuse/components'
|
||||
import otp from '@/api/otp'
|
||||
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||
import { $gettext } from '@/gettext'
|
||||
|
||||
import twoFA from '@/api/2fa'
|
||||
|
||||
const status = ref(false)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue