From 11c733547f6537182b1baf61eb02fe137555cbaa Mon Sep 17 00:00:00 2001 From: Jacky Date: Fri, 26 Jul 2024 17:56:45 +0800 Subject: [PATCH] feat: save settings required 2fa if enabled otp --- api/settings/router.go | 3 +- api/user/otp.go | 367 +++++++++++++----------- api/user/router.go | 4 +- app/src/components/OTP/useOTPModal.ts | 8 +- app/src/views/preference/Preference.vue | 26 +- 5 files changed, 218 insertions(+), 190 deletions(-) diff --git a/api/settings/router.go b/api/settings/router.go index c74eb48d..13ea31b5 100644 --- a/api/settings/router.go +++ b/api/settings/router.go @@ -1,13 +1,14 @@ package settings import ( + "github.com/0xJacky/Nginx-UI/internal/middleware" "github.com/gin-gonic/gin" ) func InitRouter(r *gin.RouterGroup) { r.GET("settings/server/name", GetServerName) r.GET("settings", GetSettings) - r.POST("settings", SaveSettings) + r.POST("settings", middleware.RequireSecureSession(), SaveSettings) r.GET("settings/auth/banned_ips", GetBanLoginIP) r.DELETE("settings/auth/banned_ip", RemoveBannedIP) diff --git a/api/user/otp.go b/api/user/otp.go index 9bed1a9e..1818c5d4 100644 --- a/api/user/otp.go +++ b/api/user/otp.go @@ -1,215 +1,238 @@ package user import ( - "bytes" - "crypto/sha1" - "encoding/base64" - "encoding/hex" - "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/query" - "github.com/0xJacky/Nginx-UI/settings" - "github.com/gin-gonic/gin" - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" - "image/jpeg" - "net/http" - "strings" + "bytes" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "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/query" + "github.com/0xJacky/Nginx-UI/settings" + "github.com/gin-gonic/gin" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "image/jpeg" + "net/http" + "strings" ) func GenerateTOTP(c *gin.Context) { - u := api.CurrentUser(c) + u := api.CurrentUser(c) - issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name) - issuer = strings.TrimSpace(issuer) + issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name) + issuer = strings.TrimSpace(issuer) - otpOpts := totp.GenerateOpts{ - Issuer: issuer, - AccountName: u.Name, - Period: 30, // seconds - Digits: otp.DigitsSix, - Algorithm: otp.AlgorithmSHA1, - } - otpKey, err := totp.Generate(otpOpts) - if err != nil { - api.ErrHandler(c, err) - return - } - ciphertext, err := crypto.AesEncrypt([]byte(otpKey.Secret())) - if err != nil { - api.ErrHandler(c, err) - return - } + otpOpts := totp.GenerateOpts{ + Issuer: issuer, + AccountName: u.Name, + Period: 30, // seconds + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + } + otpKey, err := totp.Generate(otpOpts) + if err != nil { + api.ErrHandler(c, err) + return + } + ciphertext, err := crypto.AesEncrypt([]byte(otpKey.Secret())) + if err != nil { + api.ErrHandler(c, err) + return + } - qrCode, err := otpKey.Image(512, 512) - if err != nil { - api.ErrHandler(c, err) - return - } + qrCode, err := otpKey.Image(512, 512) + if err != nil { + api.ErrHandler(c, err) + return + } - // Encode the image to a buffer - var buf []byte - buffer := bytes.NewBuffer(buf) - err = jpeg.Encode(buffer, qrCode, nil) - if err != nil { - fmt.Println("Error encoding image:", err) - return - } + // Encode the image to a buffer + var buf []byte + buffer := bytes.NewBuffer(buf) + err = jpeg.Encode(buffer, qrCode, nil) + if err != nil { + fmt.Println("Error encoding image:", err) + return + } - // Convert the buffer to a base64 string - base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes()) + // Convert the buffer to a base64 string + base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes()) - c.JSON(http.StatusOK, gin.H{ - "secret": base64.StdEncoding.EncodeToString(ciphertext), - "qr_code": base64Str, - }) + c.JSON(http.StatusOK, gin.H{ + "secret": base64.StdEncoding.EncodeToString(ciphertext), + "qr_code": base64Str, + }) } func EnrollTOTP(c *gin.Context) { - cUser := api.CurrentUser(c) - if cUser.EnabledOTP() { - c.JSON(http.StatusBadRequest, gin.H{ - "message": "User already enrolled", - }) - return - } + cUser := api.CurrentUser(c) + if cUser.EnabledOTP() { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "User already enrolled", + }) + return + } - if settings.ServerSettings.Demo { - c.JSON(http.StatusBadRequest, gin.H{ - "message": "This feature is disabled in demo mode", - }) - return - } + if settings.ServerSettings.Demo { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "This feature is disabled in demo mode", + }) + return + } - var json struct { - Secret string `json:"secret" binding:"required"` - Passcode string `json:"passcode" binding:"required"` - } - if !api.BindAndValid(c, &json) { - return - } + var json struct { + Secret string `json:"secret" binding:"required"` + Passcode string `json:"passcode" binding:"required"` + } + if !api.BindAndValid(c, &json) { + return + } - secret, err := base64.StdEncoding.DecodeString(json.Secret) - if err != nil { - api.ErrHandler(c, err) - return - } + secret, err := base64.StdEncoding.DecodeString(json.Secret) + if err != nil { + api.ErrHandler(c, err) + return + } - decrypted, err := crypto.AesDecrypt(secret) - if err != nil { - api.ErrHandler(c, err) - return - } + decrypted, err := crypto.AesDecrypt(secret) + if err != nil { + api.ErrHandler(c, err) + return + } - if ok := totp.Validate(json.Passcode, string(decrypted)); !ok { - c.JSON(http.StatusNotAcceptable, gin.H{ - "message": "Invalid passcode", - }) - return - } + if ok := totp.Validate(json.Passcode, string(decrypted)); !ok { + c.JSON(http.StatusNotAcceptable, gin.H{ + "message": "Invalid passcode", + }) + return + } - ciphertext, err := crypto.AesEncrypt(decrypted) - if err != nil { - api.ErrHandler(c, err) - return - } + ciphertext, err := crypto.AesEncrypt(decrypted) + if err != nil { + api.ErrHandler(c, err) + return + } - u := query.Auth - _, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext) - if err != nil { - api.ErrHandler(c, err) - return - } + u := query.Auth + _, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext) + if err != nil { + api.ErrHandler(c, err) + return + } - recoveryCode := sha1.Sum(ciphertext) + recoveryCode := sha1.Sum(ciphertext) - c.JSON(http.StatusOK, gin.H{ - "message": "ok", - "recovery_code": hex.EncodeToString(recoveryCode[:]), - }) + c.JSON(http.StatusOK, gin.H{ + "message": "ok", + "recovery_code": hex.EncodeToString(recoveryCode[:]), + }) } func ResetOTP(c *gin.Context) { - var json struct { - RecoveryCode string `json:"recovery_code"` - } - if !api.BindAndValid(c, &json) { - return - } - recoverCode, err := hex.DecodeString(json.RecoveryCode) - if err != nil { - api.ErrHandler(c, err) - return - } - cUser := api.CurrentUser(c) - k := sha1.Sum(cUser.OTPSecret) - if !bytes.Equal(k[:], recoverCode) { - c.JSON(http.StatusBadRequest, gin.H{ - "message": "Invalid recovery code", - }) - return - } + var json struct { + RecoveryCode string `json:"recovery_code"` + } + if !api.BindAndValid(c, &json) { + return + } + recoverCode, err := hex.DecodeString(json.RecoveryCode) + if err != nil { + api.ErrHandler(c, err) + return + } + cUser := api.CurrentUser(c) + k := sha1.Sum(cUser.OTPSecret) + if !bytes.Equal(k[:], recoverCode) { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Invalid recovery code", + }) + return + } - u := query.Auth - _, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null()) - if err != nil { - api.ErrHandler(c, err) - return - } + u := query.Auth + _, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null()) + if err != nil { + api.ErrHandler(c, err) + return + } - c.JSON(http.StatusOK, gin.H{ - "message": "ok", - }) + c.JSON(http.StatusOK, gin.H{ + "message": "ok", + }) } func OTPStatus(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": len(api.CurrentUser(c).OTPSecret) > 0, - }) + c.JSON(http.StatusOK, gin.H{ + "status": len(api.CurrentUser(c).OTPSecret) > 0, + }) } func SecureSessionStatus(c *gin.Context) { - // if you can visit this endpoint, you are already in a secure session - c.JSON(http.StatusOK, gin.H{ - "status": true, - }) + cUser := api.CurrentUser(c) + if !cUser.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, cUser.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 - } + 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 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 - } + 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) + 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 fa2f9094..d5576733 100644 --- a/api/user/router.go +++ b/api/user/router.go @@ -1,7 +1,6 @@ package user import ( - "github.com/0xJacky/Nginx-UI/internal/middleware" "github.com/gin-gonic/gin" ) @@ -27,7 +26,6 @@ func InitUserRouter(r *gin.RouterGroup) { r.POST("/otp_enroll", EnrollTOTP) r.POST("/otp_reset", ResetOTP) - r.GET("/otp_secure_session_status", - middleware.RequireSecureSession(), SecureSessionStatus) + r.GET("/otp_secure_session_status", SecureSessionStatus) r.POST("/otp_secure_session", StartSecure2FASession) } diff --git a/app/src/components/OTP/useOTPModal.ts b/app/src/components/OTP/useOTPModal.ts index 8f1c2966..637cf5ab 100644 --- a/app/src/components/OTP/useOTPModal.ts +++ b/app/src/components/OTP/useOTPModal.ts @@ -23,6 +23,7 @@ const useOTPModal = () => { const open = async (): Promise => { const { status } = await otp.status() + const { status: secureSessionStatus } = await otp.secure_session_status() return new Promise((resolve, reject) => { if (!status) { @@ -33,13 +34,12 @@ const useOTPModal = () => { const cookies = useCookies(['nginx-ui-2fa']) const ssid = cookies.get('secure_session_id') - if (ssid) { + if (ssid && secureSessionStatus) { resolve(ssid) secureSessionId.value = ssid return } - injectStyles() let container: HTMLDivElement | null = document.createElement('div') document.body.appendChild(container) @@ -51,11 +51,11 @@ const useOTPModal = () => { } const verify = (passcode: string, recovery: string) => { - otp.start_secure_session(passcode, recovery).then(r => { + otp.start_secure_session(passcode, recovery).then(async r => { cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 }) - resolve(r.session_id) close() secureSessionId.value = r.session_id + resolve(r.session_id) }).catch(async () => { refOTPAuthorization.value?.clearInput() await message.error($gettext('Invalid passcode or recovery code')) diff --git a/app/src/views/preference/Preference.vue b/app/src/views/preference/Preference.vue index a80f5061..c731e898 100644 --- a/app/src/views/preference/Preference.vue +++ b/app/src/views/preference/Preference.vue @@ -11,6 +11,7 @@ import type { Settings } from '@/views/preference/typedef' import LogrotateSettings from '@/views/preference/LogrotateSettings.vue' import { useSettingsStore } from '@/pinia' import AuthSettings from '@/views/preference/AuthSettings.vue' +import useOTPModal from '@/components/OTP/useOTPModal' const data = ref({ server: { @@ -66,16 +67,21 @@ const refAuthSettings = ref() async function save() { // fix type data.value.server.http_challenge_port = data.value.server.http_challenge_port.toString() - settings.save(data.value).then(r => { - if (!settingsStore.is_remote) - server_name.value = r?.server?.name ?? '' - data.value = r - refAuthSettings.value?.getBannedIPs?.() - message.success($gettext('Save successfully')) - errors.value = {} - }).catch(e => { - errors.value = e.errors - message.error(e?.message ?? $gettext('Server error')) + + const otpModal = useOTPModal() + + otpModal.open().then(() => { + settings.save(data.value).then(r => { + if (!settingsStore.is_remote) + server_name.value = r?.server?.name ?? '' + data.value = r + refAuthSettings.value?.getBannedIPs?.() + message.success($gettext('Save successfully')) + errors.value = {} + }).catch(e => { + errors.value = e.errors + message.error(e?.message ?? $gettext('Server error')) + }) }) }