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:
+16
-40
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user