Compare commits

...

1 Commits

Author SHA1 Message Date
yushen
3f8a492290 fix(auth): make JWT and CloudFront cookie expiration configurable
Replace hardcoded 72-hour auth timeout with configurable JWT_EXPIRATION
env var (default: 30 days). This prevents users from being logged out
every 3 days. CloudFront cookie expiration is now synced with JWT
expiration automatically.

Closes MUL-151
2026-04-01 18:21:18 +08:00
4 changed files with 49 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Server
PORT=8080
JWT_SECRET=change-me-in-production
JWT_EXPIRATION=720h
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
MULTICA_DAEMON_CONFIG=

View File

@@ -0,0 +1,45 @@
package auth
import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
)
const (
// DefaultJWTExpiration is the default JWT token lifetime.
// 30 days is reasonable for a productivity tool — users shouldn't need to
// re-authenticate every few days.
DefaultJWTExpiration = 30 * 24 * time.Hour // 720h
)
var (
jwtExpiration time.Duration
jwtExpirationOnce sync.Once
)
// JWTExpiration returns the configured JWT token lifetime.
// Reads from JWT_EXPIRATION env var (Go duration string, e.g. "720h", "168h").
// Falls back to DefaultJWTExpiration (30 days).
func JWTExpiration() time.Duration {
jwtExpirationOnce.Do(func() {
jwtExpiration = DefaultJWTExpiration
if v := strings.TrimSpace(os.Getenv("JWT_EXPIRATION")); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
slog.Error("invalid JWT_EXPIRATION, using default", "value", v, "error", err, "default", DefaultJWTExpiration)
return
}
if d <= 0 {
slog.Error("JWT_EXPIRATION must be positive, using default", "value", v, "default", DefaultJWTExpiration)
return
}
jwtExpiration = d
slog.Info(fmt.Sprintf("JWT expiration set to %s", d))
}
})
return jwtExpiration
}

View File

@@ -175,7 +175,7 @@ func (h *Handler) issueJWT(user db.User) (string, error) {
"sub": uuidToString(user.ID),
"email": user.Email,
"name": user.Name,
"exp": time.Now().Add(72 * time.Hour).Unix(),
"exp": time.Now().Add(auth.JWTExpiration()).Unix(),
"iat": time.Now().Unix(),
})
return token.SignedString(auth.JWTSecret())
@@ -302,7 +302,7 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
// Set CloudFront signed cookies for CDN access.
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(auth.JWTExpiration())) {
http.SetCookie(w, cookie)
}
}

View File

@@ -18,7 +18,7 @@ func RefreshCloudFrontCookies(signer *auth.CloudFrontSigner) func(http.Handler)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := r.Cookie("CloudFront-Policy"); err != nil {
for _, cookie := range signer.SignedCookies(time.Now().Add(72 * time.Hour)) {
for _, cookie := range signer.SignedCookies(time.Now().Add(auth.JWTExpiration())) {
http.SetCookie(w, cookie)
}
}