refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+16 -40
View File
@@ -4,9 +4,7 @@ import (
"crypto/tls"
//"os"
//"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
@@ -19,44 +17,6 @@ var Footer = ""
var Logo = ""
var TopUpLink = ""
var themeValue atomic.Value // stores string; safe for concurrent read/write
func init() {
themeValue.Store("classic")
}
func GetTheme() string {
return themeValue.Load().(string)
}
// SetTheme updates the frontend theme atomically.
// Only "default" and "classic" are accepted; other values are silently ignored.
func SetTheme(t string) {
if t == "default" || t == "classic" {
themeValue.Store(t)
}
}
// ThemeAwarePath rewrites legacy /console/* paths to the default-theme
// equivalents when the active theme is "default". For "classic" (or any
// other theme) the path is returned unchanged. The function only touches
// known prefixes so it is safe to call with arbitrary suffixes and query
// strings.
func ThemeAwarePath(suffix string) string {
if GetTheme() != "default" {
return suffix
}
switch {
case strings.HasPrefix(suffix, "/console/topup"):
return strings.Replace(suffix, "/console/topup", "/wallet", 1)
case strings.HasPrefix(suffix, "/console/log"):
return strings.Replace(suffix, "/console/log", "/usage-logs", 1)
case strings.HasPrefix(suffix, "/console/personal"):
return strings.Replace(suffix, "/console/personal", "/profile", 1)
}
return suffix
}
// var ChatLink = ""
// var ChatLink2 = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
@@ -77,6 +37,22 @@ var CryptoSecret = uuid.New().String()
var SessionCookieSecure = false
var SessionCookieTrustedURLs []string
const (
DefaultUserSessionActiveLimit = 50
DefaultUserSessionIssuanceLimit = 100
DefaultUserSessionIssuanceWindowSeconds = 24 * 60 * 60
DefaultUserSessionRevokedRetentionDays = 7
DefaultUserSessionHourlyAlertThreshold = 5000
)
var (
UserSessionActiveLimit = DefaultUserSessionActiveLimit
UserSessionIssuanceLimit = DefaultUserSessionIssuanceLimit
UserSessionIssuanceWindowSeconds = int64(DefaultUserSessionIssuanceWindowSeconds)
UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays
UserSessionHourlyAlertThreshold = DefaultUserSessionHourlyAlertThreshold
)
var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex
-26
View File
@@ -41,29 +41,3 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
FileSystem: http.FS(efs),
}
}
// themeAwareFileSystem delegates to the appropriate embedded FS based on
// the current theme (via GetTheme). This enables runtime theme switching
// without restarting the server.
type themeAwareFileSystem struct {
defaultFS static.ServeFileSystem
classicFS static.ServeFileSystem
}
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
if GetTheme() == "classic" {
return t.classicFS.Exists(prefix, path)
}
return t.defaultFS.Exists(prefix, path)
}
func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
if GetTheme() == "classic" {
return t.classicFS.Open(name)
}
return t.defaultFS.Open(name)
}
func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS}
}
+39
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"log"
"math"
"net/http"
"os"
"path/filepath"
@@ -64,6 +65,7 @@ func InitEnv() {
if err := InitSessionCookieSettings(); err != nil {
log.Fatal(err)
}
initUserSessionSettings()
if os.Getenv("SQLITE_PATH") != "" {
SQLitePath = os.Getenv("SQLITE_PATH")
}
@@ -134,6 +136,43 @@ func InitEnv() {
initConstantEnv()
}
func initUserSessionSettings() {
UserSessionActiveLimit = positiveUserSessionEnv("USER_SESSION_ACTIVE_LIMIT", DefaultUserSessionActiveLimit)
UserSessionIssuanceLimit = positiveUserSessionEnv("USER_SESSION_ISSUANCE_LIMIT", DefaultUserSessionIssuanceLimit)
UserSessionIssuanceWindowSeconds = int64(positiveUserSessionEnv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", DefaultUserSessionIssuanceWindowSeconds))
UserSessionRevokedRetentionDays = positiveUserSessionEnv("USER_SESSION_REVOKED_RETENTION_DAYS", DefaultUserSessionRevokedRetentionDays)
UserSessionHourlyAlertThreshold = positiveUserSessionEnv("USER_SESSION_HOURLY_ALERT_THRESHOLD", DefaultUserSessionHourlyAlertThreshold)
const secondsPerDay = 24 * 60 * 60
if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay {
SysError(fmt.Sprintf(
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d",
DefaultUserSessionRevokedRetentionDays,
))
UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays
}
retentionSeconds := int64(UserSessionRevokedRetentionDays) * secondsPerDay
if UserSessionIssuanceWindowSeconds > retentionSeconds {
configuredWindow := UserSessionIssuanceWindowSeconds
UserSessionIssuanceWindowSeconds = retentionSeconds
SysError(fmt.Sprintf(
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
configuredWindow,
retentionSeconds,
UserSessionIssuanceWindowSeconds,
))
}
}
func positiveUserSessionEnv(name string, fallback int) int {
value := GetEnvOrDefault(name, fallback)
if value <= 0 {
SysError(fmt.Sprintf("%s must be positive, using default value: %d", name, fallback))
return fallback
}
return value
}
func initConstantEnv() {
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
+37 -3
View File
@@ -2,11 +2,45 @@ package common
import (
"fmt"
"net"
"net/url"
"os"
"strings"
)
// NormalizeOrigin validates and canonicalizes a browser origin. Only an exact
// scheme/host/effective-port match is meaningful; paths and wildcards are not
// accepted for authentication cookie endpoints.
func NormalizeOrigin(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" || strings.ContainsAny(raw, "\r\n") {
return "", fmt.Errorf("origin is empty or invalid")
}
parsedURL, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("invalid origin: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return "", fmt.Errorf("origin scheme must be http or https")
}
if parsedURL.Host == "" || parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" || (parsedURL.Path != "" && parsedURL.Path != "/") {
return "", fmt.Errorf("origin must contain only scheme and host")
}
hostname := strings.ToLower(parsedURL.Hostname())
if hostname == "" || strings.Contains(hostname, "*") {
return "", fmt.Errorf("origin host is empty")
}
port := parsedURL.Port()
normalizedHost := hostname
if strings.Contains(hostname, ":") {
normalizedHost = "[" + hostname + "]"
}
if port == "" || (parsedURL.Scheme == "http" && port == "80") || (parsedURL.Scheme == "https" && port == "443") {
return parsedURL.Scheme + "://" + normalizedHost, nil
}
return parsedURL.Scheme + "://" + net.JoinHostPort(hostname, port), nil
}
func InitSessionCookieSettings() error {
secureRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_SECURE"))
trustedURLsRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_TRUSTED_URL"))
@@ -35,14 +69,14 @@ func InitSessionCookieSettings() error {
if trustedURL == "" {
return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL contains an empty URL")
}
parsedURL, err := url.Parse(trustedURL)
normalizedOrigin, err := NormalizeOrigin(trustedURL)
if err != nil {
return fmt.Errorf("invalid SESSION_COOKIE_TRUSTED_URL: %w", err)
}
if parsedURL.Scheme != "https" || parsedURL.Host == "" {
if !strings.HasPrefix(normalizedOrigin, "https://") {
return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL must contain only https URLs with hosts")
}
SessionCookieTrustedURLs = append(SessionCookieTrustedURLs, trustedURL)
SessionCookieTrustedURLs = append(SessionCookieTrustedURLs, normalizedOrigin)
}
SessionCookieSecure = true
+3 -2
View File
@@ -47,9 +47,10 @@ func LogStartupSuccess(startTime time.Time, port string) {
defer LogWriterMu.RUnlock()
if SessionCookieSecure == false {
// log warning if session cookie is not secure
// Warn when the local HTTP compatibility mode disables cookie transport
// security and refresh/logout Origin validation.
fmt.Fprintf(gin.DefaultWriter, "\n")
fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Session cookie is not secure. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n")
fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n")
fmt.Fprintf(gin.DefaultWriter, "\n")
}
+26
View File
@@ -193,3 +193,29 @@ func TestInitSessionCookieSettingsRejectsEmptyTrustedURLInList(t *testing.T) {
require.Error(t, InitSessionCookieSettings())
}
func TestInitSessionCookieSettingsNormalizesExactOrigins(t *testing.T) {
resetSessionCookieSettingsAfterTest(t)
t.Setenv("SESSION_COOKIE_SECURE", "true")
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://EXAMPLE.com:443,https://admin.example.com:8443/")
require.NoError(t, InitSessionCookieSettings())
assert.Equal(t, []string{"https://example.com", "https://admin.example.com:8443"}, SessionCookieTrustedURLs)
}
func TestInitSessionCookieSettingsRejectsNonOriginURLs(t *testing.T) {
for _, trustedURL := range []string{
"https://*.example.com",
"https://user@example.com",
"https://example.com/admin",
"https://example.com?next=admin",
"https://example.com#admin",
} {
t.Run(trustedURL, func(t *testing.T) {
resetSessionCookieSettingsAfterTest(t)
t.Setenv("SESSION_COOKIE_SECURE", "true")
t.Setenv("SESSION_COOKIE_TRUSTED_URL", trustedURL)
require.Error(t, InitSessionCookieSettings())
})
}
}
+60
View File
@@ -0,0 +1,60 @@
package common
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestInitUserSessionSettingsUsesPositiveFallbacksAndClampsWindow(t *testing.T) {
previousActiveLimit := UserSessionActiveLimit
previousIssuanceLimit := UserSessionIssuanceLimit
previousIssuanceWindow := UserSessionIssuanceWindowSeconds
previousRevokedRetention := UserSessionRevokedRetentionDays
previousAlertThreshold := UserSessionHourlyAlertThreshold
t.Cleanup(func() {
UserSessionActiveLimit = previousActiveLimit
UserSessionIssuanceLimit = previousIssuanceLimit
UserSessionIssuanceWindowSeconds = previousIssuanceWindow
UserSessionRevokedRetentionDays = previousRevokedRetention
UserSessionHourlyAlertThreshold = previousAlertThreshold
})
t.Setenv("USER_SESSION_ACTIVE_LIMIT", "0")
t.Setenv("USER_SESSION_ISSUANCE_LIMIT", "-2")
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "invalid")
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "0")
t.Setenv("USER_SESSION_HOURLY_ALERT_THRESHOLD", "-1")
initUserSessionSettings()
assert.Equal(t, DefaultUserSessionActiveLimit, UserSessionActiveLimit)
assert.Equal(t, DefaultUserSessionIssuanceLimit, UserSessionIssuanceLimit)
assert.Equal(t, int64(DefaultUserSessionIssuanceWindowSeconds), UserSessionIssuanceWindowSeconds)
assert.Equal(t, DefaultUserSessionRevokedRetentionDays, UserSessionRevokedRetentionDays)
assert.Equal(t, DefaultUserSessionHourlyAlertThreshold, UserSessionHourlyAlertThreshold)
t.Setenv("USER_SESSION_ACTIVE_LIMIT", "12")
t.Setenv("USER_SESSION_ISSUANCE_LIMIT", "34")
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "172800")
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "1")
t.Setenv("USER_SESSION_HOURLY_ALERT_THRESHOLD", "56")
initUserSessionSettings()
assert.Equal(t, 12, UserSessionActiveLimit)
assert.Equal(t, 34, UserSessionIssuanceLimit)
assert.Equal(t, int64(24*60*60), UserSessionIssuanceWindowSeconds)
assert.Equal(t, 1, UserSessionRevokedRetentionDays)
assert.Equal(t, 56, UserSessionHourlyAlertThreshold)
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "43200")
initUserSessionSettings()
assert.Equal(t, int64(12*60*60), UserSessionIssuanceWindowSeconds, "a window below retention remains unchanged")
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "86400")
initUserSessionSettings()
assert.Equal(t, int64(24*60*60), UserSessionIssuanceWindowSeconds, "a window equal to retention remains unchanged")
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "9223372036854775807")
initUserSessionSettings()
assert.Equal(t, DefaultUserSessionRevokedRetentionDays, UserSessionRevokedRetentionDays)
}