3x-ui/web/service/user.go

168 lines
4.1 KiB
Go
Raw Normal View History

2023-02-09 19:18:06 +00:00
package service
import (
"errors"
2026-05-10 00:13:42 +00:00
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/crypto"
ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
"github.com/xlzd/gotp"
2023-02-09 19:18:06 +00:00
"gorm.io/gorm"
)
2025-09-20 07:35:50 +00:00
// UserService provides business logic for user management and authentication.
// It handles user creation, login, password management, and 2FA operations.
type UserService struct {
settingService SettingService
}
2023-02-09 19:18:06 +00:00
2025-09-20 07:35:50 +00:00
// GetFirstUser retrieves the first user from the database.
// This is typically used for initial setup or when there's only one admin user.
2023-02-09 19:18:06 +00:00
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) {
2023-02-09 19:18:06 +00:00
db := database.GetDB()
user := &model.User{}
2023-02-09 19:18:06 +00:00
err := db.Model(model.User{}).
Where("username = ?", username).
2023-02-09 19:18:06 +00:00
First(user).
Error
if err == gorm.ErrRecordNotFound {
return nil, errors.New("invalid credentials")
2023-02-09 19:18:06 +00:00
} else if err != nil {
logger.Warning("check user err:", err)
return nil, err
2023-02-09 19:18:06 +00:00
}
2025-10-21 11:02:55 +00:00
if !crypto.CheckPasswordHash(user.Password, password) {
ldapEnabled, _ := s.settingService.GetLdapEnable()
if !ldapEnabled {
return nil, errors.New("invalid credentials")
2025-10-21 11:02:55 +00:00
}
host, _ := s.settingService.GetLdapHost()
port, _ := s.settingService.GetLdapPort()
useTLS, _ := s.settingService.GetLdapUseTLS()
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,
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")
2025-10-21 11:02:55 +00:00
}
}
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()
2023-02-09 19:18:06 +00:00
if err != nil {
logger.Warning("check two factor token err:", err)
return nil, err
}
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
2026-03-17 21:30:05 +00:00
return nil, errors.New("invalid 2fa code")
}
}
return user, nil
}
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 {
2024-03-12 17:15:44 +00:00
db := database.GetDB()
hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
2024-03-12 17:15:44 +00:00
if err != nil {
return err
2024-03-12 17:15:44 +00:00
}
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).
Security hardening: sessions, SSRF, CSP nonce, CSRF logout, trusted proxies (#4275) * refactor(session): store user ID in session instead of full struct Replaces storing the full User object in the session cookie with just the user ID. GetLoginUser now re-fetches the user from the database on every request so credential/permission changes take effect immediately without requiring a re-login. Includes a backward-compatible migration path for existing sessions that still carry the old struct payload. * feat(auth): block panel with default admin/admin credentials and guide credential change checkLogin middleware now detects default admin/admin credentials and redirects every panel route to /panel/settings until they are changed. The settings page auto-opens the Authentication tab, shows a non-dismissible error banner, and lists 'Default credentials' first in the security checklist. Login response includes mustChangeCredentials so the login page can redirect directly. Logout is now POST-only. Password must be at least 10 characters and cannot be admin/admin. * feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs Introduces AllSettingView which strips tgBotToken, twoFactorToken, ldapPassword, apiToken and warp/nord secrets before sending them to the browser, replacing them with boolean hasFoo presence flags. A new /panel/setting/secret endpoint allows updating individual secrets by key. Secrets that arrive blank on a save are preserved from the DB rather than overwritten. Adds TrustedProxyCIDRs as a configurable setting (defaults to localhost CIDRs). URL fields are validated before save. * fix(security): SSRF prevention, trusted-proxy header gating, CSP nonce, HTTP timeouts Adds SanitizeHTTPURL / SanitizePublicHTTPURL to reject private-range and loopback targets before any outbound HTTP request (node probe, xray download, outbound test, external traffic inform, tgbot API server, panel updater). Forwarded headers (X-Real-IP, X-Forwarded-For, X-Forwarded-Host) are now only trusted when the direct connection arrives from a CIDR in TrustedProxyCIDRs. CSP policy is tightened with a per-request nonce. HTTP server gains read/write/idle timeouts. Panel updater downloads the script to a temp file instead of piping curl into shell. Xray archive download adds a size cap and response-code check. backuptotgbot is changed from GET to POST. * feat(nodes): add allow-private-address toggle per node Adds AllowPrivateAddress to the Node model (DB default false). When enabled it bypasses the SSRF private-range check for that node's probe URL, allowing nodes hosted on RFC-1918 or loopback addresses (e.g. a private VPN or LAN setup). * chore: frontend UX improvements, CI pipeline, and dev tooling - AppSidebar: logout via POST /logout instead of navigating to GET - InboundList: persist filter state (search, protocol, node) to localStorage across page reloads; add protocol and node filter dropdowns - IndexPage: add health status strip (Xray, CPU, Memory, Update) with quick-action buttons - dependabot: weekly go mod and npm update schedule - ci.yml: add GitHub Actions workflow for build and vet - .nvmrc: pin Node 22 for local development - frontend: bump package.json and package-lock.json - SubPage, DnsPresetsModal, api-docs: minor fixes * fix(ci): stub web/dist before go list to satisfy go:embed at compile time * chore(ui): remove health-strip bar from dashboard top * Revert "feat(auth): block panel with default admin/admin credentials and guide credential change" This reverts commit 56ce6073ce09f08147f989858e0e88b3a4359546. * fix(auth): make logout POST+CSRF and propagate session loss to other tabs - Switch /logout from GET to POST with CSRFMiddleware so it matches the SPA's existing HttpUtil.post('/logout') call (previously 404'd silently) and blocks GET-based logout via image tags or link prefetchers. Handler now returns JSON; the SPA already navigates client-side. - Return 401 (instead of 404) from /panel/api/* when the caller is a browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor redirects to the login page on logout-in-another-tab, cookie expiry, and server restart. Anonymous callers still get 404 to keep endpoints hidden from casual scanners. - One-shot the 401 redirect in axios-init.js and hang the rejected promise so queued polls don't stack reloads or surface error toasts while the browser is navigating away. - Add the CSP nonce to the runtime-injected <script> in dist.go so the panel loads under the existing script-src 'nonce-...' policy. - Update api-docs endpoints.js: GET /logout doc entry was missing. * fix(settings): POST /logout after credential change * fix(auth): invalidate other sessions when credentials change When the admin changes username/password from one machine, sessions on every other machine kept working until they manually logged out because session storage is a signed client-side cookie — there is no server-side session list to revoke. Add a per-user LoginEpoch counter stamped into the session at login and re-verified on every authenticated request. UpdateUser and UpdateFirstUser bump the epoch (UpdateUser via gorm.Expr so a single update statement is atomic), so any cookie issued before the change no longer matches the user's current epoch and GetLoginUser returns nil — the SPA's 401 interceptor then redirects to the login page. Backward compatible: the column defaults to 0 and missing cookie values are treated as 0, so sessions issued before this change remain valid until the first credential update. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 10:52:52 +00:00
Updates(map[string]any{
"username": username,
"password": hashedPassword,
"login_epoch": gorm.Expr("login_epoch + 1"),
}).
Error
2024-03-12 17:15:44 +00:00
}
2023-02-09 19:18:06 +00:00
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
}
2023-02-09 19:18:06 +00:00
db := database.GetDB()
user := &model.User{}
err := db.Model(model.User{}).First(user).Error
if database.IsNotFound(err) {
user.Username = username
user.Password = hashedPassword
2023-02-09 19:18:06 +00:00
return db.Model(model.User{}).Create(user).Error
} else if err != nil {
return err
}
user.Username = username
user.Password = hashedPassword
Security hardening: sessions, SSRF, CSP nonce, CSRF logout, trusted proxies (#4275) * refactor(session): store user ID in session instead of full struct Replaces storing the full User object in the session cookie with just the user ID. GetLoginUser now re-fetches the user from the database on every request so credential/permission changes take effect immediately without requiring a re-login. Includes a backward-compatible migration path for existing sessions that still carry the old struct payload. * feat(auth): block panel with default admin/admin credentials and guide credential change checkLogin middleware now detects default admin/admin credentials and redirects every panel route to /panel/settings until they are changed. The settings page auto-opens the Authentication tab, shows a non-dismissible error banner, and lists 'Default credentials' first in the security checklist. Login response includes mustChangeCredentials so the login page can redirect directly. Logout is now POST-only. Password must be at least 10 characters and cannot be admin/admin. * feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs Introduces AllSettingView which strips tgBotToken, twoFactorToken, ldapPassword, apiToken and warp/nord secrets before sending them to the browser, replacing them with boolean hasFoo presence flags. A new /panel/setting/secret endpoint allows updating individual secrets by key. Secrets that arrive blank on a save are preserved from the DB rather than overwritten. Adds TrustedProxyCIDRs as a configurable setting (defaults to localhost CIDRs). URL fields are validated before save. * fix(security): SSRF prevention, trusted-proxy header gating, CSP nonce, HTTP timeouts Adds SanitizeHTTPURL / SanitizePublicHTTPURL to reject private-range and loopback targets before any outbound HTTP request (node probe, xray download, outbound test, external traffic inform, tgbot API server, panel updater). Forwarded headers (X-Real-IP, X-Forwarded-For, X-Forwarded-Host) are now only trusted when the direct connection arrives from a CIDR in TrustedProxyCIDRs. CSP policy is tightened with a per-request nonce. HTTP server gains read/write/idle timeouts. Panel updater downloads the script to a temp file instead of piping curl into shell. Xray archive download adds a size cap and response-code check. backuptotgbot is changed from GET to POST. * feat(nodes): add allow-private-address toggle per node Adds AllowPrivateAddress to the Node model (DB default false). When enabled it bypasses the SSRF private-range check for that node's probe URL, allowing nodes hosted on RFC-1918 or loopback addresses (e.g. a private VPN or LAN setup). * chore: frontend UX improvements, CI pipeline, and dev tooling - AppSidebar: logout via POST /logout instead of navigating to GET - InboundList: persist filter state (search, protocol, node) to localStorage across page reloads; add protocol and node filter dropdowns - IndexPage: add health status strip (Xray, CPU, Memory, Update) with quick-action buttons - dependabot: weekly go mod and npm update schedule - ci.yml: add GitHub Actions workflow for build and vet - .nvmrc: pin Node 22 for local development - frontend: bump package.json and package-lock.json - SubPage, DnsPresetsModal, api-docs: minor fixes * fix(ci): stub web/dist before go list to satisfy go:embed at compile time * chore(ui): remove health-strip bar from dashboard top * Revert "feat(auth): block panel with default admin/admin credentials and guide credential change" This reverts commit 56ce6073ce09f08147f989858e0e88b3a4359546. * fix(auth): make logout POST+CSRF and propagate session loss to other tabs - Switch /logout from GET to POST with CSRFMiddleware so it matches the SPA's existing HttpUtil.post('/logout') call (previously 404'd silently) and blocks GET-based logout via image tags or link prefetchers. Handler now returns JSON; the SPA already navigates client-side. - Return 401 (instead of 404) from /panel/api/* when the caller is a browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor redirects to the login page on logout-in-another-tab, cookie expiry, and server restart. Anonymous callers still get 404 to keep endpoints hidden from casual scanners. - One-shot the 401 redirect in axios-init.js and hang the rejected promise so queued polls don't stack reloads or surface error toasts while the browser is navigating away. - Add the CSP nonce to the runtime-injected <script> in dist.go so the panel loads under the existing script-src 'nonce-...' policy. - Update api-docs endpoints.js: GET /logout doc entry was missing. * fix(settings): POST /logout after credential change * fix(auth): invalidate other sessions when credentials change When the admin changes username/password from one machine, sessions on every other machine kept working until they manually logged out because session storage is a signed client-side cookie — there is no server-side session list to revoke. Add a per-user LoginEpoch counter stamped into the session at login and re-verified on every authenticated request. UpdateUser and UpdateFirstUser bump the epoch (UpdateUser via gorm.Expr so a single update statement is atomic), so any cookie issued before the change no longer matches the user's current epoch and GetLoginUser returns nil — the SPA's 401 interceptor then redirects to the login page. Backward compatible: the column defaults to 0 and missing cookie values are treated as 0, so sessions issued before this change remain valid until the first credential update. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 10:52:52 +00:00
user.LoginEpoch++
2023-02-09 19:18:06 +00:00
return db.Save(user).Error
}