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
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
1
app/components.d.ts
vendored
|
@ -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']
|
||||||
|
|
|
@ -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')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
@ -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,
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 } })
|
||||||
|
|
|
@ -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') }}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"
|
|
@ -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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue