From 0a6a7693a105c98077fd3cabe92f2d06cad25197 Mon Sep 17 00:00:00 2001 From: Jacky Date: Mon, 16 Sep 2024 11:18:14 +0800 Subject: [PATCH] feat: 2fa via passkey --- api/user/2fa.go | 156 ++++++++++++++++++ api/user/auth.go | 4 +- api/user/otp.go | 81 --------- api/user/passkey.go | 10 ++ api/user/router.go | 11 +- app/components.d.ts | 2 + app/src/api/2fa.ts | 37 +++++ app/src/api/otp.ts | 12 -- app/src/api/passkey.ts | 4 +- .../2FAAuthorization.vue} | 48 +++++- .../useOTPModal.ts => 2FA/use2FAModal.ts} | 33 ++-- app/src/lib/http/index.ts | 4 +- app/src/views/config/components/Mkdir.vue | 4 +- app/src/views/config/components/Rename.vue | 4 +- app/src/views/other/Login.vue | 4 +- app/src/views/preference/Preference.vue | 6 +- app/src/views/preference/components/TOTP.vue | 5 +- app/src/views/pty/Terminal.vue | 8 +- internal/user/otp.go | 2 +- model/auth.go | 25 +-- router/routers.go | 142 ++++++++-------- 21 files changed, 384 insertions(+), 218 deletions(-) create mode 100644 api/user/2fa.go create mode 100644 app/src/api/2fa.ts rename app/src/components/{OTP/OTPAuthorization.vue => 2FA/2FAAuthorization.vue} (50%) rename app/src/components/{OTP/useOTPModal.ts => 2FA/use2FAModal.ts} (72%) diff --git a/api/user/2fa.go b/api/user/2fa.go new file mode 100644 index 00000000..86dbece6 --- /dev/null +++ b/api/user/2fa.go @@ -0,0 +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" +) + +type Status2FA struct { + 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 +} + +func Get2FAStatus(c *gin.Context) { + 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 + } + + 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) + + 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 + } + + 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 + } + + sessionId := user.SetSecureSessionID(u.ID) + + 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, + }) +} + +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(), + }) + + sessionId := user.SetSecureSessionID(u.ID) + + c.JSON(http.StatusOK, gin.H{ + "session_id": sessionId, + }) +} diff --git a/api/user/auth.go b/api/user/auth.go index 40b8602b..28d618cd 100644 --- a/api/user/auth.go +++ b/api/user/auth.go @@ -8,6 +8,7 @@ import ( "github.com/0xJacky/Nginx-UI/settings" "github.com/gin-gonic/gin" "github.com/pkg/errors" + "math/rand/v2" "net/http" "sync" "time" @@ -67,7 +68,8 @@ func Login(c *gin.Context) { u, err := user.Login(json.Name, json.Password) if err != nil { - // time.Sleep(5 * time.Second) + random := time.Duration(rand.Int() % 10) + time.Sleep(random * time.Second) switch { case errors.Is(err, user.ErrPasswordIncorrect): c.JSON(http.StatusForbidden, LoginResponse{ diff --git a/api/user/otp.go b/api/user/otp.go index 997529c7..26ee3356 100644 --- a/api/user/otp.go +++ b/api/user/otp.go @@ -8,8 +8,6 @@ import ( "fmt" "github.com/0xJacky/Nginx-UI/api" "github.com/0xJacky/Nginx-UI/internal/crypto" - "github.com/0xJacky/Nginx-UI/internal/user" - "github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/query" "github.com/0xJacky/Nginx-UI/settings" "github.com/gin-gonic/gin" @@ -147,82 +145,3 @@ func ResetOTP(c *gin.Context) { "message": "ok", }) } - -func OTPStatus(c *gin.Context) { - status := false - u, ok := c.Get("user") - if ok { - status = u.(*model.User).EnabledOTP() - } - c.JSON(http.StatusOK, gin.H{ - "status": status, - }) -} - -func SecureSessionStatus(c *gin.Context) { - u, ok := c.Get("user") - if !ok || !u.(*model.User).EnabledOTP() { - 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 - } - - if user.VerifySecureSessionID(ssid, u.(*model.User).ID) { - c.JSON(http.StatusOK, gin.H{ - "status": true, - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "status": false, - }) -} - -func StartSecure2FASession(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 not configured with 2FA", - }) - return - } - - if json.OTP == "" && json.RecoveryCode == "" { - c.JSON(http.StatusBadRequest, LoginResponse{ - Message: "The user has enabled 2FA", - }) - return - } - - if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { - c.JSON(http.StatusBadRequest, LoginResponse{ - Message: "Invalid 2FA or recovery code", - }) - return - } - - sessionId := user.SetSecureSessionID(u.ID) - - c.JSON(http.StatusOK, gin.H{ - "session_id": sessionId, - }) -} diff --git a/api/user/passkey.go b/api/user/passkey.go index 3170e7dc..a79174db 100644 --- a/api/user/passkey.go +++ b/api/user/passkey.go @@ -27,6 +27,12 @@ func buildCachePasskeyRegKey(id int) string { return fmt.Sprintf("passkey-reg-%d", id) } +func GetPasskeyConfigStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": passkey.Enabled(), + }) +} + func BeginPasskeyRegistration(c *gin.Context) { u := api.CurrentUser(c) @@ -100,6 +106,10 @@ func BeginPasskeyLogin(c *gin.Context) { } func FinishPasskeyLogin(c *gin.Context) { + if !passkey.Enabled() { + api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured")) + return + } sessionId := c.GetHeader("X-Passkey-Session-ID") sessionDataBytes, ok := cache.Get(sessionId) if !ok { diff --git a/api/user/router.go b/api/user/router.go index 7cffa039..9c8d8b47 100644 --- a/api/user/router.go +++ b/api/user/router.go @@ -25,17 +25,20 @@ func InitManageUserRouter(r *gin.RouterGroup) { } func InitUserRouter(r *gin.RouterGroup) { - r.GET("/otp_status", OTPStatus) + r.GET("/2fa_status", Get2FAStatus) + r.GET("/2fa_secure_session/status", SecureSessionStatus) + r.POST("/2fa_secure_session/otp", Start2FASecureSessionByOTP) + r.GET("/2fa_secure_session/passkey", BeginStart2FASecureSessionByPasskey) + r.POST("/2fa_secure_session/passkey", FinishStart2FASecureSessionByPasskey) + r.GET("/otp_secret", GenerateTOTP) r.POST("/otp_enroll", EnrollTOTP) r.POST("/otp_reset", ResetOTP) - r.GET("/otp_secure_session_status", SecureSessionStatus) - r.POST("/otp_secure_session", StartSecure2FASession) - 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 f6e12d60..85fbd684 100644 --- a/app/components.d.ts +++ b/app/components.d.ts @@ -7,6 +7,8 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + 2FA2FAAuthorization: typeof import('./src/components/2FA/2FAAuthorization.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'] AAvatar: typeof import('ant-design-vue/es')['Avatar'] diff --git a/app/src/api/2fa.ts b/app/src/api/2fa.ts new file mode 100644 index 00000000..f0e4f17c --- /dev/null +++ b/app/src/api/2fa.ts @@ -0,0 +1,37 @@ +import type { AuthenticationResponseJSON } from '@simplewebauthn/types' +import http from '@/lib/http' + +export interface TwoFAStatusResponse { + enabled: boolean + otp_status: boolean + passkey_status: boolean +} + +const twoFA = { + status(): Promise { + return http.get('/2fa_status') + }, + start_secure_session_by_otp(passcode: string, recovery_code: string): Promise<{ session_id: string }> { + return http.post('/2fa_secure_session/otp', { + otp: passcode, + recovery_code, + }) + }, + secure_session_status(): Promise<{ status: boolean }> { + return http.get('/2fa_secure_session/status') + }, + begin_start_secure_session_by_passkey() { + return http.get('/2fa_secure_session/passkey') + }, + finish_start_secure_session_by_passkey(data: { session_id: string; options: AuthenticationResponseJSON }): Promise<{ + session_id: string + }> { + return http.post('/2fa_secure_session/passkey', data.options, { + headers: { + 'X-Passkey-Session-Id': data.session_id, + }, + }) + }, +} + +export default twoFA diff --git a/app/src/api/otp.ts b/app/src/api/otp.ts index 60c71ad3..75672a0b 100644 --- a/app/src/api/otp.ts +++ b/app/src/api/otp.ts @@ -6,9 +6,6 @@ export interface OTPGenerateSecretResponse { } const otp = { - status(): Promise<{ status: boolean }> { - return http.get('/otp_status') - }, generate_secret(): Promise { return http.get('/otp_secret') }, @@ -18,15 +15,6 @@ const otp = { reset(recovery_code: string) { return http.post('/otp_reset', { recovery_code }) }, - start_secure_session(passcode: string, recovery_code: string): Promise<{ session_id: string }> { - return http.post('/otp_secure_session', { - otp: passcode, - recovery_code, - }) - }, - secure_session_status() { - return http.get('/otp_secure_session_status') - }, } export default otp diff --git a/app/src/api/passkey.ts b/app/src/api/passkey.ts index a50c0644..c6cc91e0 100644 --- a/app/src/api/passkey.ts +++ b/app/src/api/passkey.ts @@ -27,8 +27,8 @@ const passkey = { remove(passkeyId: number) { return http.delete(`/passkeys/${passkeyId}`) }, - get_passkey_enabled() { - return http.get('/passkey_enabled') + get_passkey_config_status(): Promise<{ status: boolean }> { + return http.get('/passkey/config') }, } diff --git a/app/src/components/OTP/OTPAuthorization.vue b/app/src/components/2FA/2FAAuthorization.vue similarity index 50% rename from app/src/components/OTP/OTPAuthorization.vue rename to app/src/components/2FA/2FAAuthorization.vue index 840ebf99..18dc0d32 100644 --- a/app/src/components/OTP/OTPAuthorization.vue +++ b/app/src/components/2FA/2FAAuthorization.vue @@ -1,12 +1,18 @@ diff --git a/app/src/components/OTP/useOTPModal.ts b/app/src/components/2FA/use2FAModal.ts similarity index 72% rename from app/src/components/OTP/useOTPModal.ts rename to app/src/components/2FA/use2FAModal.ts index 637cf5ab..1ece82a2 100644 --- a/app/src/components/OTP/useOTPModal.ts +++ b/app/src/components/2FA/use2FAModal.ts @@ -1,11 +1,11 @@ import { createVNode, render } from 'vue' import { Modal, message } from 'ant-design-vue' import { useCookies } from '@vueuse/integrations/useCookies' -import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue' -import otp from '@/api/otp' +import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue' +import twoFA from '@/api/2fa' import { useUserStore } from '@/pinia' -const useOTPModal = () => { +const use2FAModal = () => { const refOTPAuthorization = ref() const randomId = Math.random().toString(36).substring(2, 8) const { secureSessionId } = storeToRefs(useUserStore()) @@ -22,11 +22,11 @@ const useOTPModal = () => { } const open = async (): Promise => { - const { status } = await otp.status() - const { status: secureSessionStatus } = await otp.secure_session_status() + const { enabled } = await twoFA.status() + const { status: secureSessionStatus } = await twoFA.secure_session_status() return new Promise((resolve, reject) => { - if (!status) { + if (!enabled) { resolve('') return @@ -50,12 +50,16 @@ const useOTPModal = () => { container = null } - const verify = (passcode: string, recovery: string) => { - otp.start_secure_session(passcode, recovery).then(async r => { - cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 }) - close() - secureSessionId.value = r.session_id - resolve(r.session_id) + const setSessionId = (sessionId: string) => { + cookies.set('secure_session_id', sessionId, { maxAge: 60 * 3 }) + close() + secureSessionId.value = sessionId + resolve(sessionId) + } + + const verifyOTP = (passcode: string, recovery: string) => { + twoFA.start_secure_session_by_otp(passcode, recovery).then(async r => { + setSessionId(r.session_id) }).catch(async () => { refOTPAuthorization.value?.clearInput() await message.error($gettext('Invalid passcode or recovery code')) @@ -80,7 +84,8 @@ const useOTPModal = () => { { ref: refOTPAuthorization, class: 'mt-3', - onOnSubmit: verify, + onSubmitOTP: verifyOTP, + onSubmitSecureSessionID: setSessionId, }, ), }) @@ -92,4 +97,4 @@ const useOTPModal = () => { return { open } } -export default useOTPModal +export default use2FAModal diff --git a/app/src/lib/http/index.ts b/app/src/lib/http/index.ts index a6ad4a7e..ce4c67a5 100644 --- a/app/src/lib/http/index.ts +++ b/app/src/lib/http/index.ts @@ -7,7 +7,7 @@ import { useSettingsStore, useUserStore } from '@/pinia' import 'nprogress/nprogress.css' import router from '@/routes' -import useOTPModal from '@/components/OTP/useOTPModal' +import use2FAModal from '@/components/2FA/use2FAModal' const user = useUserStore() const settings = useSettingsStore() @@ -61,7 +61,7 @@ instance.interceptors.response.use( async error => { NProgress.done() - const otpModal = useOTPModal() + const otpModal = use2FAModal() const cookies = useCookies(['nginx-ui-2fa']) switch (error.response.status) { case 401: diff --git a/app/src/views/config/components/Mkdir.vue b/app/src/views/config/components/Mkdir.vue index b485a7a7..bb8ce374 100644 --- a/app/src/views/config/components/Mkdir.vue +++ b/app/src/views/config/components/Mkdir.vue @@ -2,7 +2,7 @@ import { message } from 'ant-design-vue' import config from '@/api/config' -import useOTPModal from '@/components/OTP/useOTPModal' +import use2FAModal from '@/components/2FA/use2FAModal' const emit = defineEmits(['created']) const visible = ref(false) @@ -25,7 +25,7 @@ defineExpose({ function ok() { refForm.value.validate().then(() => { - const otpModal = useOTPModal() + const otpModal = use2FAModal() otpModal.open().then(() => { config.mkdir(data.value.basePath, data.value.name).then(() => { diff --git a/app/src/views/config/components/Rename.vue b/app/src/views/config/components/Rename.vue index a2fb2a8a..6946a140 100644 --- a/app/src/views/config/components/Rename.vue +++ b/app/src/views/config/components/Rename.vue @@ -1,7 +1,7 @@