feat: new recovery codes

This commit is contained in:
Hintay 2025-02-09 23:36:28 +09:00
parent 3738bebca7
commit 0d1f56a43e
No known key found for this signature in database
GPG key ID: 120FC7FF121F2F2D
24 changed files with 1882 additions and 713 deletions

View file

@ -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
}

View file

@ -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

81
api/user/recovery.go Normal file
View file

@ -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,
})
}

View file

@ -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)
}
}