From 0d1f56a43e6c3dffe7adffd004b7bade6f7648ab Mon Sep 17 00:00:00 2001 From: Hintay Date: Sun, 9 Feb 2025 23:36:28 +0900 Subject: [PATCH] feat: new recovery codes --- api/user/2fa.go | 17 +- api/user/otp.go | 53 ++--- api/user/recovery.go | 81 +++++++ api/user/router.go | 10 +- app/src/api/2fa.ts | 6 +- app/src/api/otp.ts | 7 +- app/src/api/recovery.ts | 27 +++ app/src/components/TwoFA/Authorization.vue | 4 +- app/src/language/ar/app.po | 195 ++++++++++++----- app/src/language/en/app.po | 177 ++++++++++----- app/src/language/es/app.po | 199 ++++++++++++----- app/src/language/fr_FR/app.po | 172 ++++++++++----- app/src/language/ko_KR/app.po | 177 ++++++++++----- app/src/language/messages.pot | 153 ++++++++----- app/src/language/ru_RU/app.po | 199 ++++++++++++----- app/src/language/tr_TR/app.po | 202 +++++++++++++----- app/src/language/vi_VN/app.po | 177 ++++++++++----- app/src/language/zh_CN/app.po | 190 +++++++++++----- app/src/language/zh_TW/app.po | 194 ++++++++++++----- app/src/views/preference/AuthSettings.vue | 30 ++- .../preference/components/RecoveryCodes.vue | 172 +++++++++++++++ app/src/views/preference/components/TOTP.vue | 97 +++------ model/user.go | 32 ++- query/auths.gen.go | 24 ++- 24 files changed, 1882 insertions(+), 713 deletions(-) create mode 100644 api/user/recovery.go create mode 100644 app/src/api/recovery.ts create mode 100644 app/src/views/preference/components/RecoveryCodes.vue diff --git a/api/user/2fa.go b/api/user/2fa.go index d9900a02..df26b12b 100644 --- a/api/user/2fa.go +++ b/api/user/2fa.go @@ -2,6 +2,10 @@ package user import ( "encoding/base64" + "net/http" + "strings" + "time" + "github.com/0xJacky/Nginx-UI/api" "github.com/0xJacky/Nginx-UI/internal/cache" "github.com/0xJacky/Nginx-UI/internal/passkey" @@ -12,15 +16,14 @@ import ( "github.com/go-webauthn/webauthn/webauthn" "github.com/google/uuid" "github.com/uozi-tech/cosy" - "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"` + RecoveryCodesGenerated bool `json:"recovery_codes_generated"` + RecoveryCodesViewed bool `json:"recovery_codes_viewed"` } func get2FAStatus(c *gin.Context) (status Status2FA) { @@ -31,6 +34,8 @@ func get2FAStatus(c *gin.Context) (status Status2FA) { status.OTPStatus = userPtr.EnabledOTP() status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled() status.Enabled = status.OTPStatus || status.PasskeyStatus + status.RecoveryCodesGenerated = userPtr.RecoveryCodeGenerated() + status.RecoveryCodesViewed = userPtr.RecoveryCodeViewed() } return } diff --git a/api/user/otp.go b/api/user/otp.go index 66071ea6..5211091c 100644 --- a/api/user/otp.go +++ b/api/user/otp.go @@ -1,15 +1,15 @@ package user import ( - "bytes" - "crypto/sha1" - "encoding/hex" + "encoding/json" "fmt" "net/http" "strings" + "time" "github.com/0xJacky/Nginx-UI/api" "github.com/0xJacky/Nginx-UI/internal/crypto" + "github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/query" "github.com/0xJacky/Nginx-UI/settings" "github.com/gin-gonic/gin" @@ -59,22 +59,22 @@ func EnrollTOTP(c *gin.Context) { return } - var json struct { + var twoFA struct { Secret string `json:"secret" binding:"required"` Passcode string `json:"passcode" binding:"required"` } - if !cosy.BindAndValid(c, &json) { + if !cosy.BindAndValid(c, &twoFA) { return } - if ok := totp.Validate(json.Passcode, json.Secret); !ok { + if ok := totp.Validate(twoFA.Passcode, twoFA.Secret); !ok { c.JSON(http.StatusNotAcceptable, gin.H{ "message": "Invalid passcode", }) return } - ciphertext, err := crypto.AesEncrypt([]byte(json.Secret)) + ciphertext, err := crypto.AesEncrypt([]byte(twoFA.Secret)) if err != nil { api.ErrHandler(c, err) return @@ -87,37 +87,30 @@ func EnrollTOTP(c *gin.Context) { return } - recoveryCode := sha1.Sum(ciphertext) - - 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 !cosy.BindAndValid(c, &json) { - return - } - recoverCode, err := hex.DecodeString(json.RecoveryCode) + t := time.Now() + recoveryCodes := model.RecoveryCodes{Codes: generateRecoveryCodes(16), LastViewed: &t} + codesJson, err := json.Marshal(&recoveryCodes) 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", - }) + + _, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.RecoveryCodes, codesJson) + if err != nil { + api.ErrHandler(c, err) return } + c.JSON(http.StatusOK, RecoveryCodesResponse{ + Message: "ok", + RecoveryCodes: recoveryCodes, + }) +} + +func ResetOTP(c *gin.Context) { + cUser := api.CurrentUser(c) u := query.User - _, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null()) + _, err := u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null(), u.RecoveryCodes.Null()) if err != nil { api.ErrHandler(c, err) return diff --git a/api/user/recovery.go b/api/user/recovery.go new file mode 100644 index 00000000..8de41c04 --- /dev/null +++ b/api/user/recovery.go @@ -0,0 +1,81 @@ +package user + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http" + "time" + + "github.com/0xJacky/Nginx-UI/api" + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + "github.com/gin-gonic/gin" +) + +type RecoveryCodesResponse struct { + Message string `json:"message"` + model.RecoveryCodes +} + +func generateRecoveryCode() string { + // generate recovery code, 10 hex numbers + return fmt.Sprintf("%010x", rand.Intn(0x10000000000)) +} + +func generateRecoveryCodes(count int) []model.RecoveryCode { + recoveryCodes := make([]model.RecoveryCode, count) + for i := 0; i < count; i++ { + recoveryCodes[i].Code = generateRecoveryCode() + } + return recoveryCodes +} + +func ViewRecoveryCodes(c *gin.Context) { + user := api.CurrentUser(c) + + u := query.User + user, err := u.Where(u.ID.Eq(user.ID)).First() + if err != nil { + api.ErrHandler(c, err) + return + } + + // update last viewed time + t := time.Now() + user.RecoveryCodes.LastViewed = &t + _, err = u.Where(u.ID.Eq(user.ID)).Updates(user) + if err != nil { + api.ErrHandler(c, err) + return + } + + c.JSON(http.StatusOK, RecoveryCodesResponse{ + Message: "ok", + RecoveryCodes: user.RecoveryCodes, + }) +} + +func GenerateRecoveryCodes(c *gin.Context) { + user := api.CurrentUser(c) + + t := time.Now() + recoveryCodes := model.RecoveryCodes{Codes: generateRecoveryCodes(16), LastViewed: &t} + codesJson, err := json.Marshal(&recoveryCodes) + if err != nil { + api.ErrHandler(c, err) + return + } + + u := query.User + _, err = u.Where(u.ID.Eq(user.ID)).Update(u.RecoveryCodes, codesJson) + if err != nil { + api.ErrHandler(c, err) + return + } + + c.JSON(http.StatusOK, RecoveryCodesResponse{ + Message: "ok", + RecoveryCodes: recoveryCodes, + }) +} diff --git a/api/user/router.go b/api/user/router.go index 0222697b..684441cd 100644 --- a/api/user/router.go +++ b/api/user/router.go @@ -1,6 +1,7 @@ package user import ( + "github.com/0xJacky/Nginx-UI/internal/middleware" "github.com/gin-gonic/gin" ) @@ -26,7 +27,6 @@ func InitUserRouter(r *gin.RouterGroup) { r.GET("/otp_secret", GenerateTOTP) r.POST("/otp_enroll", EnrollTOTP) - r.POST("/otp_reset", ResetOTP) r.GET("/begin_passkey_register", BeginPasskeyRegistration) r.POST("/finish_passkey_register", FinishPasskeyRegistration) @@ -34,4 +34,12 @@ func InitUserRouter(r *gin.RouterGroup) { r.GET("/passkeys", GetPasskeyList) r.POST("/passkeys/:id", UpdatePasskey) r.DELETE("/passkeys/:id", DeletePasskey) + + o := r.Group("", middleware.RequireSecureSession()) + { + o.GET("/otp_reset", ResetOTP) + + o.GET("/recovery_codes", ViewRecoveryCodes) + o.GET("/recovery_codes_generate", GenerateRecoveryCodes) + } } diff --git a/app/src/api/2fa.ts b/app/src/api/2fa.ts index 6f13de5d..e5282a0a 100644 --- a/app/src/api/2fa.ts +++ b/app/src/api/2fa.ts @@ -1,14 +1,16 @@ import type { AuthenticationResponseJSON } from '@simplewebauthn/browser' import http from '@/lib/http' -export interface TwoFAStatusResponse { +export interface TwoFAStatus { enabled: boolean otp_status: boolean passkey_status: boolean + recovery_codes_generated: boolean + recovery_codes_viewed: boolean } const twoFA = { - status(): Promise { + status(): Promise { return http.get('/2fa_status') }, start_secure_session_by_otp(passcode: string, recovery_code: string): Promise<{ session_id: string }> { diff --git a/app/src/api/otp.ts b/app/src/api/otp.ts index 0bbe2c63..efa30c6c 100644 --- a/app/src/api/otp.ts +++ b/app/src/api/otp.ts @@ -1,3 +1,4 @@ +import type { RecoveryCodesResponse } from '@/api/recovery' import http from '@/lib/http' export interface OTPGenerateSecretResponse { @@ -9,11 +10,11 @@ const otp = { generate_secret(): Promise { return http.get('/otp_secret') }, - enroll_otp(secret: string, passcode: string): Promise<{ recovery_code: string }> { + enroll_otp(secret: string, passcode: string): Promise { return http.post('/otp_enroll', { secret, passcode }) }, - reset(recovery_code: string) { - return http.post('/otp_reset', { recovery_code }) + reset() { + return http.get('/otp_reset') }, } diff --git a/app/src/api/recovery.ts b/app/src/api/recovery.ts new file mode 100644 index 00000000..b07c25b0 --- /dev/null +++ b/app/src/api/recovery.ts @@ -0,0 +1,27 @@ +import http from '@/lib/http' + +export interface RecoveryCode { + code: string + used_time?: number +} + +export interface RecoveryCodes { + codes: RecoveryCode[] + last_viewed?: number + last_downloaded?: number +} + +export interface RecoveryCodesResponse extends RecoveryCodes { + message: string +} + +const recovery = { + generate(): Promise { + return http.get('/recovery_codes_generate') + }, + view(): Promise { + return http.get('/recovery_codes') + }, +} + +export default recovery diff --git a/app/src/components/TwoFA/Authorization.vue b/app/src/components/TwoFA/Authorization.vue index d240f72d..0eac5c7d 100644 --- a/app/src/components/TwoFA/Authorization.vue +++ b/app/src/components/TwoFA/Authorization.vue @@ -1,5 +1,5 @@ diff --git a/model/user.go b/model/user.go index bb4cdf20..e814ee18 100644 --- a/model/user.go +++ b/model/user.go @@ -1,19 +1,33 @@ package model import ( + "time" + "github.com/go-webauthn/webauthn/webauthn" "github.com/spf13/cast" "gorm.io/gorm" ) +type RecoveryCode struct { + Code string `json:"code"` + UsedTime *time.Time `json:"used_time,omitempty" gorm:"type:datetime;default:null"` +} + +type RecoveryCodes struct { + Codes []RecoveryCode `json:"codes"` + LastViewed *time.Time `json:"last_viewed,omitempty" gorm:"type:datetime;default:null"` + LastDownloaded *time.Time `json:"last_downloaded,omitempty" gorm:"type:datetime;default:null"` +} + type User struct { Model - Name string `json:"name" cosy:"add:max=20;update:omitempty,max=20;list:fussy;db_unique"` - Password string `json:"-" cosy:"json:password;add:required,max=20;update:omitempty,max=20"` - Status bool `json:"status" gorm:"default:1"` - OTPSecret []byte `json:"-" gorm:"type:blob"` - EnabledTwoFA bool `json:"enabled_2fa" gorm:"-"` + Name string `json:"name" cosy:"add:max=20;update:omitempty,max=20;list:fussy;db_unique"` + Password string `json:"-" cosy:"json:password;add:required,max=20;update:omitempty,max=20"` + Status bool `json:"status" gorm:"default:1"` + OTPSecret []byte `json:"-" gorm:"type:blob"` + RecoveryCodes RecoveryCodes `json:"-" gorm:"serializer:json"` + EnabledTwoFA bool `json:"enabled_2fa" gorm:"-"` } type AuthToken struct { @@ -35,6 +49,14 @@ func (u *User) EnabledOTP() bool { return len(u.OTPSecret) != 0 } +func (u *User) RecoveryCodeGenerated() bool { + return len(u.RecoveryCodes.Codes) > 0 +} + +func (u *User) RecoveryCodeViewed() bool { + return u.RecoveryCodes.LastViewed != nil +} + func (u *User) EnabledPasskey() bool { var passkeys Passkey db.Where("user_id", u.ID).Limit(1).Find(&passkeys) diff --git a/query/auths.gen.go b/query/auths.gen.go index 3a5d1701..1dda7ce4 100644 --- a/query/auths.gen.go +++ b/query/auths.gen.go @@ -36,6 +36,7 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) user { _user.Password = field.NewString(tableName, "password") _user.Status = field.NewBool(tableName, "status") _user.OTPSecret = field.NewBytes(tableName, "otp_secret") + _user.RecoveryCodes = field.NewField(tableName, "recovery_codes") _user.fillFieldMap() @@ -45,15 +46,16 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) user { type user struct { userDo - ALL field.Asterisk - ID field.Uint64 - CreatedAt field.Time - UpdatedAt field.Time - DeletedAt field.Field - Name field.String - Password field.String - Status field.Bool - OTPSecret field.Bytes + ALL field.Asterisk + ID field.Uint64 + CreatedAt field.Time + UpdatedAt field.Time + DeletedAt field.Field + Name field.String + Password field.String + Status field.Bool + OTPSecret field.Bytes + RecoveryCodes field.Field fieldMap map[string]field.Expr } @@ -78,6 +80,7 @@ func (u *user) updateTableName(table string) *user { u.Password = field.NewString(table, "password") u.Status = field.NewBool(table, "status") u.OTPSecret = field.NewBytes(table, "otp_secret") + u.RecoveryCodes = field.NewField(table, "recovery_codes") u.fillFieldMap() @@ -94,7 +97,7 @@ func (u *user) GetFieldByName(fieldName string) (field.OrderExpr, bool) { } func (u *user) fillFieldMap() { - u.fieldMap = make(map[string]field.Expr, 8) + u.fieldMap = make(map[string]field.Expr, 9) u.fieldMap["id"] = u.ID u.fieldMap["created_at"] = u.CreatedAt u.fieldMap["updated_at"] = u.UpdatedAt @@ -103,6 +106,7 @@ func (u *user) fillFieldMap() { u.fieldMap["password"] = u.Password u.fieldMap["status"] = u.Status u.fieldMap["otp_secret"] = u.OTPSecret + u.fieldMap["recovery_codes"] = u.RecoveryCodes } func (u user) clone(db *gorm.DB) user {