mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
d440c2b932
* fix(panel): accept 2FA codes from adjacent TOTP windows 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 * fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode Move the +/-1 window helper to internal/util/totp so both 2FA acceptance points use it: login (CheckUser) and disable/rebind plus username/password changes (VerifyTwoFactorCode). Also shrink comments to the 2-line house rule and anchor the unit test mid-window to avoid a step-boundary flake. Addresses review on #6546 (MEDIUM + 2 LOWs). --------- Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
266 lines
7.8 KiB
Go
266 lines
7.8 KiB
Go
package service
|
|
|
|
import (
|
|
"path/filepath"
|
|
"regexp"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/xlzd/gotp"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func TestResetSettingsRegeneratesSubscriptionPaths(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
for key, value := range map[string]string{
|
|
"subPath": "/sub/",
|
|
"subJsonPath": "/json/",
|
|
"subClashPath": "/clash/",
|
|
"webPort": "8443",
|
|
} {
|
|
if err := s.saveSetting(key, value); err != nil {
|
|
t.Fatalf("save %s: %v", key, err)
|
|
}
|
|
}
|
|
|
|
if err := s.ResetSettings(); err != nil {
|
|
t.Fatalf("ResetSettings: %v", err)
|
|
}
|
|
|
|
pathPattern := regexp.MustCompile(`^/[0-9a-z]{16}/$`)
|
|
paths := map[string]string{}
|
|
for _, key := range []string{"subPath", "subJsonPath", "subClashPath"} {
|
|
value, err := s.getString(key)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", key, err)
|
|
}
|
|
if !pathPattern.MatchString(value) {
|
|
t.Errorf("%s = %q, want /<16 lowercase alphanumeric characters>/", key, value)
|
|
}
|
|
paths[key] = value
|
|
}
|
|
if paths["subPath"] == paths["subJsonPath"] || paths["subPath"] == paths["subClashPath"] || paths["subJsonPath"] == paths["subClashPath"] {
|
|
t.Fatalf("subscription paths must be distinct: %v", paths)
|
|
}
|
|
if port, err := s.GetPort(); err != nil || port != 2053 {
|
|
t.Fatalf("web port after reset = %d, %v; want 2053", port, err)
|
|
}
|
|
}
|
|
|
|
func setupSettingTestDB(t *testing.T) {
|
|
t.Helper()
|
|
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := database.CloseDB(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
view, err := s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" || view.DiscordBotToken != "" {
|
|
t.Fatalf("settings view leaked secrets: %#v", view)
|
|
}
|
|
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword || !view.HasDiscordBotToken {
|
|
t.Fatalf("settings view did not report configured secret flags: %#v", view)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
view, err := s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := &view.AllSetting
|
|
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
|
t.Fatalf("tg token = %q, want preserved secret", got)
|
|
}
|
|
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
|
t.Fatalf("ldap password = %q, want preserved secret", got)
|
|
}
|
|
if got, _ := s.GetTwoFactorToken(); got != "totp-secret" {
|
|
t.Fatalf("2fa token = %q, want preserved secret", got)
|
|
}
|
|
if got, _ := s.GetSmtpPassword(); got != "smtp-secret" {
|
|
t.Fatalf("smtp password = %q, want preserved secret", got)
|
|
}
|
|
if got, _ := s.GetDiscordBotToken(); got != "discord-secret" {
|
|
t.Fatalf("discord token = %q, want preserved secret", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
view, err := s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.GetSmtpPassword(); got != "" {
|
|
t.Fatalf("smtp password = %q, want cleared", got)
|
|
}
|
|
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
|
t.Fatalf("tg token = %q, unflagged secret must stay preserved", got)
|
|
}
|
|
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
|
t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got)
|
|
}
|
|
if got, _ := s.GetDiscordBotToken(); got != "discord-secret" {
|
|
t.Fatalf("discord token = %q, unflagged secret must stay preserved", got)
|
|
}
|
|
|
|
view, err = s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view.HasSmtpPassword {
|
|
t.Fatal("hasSmtpPassword must report false after clearing")
|
|
}
|
|
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true, DiscordBotToken: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.GetTgBotToken(); got != "" {
|
|
t.Fatalf("tg token = %q, want cleared", got)
|
|
}
|
|
if got, _ := s.GetLdapPassword(); got != "" {
|
|
t.Fatalf("ldap password = %q, want cleared", got)
|
|
}
|
|
if got, _ := s.GetDiscordBotToken(); got != "" {
|
|
t.Fatalf("discord token = %q, want cleared", got)
|
|
}
|
|
|
|
view, err = s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view.HasDiscordBotToken {
|
|
t.Fatal("hasDiscordBotToken must report false after clearing")
|
|
}
|
|
}
|
|
|
|
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
|
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
|
t.Fatal("expected localhost URL to be blocked")
|
|
}
|
|
if got, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", true); err != nil || got != "http://127.0.0.1:8080/hook" {
|
|
t.Fatalf("allowPrivate result = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyTwoFactorCode(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const token = "JBSWY3DPEHPK3PXP"
|
|
if err := s.saveSetting("twoFactorToken", token); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).Now()); err != nil {
|
|
t.Fatalf("valid code rejected: %v", err)
|
|
}
|
|
if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).AtTime(time.Now().Add(-30 * time.Second))); err != nil {
|
|
t.Fatalf("previous window code rejected: %v", err)
|
|
}
|
|
if err := s.VerifyTwoFactorCode("000000"); err == nil {
|
|
t.Fatal("invalid code accepted")
|
|
}
|
|
}
|
|
|
|
func TestGetSecret_FallbacksOnEmptyDatabaseSetting(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("secret", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
secretBytes, err := s.GetSecret()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
secret := string(secretBytes)
|
|
if len(secret) != 32 {
|
|
t.Fatalf("secret length = %d, want 32-byte non-empty string", len(secret))
|
|
}
|
|
|
|
persisted, err := s.getString("secret")
|
|
if err != nil {
|
|
t.Fatalf("failed to retrieve persisted secret: %v", err)
|
|
}
|
|
if persisted != secret {
|
|
t.Fatalf("persisted secret = %q, want %q", persisted, secret)
|
|
}
|
|
}
|