feat: login 2fa

This commit is contained in:
Jacky 2024-07-23 17:28:13 +08:00
parent 8d8ba150ef
commit 5abd9b75bb
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
33 changed files with 1063 additions and 122 deletions

View file

@ -3,6 +3,7 @@ package api
import (
"errors"
"github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/0xJacky/Nginx-UI/model"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"net/http"
@ -11,6 +12,10 @@ import (
"strings"
)
func CurrentUser(c *gin.Context) *model.Auth {
return c.MustGet("user").(*model.Auth)
}
func ErrHandler(c *gin.Context, err error) {
logger.GetLogger().Errorln(err)
c.JSON(http.StatusInternalServerError, gin.H{

View file

@ -16,14 +16,19 @@ import (
var mutex = &sync.Mutex{}
type LoginUser struct {
Name string `json:"name" binding:"required,max=255"`
Password string `json:"password" binding:"required,max=255"`
Name string `json:"name" binding:"required,max=255"`
Password string `json:"password" binding:"required,max=255"`
OTP string `json:"otp"`
RecoveryCode string `json:"recovery_code"`
}
const (
ErrPasswordIncorrect = 4031
ErrMaxAttempts = 4291
ErrUserBanned = 4033
Enabled2FA = 199
Error2FACode = 4034
LoginSuccess = 200
)
type LoginResponse struct {
@ -80,11 +85,32 @@ func Login(c *gin.Context) {
return
}
// Check if the user enables 2FA
if len(u.OTPSecret) > 0 {
if json.OTP == "" && json.RecoveryCode == "" {
c.JSON(http.StatusOK, LoginResponse{
Message: "The user has enabled 2FA",
Code: Enabled2FA,
})
user.BanIP(clientIP)
return
}
if err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
c.JSON(http.StatusForbidden, LoginResponse{
Message: "Invalid 2FA or recovery code",
Code: Error2FACode,
})
user.BanIP(clientIP)
return
}
}
// login success, clear banned record
_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
logger.Info("[User Login]", u.Name)
token, err := user.GenerateJWT(u.Name)
token, err := user.GenerateJWT(u)
if err != nil {
c.JSON(http.StatusInternalServerError, LoginResponse{
Message: err.Error(),
@ -93,6 +119,7 @@ func Login(c *gin.Context) {
}
c.JSON(http.StatusOK, LoginResponse{
Code: LoginSuccess,
Message: "ok",
Token: token,
})
@ -101,13 +128,7 @@ func Login(c *gin.Context) {
func Logout(c *gin.Context) {
token := c.GetHeader("Authorization")
if token != "" {
err := user.DeleteToken(token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"message": err.Error(),
})
return
}
user.DeleteToken(token)
}
c.JSON(http.StatusNoContent, nil)
}

View file

@ -65,7 +65,7 @@ func CasdoorCallback(c *gin.Context) {
return
}
userToken, err := user.GenerateJWT(u.Name)
userToken, err := user.GenerateJWT(u)
if err != nil {
api.ErrHandler(c, err)
return

163
api/user/otp.go Normal file
View file

@ -0,0 +1,163 @@
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/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) {
user := api.CurrentUser(c)
issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
issuer = strings.TrimSpace(issuer)
otpOpts := totp.GenerateOpts{
Issuer: issuer,
AccountName: user.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
}
// 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": base64.StdEncoding.EncodeToString(ciphertext),
"qr_code": base64Str,
})
}
func EnrollTOTP(c *gin.Context) {
user := api.CurrentUser(c)
if len(user.OTPSecret) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"message": "User already enrolled",
})
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
}
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
}
ciphertext, err := crypto.AesEncrypt(decrypted)
if err != nil {
api.ErrHandler(c, err)
return
}
u := query.Auth
_, err = u.Where(u.ID.Eq(user.ID)).Update(u.OTPSecret, ciphertext)
if err != nil {
api.ErrHandler(c, err)
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 !api.BindAndValid(c, &json) {
return
}
recoverCode, err := hex.DecodeString(json.RecoveryCode)
if err != nil {
api.ErrHandler(c, err)
return
}
user := api.CurrentUser(c)
k := sha1.Sum(user.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(user.ID)).UpdateSimple(u.OTPSecret.Null())
if err != nil {
api.ErrHandler(c, err)
return
}
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,
})
}

View file

@ -2,18 +2,25 @@ package user
import "github.com/gin-gonic/gin"
func InitAuthRouter(r *gin.RouterGroup) {
r.POST("/login", Login)
r.DELETE("/logout", Logout)
func InitAuthRouter(r *gin.RouterGroup) {
r.POST("/login", Login)
r.DELETE("/logout", Logout)
r.GET("/casdoor_uri", GetCasdoorUri)
r.POST("/casdoor_callback", CasdoorCallback)
r.GET("/casdoor_uri", GetCasdoorUri)
r.POST("/casdoor_callback", CasdoorCallback)
}
func InitManageUserRouter(r *gin.RouterGroup) {
r.GET("users", GetUsers)
r.GET("user/:id", GetUser)
r.POST("user", AddUser)
r.POST("user/:id", EditUser)
r.DELETE("user/:id", DeleteUser)
r.GET("users", GetUsers)
r.GET("user/:id", GetUser)
r.POST("user", AddUser)
r.POST("user/:id", EditUser)
r.DELETE("user/:id", DeleteUser)
}
func InitUserRouter(r *gin.RouterGroup) {
r.GET("/otp_status", OTPStatus)
r.GET("/otp_secret", GenerateTOTP)
r.POST("/otp_enroll", EnrollTOTP)
r.POST("/otp_reset", ResetOTP)
}