diff --git a/api/user/2fa.go b/api/user/2fa.go index d9900a024..df26b12b5 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 108a5f4a2..0232cb921 100644 --- a/api/user/otp.go +++ b/api/user/otp.go @@ -1,22 +1,20 @@ package user import ( - "bytes" - "crypto/sha1" - "encoding/base64" - "encoding/hex" "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" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" "github.com/uozi-tech/cosy" - "image/jpeg" - "net/http" - "strings" ) func GenerateTOTP(c *gin.Context) { @@ -38,27 +36,9 @@ func GenerateTOTP(c *gin.Context) { 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 - } - - // Convert the buffer to a base64 string - base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes()) - c.JSON(http.StatusOK, gin.H{ - "secret": otpKey.Secret(), - "qr_code": base64Str, + "secret": otpKey.Secret(), + "url": otpKey.URL(), }) } @@ -78,22 +58,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 @@ -106,37 +86,25 @@ func EnrollTOTP(c *gin.Context) { return } - recoveryCode := sha1.Sum(ciphertext) + t := time.Now().Unix() + recoveryCodes := model.RecoveryCodes{Codes: generateRecoveryCodes(16), LastViewed: &t} + cUser.RecoveryCodes = recoveryCodes + _, err = u.Where(u.ID.Eq(cUser.ID)).Updates(cUser) + if err != nil { + api.ErrHandler(c, err) + return + } - c.JSON(http.StatusOK, gin.H{ - "message": "ok", - "recovery_code": hex.EncodeToString(recoveryCode[:]), + c.JSON(http.StatusOK, RecoveryCodesResponse{ + Message: "ok", + RecoveryCodes: recoveryCodes, }) } 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) - 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.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 000000000..81ac8f39f --- /dev/null +++ b/api/user/recovery.go @@ -0,0 +1,72 @@ +package user + +import ( + "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 with a dash in the middle + return fmt.Sprintf("%05x-%05x", rand.Intn(0x100000), rand.Intn(0x100000)) +} + +func generateRecoveryCodes(count int) []*model.RecoveryCode { + recoveryCodes := make([]*model.RecoveryCode, count) + for i := 0; i < count; i++ { + recoveryCodes[i] = &model.RecoveryCode{ + Code: generateRecoveryCode(), + } + } + return recoveryCodes +} + +func ViewRecoveryCodes(c *gin.Context) { + user := api.CurrentUser(c) + + // update last viewed time + u := query.User + t := time.Now().Unix() + 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().Unix() + recoveryCodes := model.RecoveryCodes{Codes: generateRecoveryCodes(16), LastViewed: &t} + user.RecoveryCodes = recoveryCodes + + u := query.User + _, 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: recoveryCodes, + }) +} diff --git a/api/user/router.go b/api/user/router.go index ee390dc38..38dce1a91 100644 --- a/api/user/router.go +++ b/api/user/router.go @@ -27,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) @@ -35,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/components.d.ts b/app/components.d.ts index 644e4f401..7defeaea6 100644 --- a/app/components.d.ts +++ b/app/components.d.ts @@ -49,6 +49,7 @@ declare module 'vue' { APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] APopover: typeof import('ant-design-vue/es')['Popover'] AProgress: typeof import('ant-design-vue/es')['Progress'] + AQrcode: typeof import('ant-design-vue/es')['QRCode'] ARadioButton: typeof import('ant-design-vue/es')['RadioButton'] ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] AResult: typeof import('ant-design-vue/es')['Result'] diff --git a/app/src/api/2fa.ts b/app/src/api/2fa.ts index 6f13de5dd..16850ffa4 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 75672a0b5..efa30c6c4 100644 --- a/app/src/api/otp.ts +++ b/app/src/api/otp.ts @@ -1,19 +1,20 @@ +import type { RecoveryCodesResponse } from '@/api/recovery' import http from '@/lib/http' export interface OTPGenerateSecretResponse { secret: string - qr_code: string + url: string } 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 000000000..b07c25b0f --- /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 d240f72d8..fd9865ca9 100644 --- a/app/src/components/TwoFA/Authorization.vue +++ b/app/src/components/TwoFA/Authorization.vue @@ -1,5 +1,5 @@ diff --git a/internal/crypto/aes.go b/internal/crypto/aes.go index fdbf44283..87ef71e46 100644 --- a/internal/crypto/aes.go +++ b/internal/crypto/aes.go @@ -1,12 +1,17 @@ package crypto import ( + "context" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" - "github.com/0xJacky/Nginx-UI/settings" + "encoding/json" "io" + "reflect" + + "github.com/0xJacky/Nginx-UI/settings" + "gorm.io/gorm/schema" ) // AesEncrypt encrypts text and given key with AES. @@ -55,3 +60,49 @@ func AesDecrypt(text []byte) ([]byte, error) { return data, nil } + +type JSONAesSerializer struct{} + +func (JSONAesSerializer) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue interface{}) (err error) { + fieldValue := reflect.New(field.FieldType) + + if dbValue != nil { + var bytes []byte + switch v := dbValue.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + bytes, err = json.Marshal(v) + if err != nil { + return err + } + } + + if len(bytes) > 0 { + bytes, err = AesDecrypt(bytes) + if err != nil { + return err + } + err = json.Unmarshal(bytes, fieldValue.Interface()) + } + } + + field.ReflectValueOf(ctx, dst).Set(fieldValue.Elem()) + return +} + +// Value implements serializer interface +func (JSONAesSerializer) Value(ctx context.Context, field *schema.Field, dst reflect.Value, fieldValue interface{}) (interface{}, error) { + result, err := json.Marshal(fieldValue) + if string(result) == "null" { + if field.TagSettings["NOT NULL"] != "" { + return "", nil + } + return nil, err + } + + encrypt, err := AesEncrypt(result) + return string(encrypt), err +} diff --git a/internal/user/errors.go b/internal/user/errors.go index a39ae32b1..6969ef761 100644 --- a/internal/user/errors.go +++ b/internal/user/errors.go @@ -8,6 +8,7 @@ var ( ErrUserBanned = e.New(40303, "user banned") ErrOTPCode = e.New(40304, "invalid otp code") ErrRecoveryCode = e.New(40305, "invalid recovery code") + ErrTOTPNotEnabled = e.New(40306, "legacy recovery code not allowed since totp is not enabled") ErrWebAuthnNotConfigured = e.New(50000, "WebAuthn settings are not configured") ErrUserNotEnabledOTPAs2FA = e.New(50001, "user not enabled otp as 2fa") ErrOTPOrRecoveryCodeEmpty = e.New(50002, "otp or recovery code empty") diff --git a/internal/user/otp.go b/internal/user/otp.go index 763c33fe4..e6ec10879 100644 --- a/internal/user/otp.go +++ b/internal/user/otp.go @@ -5,12 +5,15 @@ import ( "crypto/sha1" "encoding/hex" "fmt" + "time" + "github.com/0xJacky/Nginx-UI/internal/cache" "github.com/0xJacky/Nginx-UI/internal/crypto" + "github.com/0xJacky/Nginx-UI/internal/notification" "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" "github.com/google/uuid" "github.com/pquerna/otp/totp" - "time" ) func VerifyOTP(user *model.User, otp, recoveryCode string) (err error) { @@ -24,14 +27,50 @@ func VerifyOTP(user *model.User, otp, recoveryCode string) (err error) { return ErrOTPCode } } else { - recoverCode, err := hex.DecodeString(recoveryCode) + // get user from db + u := query.User + user, err = u.Where(u.ID.Eq(user.ID)).First() if err != nil { return err } - k := sha1.Sum(user.OTPSecret) - if !bytes.Equal(k[:], recoverCode) { - return ErrRecoveryCode + + // legacy recovery code + if !user.RecoveryCodeGenerated() { + if user.OTPSecret == nil { + return ErrTOTPNotEnabled + } + + recoverCode, err := hex.DecodeString(recoveryCode) + if err != nil { + return err + } + k := sha1.Sum(user.OTPSecret) + if !bytes.Equal(k[:], recoverCode) { + return ErrRecoveryCode + } + } + + // check recovery code + usedCount := 0 + verified := false + for _, code := range user.RecoveryCodes.Codes { + if code.Code == recoveryCode && code.UsedTime == nil { + t := time.Now().Unix() + code.UsedTime = &t + _, err = u.Where(u.ID.Eq(user.ID)).Updates(user) + if err != nil { + return err + } + verified = true + } + if code.UsedTime != nil { + usedCount++ + } + } + if verified && usedCount == len(user.RecoveryCodes.Codes) { + notification.Warning("All Recovery Codes Have Been Used", "Please generate new recovery codes in the preferences immediately to prevent lockout.") } + return ErrRecoveryCode } return } diff --git a/model/user.go b/model/user.go index bb4cdf206..8cd3b3441 100644 --- a/model/user.go +++ b/model/user.go @@ -1,19 +1,37 @@ package model import ( + "github.com/0xJacky/Nginx-UI/internal/crypto" "github.com/go-webauthn/webauthn/webauthn" "github.com/spf13/cast" "gorm.io/gorm" + "gorm.io/gorm/schema" ) +func init() { + schema.RegisterSerializer("json[aes]", crypto.JSONAesSerializer{}) +} + +type RecoveryCode struct { + Code string `json:"code"` + UsedTime *int64 `json:"used_time,omitempty" gorm:"type:datetime;default:null"` +} + +type RecoveryCodes struct { + Codes []*RecoveryCode `json:"codes"` + LastViewed *int64 `json:"last_viewed,omitempty" gorm:"serializer:unixtime;type:datetime;default:null"` + LastDownloaded *int64 `json:"last_downloaded,omitempty" gorm:"serializer:unixtime;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[aes]"` + EnabledTwoFA bool `json:"enabled_2fa" gorm:"-"` } type AuthToken struct { @@ -35,6 +53,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 3a5d1701b..1dda7ce44 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 {