From f42a6c2d080078a09cb343899ae1aab521d74fb7 Mon Sep 17 00:00:00 2001 From: Jacky Date: Mon, 16 Sep 2024 13:57:31 +0800 Subject: [PATCH] enhance: by default, passkey is used for 2fa if passkey is used to login --- api/user/2fa.go | 244 +++++++++--------- api/user/router.go | 3 +- app/components.d.ts | 1 + app/src/api/passkey.ts | 5 +- ...2FAAuthorization.vue => Authorization.vue} | 88 ++++--- app/src/components/2FA/use2FAModal.ts | 11 +- .../components/SetLanguage/SetLanguage.vue | 12 +- app/src/pinia/moudule/user.ts | 13 +- app/src/routes/index.ts | 3 +- app/src/views/other/Login.vue | 33 ++- app/src/views/preference/AuthSettings.vue | 3 +- .../preference/components/Passkey.vue} | 37 ++- app/src/views/preference/components/TOTP.vue | 2 +- 13 files changed, 263 insertions(+), 192 deletions(-) rename app/src/components/2FA/{2FAAuthorization.vue => Authorization.vue} (58%) rename app/src/{components/Passkey/PasskeyRegistration.vue => views/preference/components/Passkey.vue} (82%) diff --git a/api/user/2fa.go b/api/user/2fa.go index 86dbece6..0becf485 100644 --- a/api/user/2fa.go +++ b/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, + }) } diff --git a/api/user/router.go b/api/user/router.go index 9c8d8b47..a53f34de 100644 --- a/api/user/router.go +++ b/api/user/router.go @@ -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) diff --git a/app/components.d.ts b/app/components.d.ts index 85fbd684..a4cfccfb 100644 --- a/app/components.d.ts +++ b/app/components.d.ts @@ -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'] diff --git a/app/src/api/passkey.ts b/app/src/api/passkey.ts index c6cc91e0..90895bcd 100644 --- a/app/src/api/passkey.ts +++ b/app/src/api/passkey.ts @@ -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') }, } diff --git a/app/src/components/2FA/2FAAuthorization.vue b/app/src/components/2FA/Authorization.vue similarity index 58% rename from app/src/components/2FA/2FAAuthorization.vue rename to app/src/components/2FA/Authorization.vue index 18dc0d32..d572e552 100644 --- a/app/src/components/2FA/2FAAuthorization.vue +++ b/app/src/components/2FA/Authorization.vue @@ -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() +})
-
@@ -235,7 +247,10 @@ async function passkeyLogin() { {{ $gettext('Login') }} -
+
{{ $gettext('Or') }} @@ -244,7 +259,7 @@ async function passkeyLogin() { {{ $gettext('Sign in with a passkey') }} diff --git a/app/src/views/preference/AuthSettings.vue b/app/src/views/preference/AuthSettings.vue index baf1edf9..9279e7be 100644 --- a/app/src/views/preference/AuthSettings.vue +++ b/app/src/views/preference/AuthSettings.vue @@ -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 diff --git a/app/src/components/Passkey/PasskeyRegistration.vue b/app/src/views/preference/components/Passkey.vue similarity index 82% rename from app/src/components/Passkey/PasskeyRegistration.vue rename to app/src/views/preference/components/Passkey.vue index cdc3d82d..10aeaade 100644 --- a/app/src/components/Passkey/PasskeyRegistration.vue +++ b/app/src/views/preference/components/Passkey.vue @@ -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() {