feat(admin): add session-based portal login

This commit is contained in:
phamnazage-jpg
2026-05-28 11:01:29 +08:00
parent 03c4b5236f
commit de33ff3492
15 changed files with 833 additions and 75 deletions

View File

@@ -11,12 +11,17 @@ const (
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME"
EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD"
EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL"
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
DefaultListenAddr = ":8080"
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
DefaultAdminUsername = "admin"
DefaultAdminSessionTTL = 12 * time.Hour
DefaultReconcilePollInterval = 10 * time.Minute
)
@@ -44,6 +49,12 @@ type StartupConfig struct {
Reconcile ReconcileConfig
}
type AdminSessionConfig struct {
Username string
Password string
SessionTTL time.Duration
}
func LoadStartupFromEnv() (StartupConfig, error) {
return loadStartupFromLookupEnv(os.LookupEnv)
}
@@ -76,6 +87,10 @@ func LoadAdminTokenFromEnv() (string, error) {
return loadAdminTokenFromLookupEnv(os.LookupEnv)
}
func LoadAdminSessionFromEnv() (AdminSessionConfig, error) {
return loadAdminSessionFromLookupEnv(os.LookupEnv)
}
func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, error) {
token := strings.TrimSpace(readRequiredEnv(lookup, EnvAdminToken))
if token == "" {
@@ -85,6 +100,18 @@ func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, er
return token, nil
}
func loadAdminSessionFromLookupEnv(lookup func(string) (string, bool)) (AdminSessionConfig, error) {
ttl, err := readOptionalDurationEnv(lookup, EnvAdminSessionTTL, DefaultAdminSessionTTL)
if err != nil {
return AdminSessionConfig{}, err
}
return AdminSessionConfig{
Username: readOptionalEnv(lookup, EnvAdminUsername, DefaultAdminUsername),
Password: strings.TrimSpace(readOptionalEnv(lookup, EnvAdminPassword, "")),
SessionTTL: ttl,
}, nil
}
func readOptionalEnv(lookup func(string) (string, bool), key string, defaultValue string) string {
value, ok := lookup(key)
if !ok {

View File

@@ -163,6 +163,65 @@ func TestLoadAdminTokenFromLookupEnv(t *testing.T) {
})
}
func TestLoadAdminSessionFromLookupEnv(t *testing.T) {
t.Run("uses defaults", func(t *testing.T) {
cfg, err := loadAdminSessionFromLookupEnv(func(string) (string, bool) {
return "", false
})
if err != nil {
t.Fatal(err)
}
if cfg.Username != DefaultAdminUsername {
t.Fatalf("Username = %q, want %q", cfg.Username, DefaultAdminUsername)
}
if cfg.Password != "" {
t.Fatalf("Password = %q, want empty", cfg.Password)
}
if cfg.SessionTTL != DefaultAdminSessionTTL {
t.Fatalf("SessionTTL = %s, want %s", cfg.SessionTTL, DefaultAdminSessionTTL)
}
})
t.Run("loads custom values", func(t *testing.T) {
cfg, err := loadAdminSessionFromLookupEnv(func(key string) (string, bool) {
switch key {
case EnvAdminUsername:
return " portal-admin ", true
case EnvAdminPassword:
return " super-secret ", true
case EnvAdminSessionTTL:
return "4h", true
default:
return "", false
}
})
if err != nil {
t.Fatal(err)
}
if cfg.Username != "portal-admin" {
t.Fatalf("Username = %q, want portal-admin", cfg.Username)
}
if cfg.Password != "super-secret" {
t.Fatalf("Password = %q, want super-secret", cfg.Password)
}
if cfg.SessionTTL != 4*time.Hour {
t.Fatalf("SessionTTL = %s, want 4h", cfg.SessionTTL)
}
})
t.Run("rejects invalid ttl", func(t *testing.T) {
_, err := loadAdminSessionFromLookupEnv(func(key string) (string, bool) {
if key == EnvAdminSessionTTL {
return "bad", true
}
return "", false
})
if err == nil {
t.Fatal("expected error for invalid session ttl")
}
})
}
// Verify exported wrappers call the lookup versions.
// We can't easily test LoadStartupFromEnv / LoadAdminTokenFromEnv
// since they depend on os.LookupEnv, but we verify they compile and don't panic.