mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-19 08:37:14 +00:00
959e6fd62b
CheckUser compared only gotp.Now(), so a code submitted at the end of its 30s window (or with slight client/server clock drift) failed with 'invalid 2fa code', while the immediate retry in the next window succeeded. Accept current +/-1 window, the standard TOTP skew tolerance. Fixes MHSanaei/3x-ui#6535
191 lines
5.2 KiB
Go
191 lines
5.2 KiB
Go
package panel
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/xlzd/gotp"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
|
ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
// UserService provides business logic for user management and authentication.
|
|
// It handles user creation, login, password management, and 2FA operations.
|
|
type UserService struct {
|
|
settingService service.SettingService
|
|
}
|
|
|
|
// GetFirstUser retrieves the first user from the database.
|
|
// This is typically used for initial setup or when there's only one admin user.
|
|
func (s *UserService) GetFirstUser() (*model.User, error) {
|
|
db := database.GetDB()
|
|
|
|
user := &model.User{}
|
|
err := db.Model(model.User{}).
|
|
First(user).
|
|
Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) (*model.User, error) {
|
|
db := database.GetDB()
|
|
|
|
user := &model.User{}
|
|
|
|
err := db.Model(model.User{}).
|
|
Where("username = ?", username).
|
|
First(user).
|
|
Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("invalid credentials")
|
|
} else if err != nil {
|
|
logger.Warning("check user err:", err)
|
|
return nil, err
|
|
}
|
|
|
|
if !crypto.CheckPasswordHash(user.Password, password) {
|
|
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
|
if !ldapEnabled {
|
|
return nil, errors.New("invalid credentials")
|
|
}
|
|
|
|
host, _ := s.settingService.GetLdapHost()
|
|
port, _ := s.settingService.GetLdapPort()
|
|
useTLS, _ := s.settingService.GetLdapUseTLS()
|
|
skipVerify, _ := s.settingService.GetLdapInsecureSkipVerify()
|
|
bindDN, _ := s.settingService.GetLdapBindDN()
|
|
ldapPass, _ := s.settingService.GetLdapPassword()
|
|
baseDN, _ := s.settingService.GetLdapBaseDN()
|
|
userFilter, _ := s.settingService.GetLdapUserFilter()
|
|
userAttr, _ := s.settingService.GetLdapUserAttr()
|
|
|
|
cfg := ldaputil.Config{
|
|
Host: host,
|
|
Port: port,
|
|
UseTLS: useTLS,
|
|
InsecureSkipVerify: skipVerify,
|
|
BindDN: bindDN,
|
|
Password: ldapPass,
|
|
BaseDN: baseDN,
|
|
UserFilter: userFilter,
|
|
UserAttr: userAttr,
|
|
}
|
|
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
|
if err != nil || !ok {
|
|
return nil, errors.New("invalid credentials")
|
|
}
|
|
}
|
|
|
|
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
|
if err != nil {
|
|
logger.Warning("check two factor err:", err)
|
|
return nil, err
|
|
}
|
|
|
|
if twoFactorEnable {
|
|
twoFactorToken, err := s.settingService.GetTwoFactorToken()
|
|
if err != nil {
|
|
logger.Warning("check two factor token err:", err)
|
|
return nil, err
|
|
}
|
|
|
|
if !verifyTOTPWithSkew(twoFactorToken, twoFactorCode) {
|
|
return nil, errors.New("invalid 2fa code")
|
|
}
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// totpSkewWindows is how many 30s steps around now are accepted. Client and
|
|
// server clocks are rarely perfectly in sync, and a code submitted at the end
|
|
// of its window may arrive after the server has rolled over — without skew
|
|
// the first attempt fails and the immediate retry (in the next window)
|
|
// succeeds, see #6535.
|
|
const totpSkewWindows = 1
|
|
|
|
// verifyTOTPWithSkew accepts the code for the current step plus/minus
|
|
// totpSkewWindows steps, the standard tolerance for TOTP clock drift.
|
|
func verifyTOTPWithSkew(secret, code string) bool {
|
|
totp := gotp.NewDefaultTOTP(secret)
|
|
now := time.Now()
|
|
for i := -totpSkewWindows; i <= totpSkewWindows; i++ {
|
|
if totp.AtTime(now.Add(time.Duration(i*30)*time.Second)) == code {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *UserService) BumpLoginEpoch() error {
|
|
db := database.GetDB()
|
|
return db.Model(model.User{}).
|
|
Where("1 = 1").
|
|
Update("login_epoch", gorm.Expr("login_epoch + 1")).
|
|
Error
|
|
}
|
|
|
|
func (s *UserService) UpdateUser(id int, username string, password string) error {
|
|
db := database.GetDB()
|
|
hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if twoFactorEnable {
|
|
_ = s.settingService.SetTwoFactorEnable(false)
|
|
_ = s.settingService.SetTwoFactorToken("")
|
|
}
|
|
|
|
return db.Model(model.User{}).
|
|
Where("id = ?", id).
|
|
Updates(map[string]any{
|
|
"username": username,
|
|
"password": hashedPassword,
|
|
"login_epoch": gorm.Expr("login_epoch + 1"),
|
|
}).
|
|
Error
|
|
}
|
|
|
|
func (s *UserService) UpdateFirstUser(username string, password string) error {
|
|
if username == "" {
|
|
return errors.New("username can not be empty")
|
|
} else if password == "" {
|
|
return errors.New("password can not be empty")
|
|
}
|
|
hashedPassword, er := crypto.HashPasswordAsBcrypt(password)
|
|
|
|
if er != nil {
|
|
return er
|
|
}
|
|
|
|
db := database.GetDB()
|
|
user := &model.User{}
|
|
err := db.Model(model.User{}).First(user).Error
|
|
if database.IsNotFound(err) {
|
|
user.Username = username
|
|
user.Password = hashedPassword
|
|
return db.Model(model.User{}).Create(user).Error
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
user.Username = username
|
|
user.Password = hashedPassword
|
|
user.LoginEpoch++
|
|
return db.Save(user).Error
|
|
}
|