diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index b1ba779..e0c551c 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -15,7 +15,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.25' - name: Run tests run: go test ./... diff --git a/.gitignore b/.gitignore index 63c9b33..a5c70b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Binary forgejo-tickets +server # Environment .env diff --git a/Dockerfile b/Dockerfile index 3294779..9455d8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ COPY internal/handlers/ internal/handlers/ RUN npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --minify # Stage 2: Build Go binary -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/cmd/server/main.go b/cmd/server/main.go index 102fdfb..3580363 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -53,13 +54,14 @@ func main() { log.Info().Str("bot_login", forgejoClient.BotLogin).Msg("forgejo bot login initialized") } - sessionStore := auth.NewPGStore(db, []byte(cfg.SessionSecret)) + sessionStore := auth.NewPGStore(db, strings.HasPrefix(cfg.BaseURL, "https"), []byte(cfg.SessionSecret)) authService := auth.NewService(db, sessionStore, emailClient) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go sessionStore.Cleanup(ctx, 30*time.Minute) + go authService.CleanupExpiredTokens(ctx, 1*time.Hour) router := publichandlers.NewRouter(publichandlers.Dependencies{ DB: db, diff --git a/go.mod b/go.mod index 02183d1..362ed64 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,19 @@ module github.com/mattnite/forgejo-tickets -go 1.23.0 +go 1.25.3 require ( github.com/gin-gonic/gin v1.11.0 - github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 - github.com/gorilla/csrf v1.7.2 + github.com/gorilla/csrf v1.7.3 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 + github.com/microcosm-cc/bluemonday v1.0.27 github.com/mrz1836/postmark v1.6.5 github.com/rs/zerolog v1.34.0 + github.com/yuin/goldmark v1.7.16 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc golang.org/x/crypto v0.40.0 golang.org/x/oauth2 v0.25.0 gorm.io/driver/postgres v1.5.11 @@ -44,7 +47,6 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -52,8 +54,6 @@ require ( github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - github.com/yuin/goldmark v1.7.16 // indirect - github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.25.0 // indirect diff --git a/go.sum b/go.sum index 73c2431..a030945 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2Qx cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= @@ -37,8 +38,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -46,8 +47,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= -github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= diff --git a/internal/auth/apple.go b/internal/auth/apple.go index cbf8e1d..6138ad8 100644 --- a/internal/auth/apple.go +++ b/internal/auth/apple.go @@ -2,11 +2,15 @@ package auth import ( "context" + "crypto/rsa" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "fmt" "io" + "math/big" + "net/http" "os" "time" @@ -81,11 +85,61 @@ func (p *AppleProvider) getUserInfo(ctx context.Context, token *oauth2.Token) (* return nil, fmt.Errorf("missing id_token") } - // Parse without verification since we already got the token from Apple - parser := jwt.NewParser(jwt.WithoutClaimsValidation()) - parsed, _, err := parser.ParseUnverified(idToken, jwt.MapClaims{}) + // Fetch Apple's JWKS + resp, err := http.Get("https://appleid.apple.com/auth/keys") if err != nil { - return nil, fmt.Errorf("parse id_token: %w", err) + return nil, fmt.Errorf("fetch apple JWKS: %w", err) + } + defer resp.Body.Close() + + var jwks struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("decode apple JWKS: %w", err) + } + + // Parse and verify the token + parsed, err := jwt.Parse(idToken, func(t *jwt.Token) (interface{}, error) { + kid, ok := t.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing kid header") + } + + for _, key := range jwks.Keys { + if key.Kid == kid { + // Decode RSA public key from JWK + nBytes, err := base64.RawURLEncoding.DecodeString(key.N) + if err != nil { + return nil, fmt.Errorf("decode key N: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + return nil, fmt.Errorf("decode key E: %w", err) + } + + e := 0 + for _, b := range eBytes { + e = e*256 + int(b) + } + + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: e, + }, nil + } + } + return nil, fmt.Errorf("key %s not found in JWKS", kid) + }) + if err != nil { + return nil, fmt.Errorf("verify id_token: %w", err) } claims, ok := parsed.Claims.(jwt.MapClaims) @@ -99,7 +153,7 @@ func (p *AppleProvider) getUserInfo(ctx context.Context, token *oauth2.Token) (* return &OAuthUserInfo{ ProviderUserID: sub, Email: email, - Name: email, // Apple may not provide name in id_token + Name: email, }, nil } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9d27c2f..c2bced6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + "sync" + "time" "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/email" @@ -12,10 +14,21 @@ import ( "gorm.io/gorm" ) +const ( + maxLoginAttempts = 5 + lockoutDuration = 15 * time.Minute +) + +type loginAttempt struct { + count int + lastFail time.Time +} + type Service struct { - db *gorm.DB - store *PGStore - email *email.Client + db *gorm.DB + store *PGStore + email *email.Client + loginAttempts sync.Map } func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service { @@ -48,8 +61,21 @@ func (s *Service) Register(ctx context.Context, emailAddr, password, name string } func (s *Service) Login(ctx context.Context, emailAddr, password string) (*models.User, error) { + // Check if the account is locked due to too many failed attempts + if val, ok := s.loginAttempts.Load(emailAddr); ok { + attempt := val.(*loginAttempt) + if attempt.count >= maxLoginAttempts && time.Since(attempt.lastFail) < lockoutDuration { + return nil, fmt.Errorf("account temporarily locked, try again later") + } + // Reset if the lockout window has expired + if time.Since(attempt.lastFail) >= lockoutDuration { + s.loginAttempts.Delete(emailAddr) + } + } + var user models.User if err := s.db.WithContext(ctx).Where("email = ?", emailAddr).First(&user).Error; err != nil { + s.recordFailedAttempt(emailAddr) return nil, fmt.Errorf("invalid email or password") } @@ -58,6 +84,7 @@ func (s *Service) Login(ctx context.Context, emailAddr, password string) (*model } if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil { + s.recordFailedAttempt(emailAddr) return nil, fmt.Errorf("invalid email or password") } @@ -69,9 +96,19 @@ func (s *Service) Login(ctx context.Context, emailAddr, password string) (*model return nil, fmt.Errorf("your account is pending admin approval") } + // Clear failed attempts on successful login + s.loginAttempts.Delete(emailAddr) + return &user, nil } +func (s *Service) recordFailedAttempt(emailAddr string) { + val, _ := s.loginAttempts.LoadOrStore(emailAddr, &loginAttempt{}) + attempt := val.(*loginAttempt) + attempt.count++ + attempt.lastFail = time.Now() +} + func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error { session, err := s.store.Get(r, sessionCookieName) if err != nil { diff --git a/internal/auth/store.go b/internal/auth/store.go index bff5e62..5f8ba26 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -28,7 +28,7 @@ type PGStore struct { options *sessions.Options } -func NewPGStore(db *gorm.DB, keyPairs ...[]byte) *PGStore { +func NewPGStore(db *gorm.DB, secure bool, keyPairs ...[]byte) *PGStore { return &PGStore{ db: db, codecs: securecookie.CodecsFromPairs(keyPairs...), @@ -36,6 +36,7 @@ func NewPGStore(db *gorm.DB, keyPairs ...[]byte) *PGStore { Path: "/", MaxAge: sessionMaxAge, HttpOnly: true, + Secure: secure, SameSite: http.SameSiteLaxMode, }, } @@ -126,6 +127,7 @@ func (s *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sessions } result := s.db.Where("token = ?", session.ID).Assign(models.Session{ + UserID: userID, Data: buf.Bytes(), ExpiresAt: expiresAt, }).FirstOrCreate(&dbSession) diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go index ec8a438..a4f0c59 100644 --- a/internal/auth/tokens.go +++ b/internal/auth/tokens.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" ) @@ -85,6 +86,23 @@ func (s *Service) redeemToken(ctx context.Context, plainToken string, tokenType return &user, nil } +// CleanupExpiredTokens periodically deletes expired and used email tokens. +func (s *Service) CleanupExpiredTokens(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + result := s.db.Where("expires_at <= ? OR used_at IS NOT NULL", time.Now()).Delete(&models.EmailToken{}) + if result.Error != nil { + log.Error().Err(result.Error).Msg("email token cleanup error") + } + } + } +} + func hashToken(plainToken string) string { h := sha256.Sum256([]byte(plainToken)) return hex.EncodeToString(h[:]) diff --git a/internal/config/config.go b/internal/config/config.go index ac7bc00..44fe3e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strings" ) type Config struct { @@ -40,7 +41,8 @@ type Config struct { AppleKeyPath string // Admin - InitialAdminEmail string + InitialAdminEmail string + TailscaleAllowedUsers []string } func Load() (*Config, error) { @@ -66,11 +68,18 @@ func Load() (*Config, error) { cfg.InitialAdminEmail = getEnv("INITIAL_ADMIN_EMAIL", "") + if allowed := getEnv("TAILSCALE_ALLOWED_USERS", ""); allowed != "" { + cfg.TailscaleAllowedUsers = strings.Split(allowed, ",") + for i := range cfg.TailscaleAllowedUsers { + cfg.TailscaleAllowedUsers[i] = strings.TrimSpace(cfg.TailscaleAllowedUsers[i]) + } + } + if cfg.DatabaseURL == "" { return nil, fmt.Errorf("DATABASE_URL is required") } - if cfg.SessionSecret == "" { - return nil, fmt.Errorf("SESSION_SECRET is required") + if len(cfg.SessionSecret) < 32 { + return nil, fmt.Errorf("SESSION_SECRET must be at least 32 characters") } return cfg, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a22cd3c..d0142e4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,7 +25,7 @@ func clearConfigEnv(t *testing.T) { func TestLoad_MissingDatabaseURL(t *testing.T) { clearConfigEnv(t) - t.Setenv("SESSION_SECRET", "secret-value") + t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") // DATABASE_URL is not set _, err := Load() @@ -49,7 +49,7 @@ func TestLoad_MissingSessionSecret(t *testing.T) { t.Fatal("expected error when SESSION_SECRET is missing, got nil") } - expected := "SESSION_SECRET is required" + expected := "SESSION_SECRET must be at least 32 characters" if err.Error() != expected { t.Errorf("expected error %q, got %q", expected, err.Error()) } @@ -58,7 +58,7 @@ func TestLoad_MissingSessionSecret(t *testing.T) { func TestLoad_Success(t *testing.T) { clearConfigEnv(t) t.Setenv("DATABASE_URL", "postgres://localhost/test") - t.Setenv("SESSION_SECRET", "my-secret") + t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") cfg, err := Load() if err != nil { @@ -68,15 +68,15 @@ func TestLoad_Success(t *testing.T) { if cfg.DatabaseURL != "postgres://localhost/test" { t.Errorf("expected DatabaseURL %q, got %q", "postgres://localhost/test", cfg.DatabaseURL) } - if cfg.SessionSecret != "my-secret" { - t.Errorf("expected SessionSecret %q, got %q", "my-secret", cfg.SessionSecret) + if cfg.SessionSecret != "test-session-secret-that-is-32ch" { + t.Errorf("expected SessionSecret %q, got %q", "test-session-secret-that-is-32ch", cfg.SessionSecret) } } func TestLoad_DefaultValues(t *testing.T) { clearConfigEnv(t) t.Setenv("DATABASE_URL", "postgres://localhost/test") - t.Setenv("SESSION_SECRET", "my-secret") + t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") cfg, err := Load() if err != nil { @@ -97,7 +97,7 @@ func TestLoad_DefaultValues(t *testing.T) { func TestLoad_OverrideDefaults(t *testing.T) { clearConfigEnv(t) t.Setenv("DATABASE_URL", "postgres://localhost/test") - t.Setenv("SESSION_SECRET", "my-secret") + t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") t.Setenv("PUBLIC_ADDR", ":9090") t.Setenv("BASE_URL", "https://example.com") @@ -117,7 +117,7 @@ func TestLoad_OverrideDefaults(t *testing.T) { func TestLoad_InitialAdminEmail(t *testing.T) { clearConfigEnv(t) t.Setenv("DATABASE_URL", "postgres://localhost/test") - t.Setenv("SESSION_SECRET", "my-secret") + t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") t.Setenv("INITIAL_ADMIN_EMAIL", "admin@example.com") cfg, err := Load() @@ -129,3 +129,42 @@ func TestLoad_InitialAdminEmail(t *testing.T) { t.Errorf("expected InitialAdminEmail %q, got %q", "admin@example.com", cfg.InitialAdminEmail) } } + +func TestLoad_TailscaleAllowedUsers(t *testing.T) { + clearConfigEnv(t) + t.Setenv("DATABASE_URL", "postgres://localhost/test") + t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") + t.Setenv("TAILSCALE_ALLOWED_USERS", "alice@example.com, bob@example.com , charlie@example.com") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(cfg.TailscaleAllowedUsers) != 3 { + t.Fatalf("expected 3 tailscale users, got %d", len(cfg.TailscaleAllowedUsers)) + } + + expected := []string{"alice@example.com", "bob@example.com", "charlie@example.com"} + for i, want := range expected { + if cfg.TailscaleAllowedUsers[i] != want { + t.Errorf("TailscaleAllowedUsers[%d]: expected %q, got %q", i, want, cfg.TailscaleAllowedUsers[i]) + } + } +} + +func TestLoad_EmptyTailscaleAllowedUsers(t *testing.T) { + clearConfigEnv(t) + t.Setenv("DATABASE_URL", "postgres://localhost/test") + t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") + // TAILSCALE_ALLOWED_USERS not set + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if cfg.TailscaleAllowedUsers != nil { + t.Errorf("expected nil TailscaleAllowedUsers, got %v", cfg.TailscaleAllowedUsers) + } +} diff --git a/internal/email/templates.go b/internal/email/templates.go index 4d36510..5f7bfe6 100644 --- a/internal/email/templates.go +++ b/internal/email/templates.go @@ -1,6 +1,9 @@ package email -import "fmt" +import ( + "fmt" + "html" +) func emailWrapper(content string) string { return fmt.Sprintf(` @@ -24,7 +27,7 @@ func renderVerificationEmail(name, verifyURL string) string {
Or copy and paste this link into your browser:
%s
-This link expires in 24 hours.
`, name, verifyURL, verifyURL)) +This link expires in 24 hours.
`, html.EscapeString(name), verifyURL, verifyURL)) } func renderPasswordResetEmail(name, resetURL string) string { @@ -37,7 +40,7 @@ func renderPasswordResetEmail(name, resetURL string) string {Or copy and paste this link into your browser:
%s
-This link expires in 1 hour. If you didn't request this, please ignore this email.
`, name, resetURL, resetURL)) +This link expires in 1 hour. If you didn't request this, please ignore this email.
`, html.EscapeString(name), resetURL, resetURL)) } func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string { @@ -48,7 +51,7 @@ func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string { -If you believe the issue is not fully resolved, you can add a comment on the ticket page.
`, name, ticketTitle, ticketURL)) +If you believe the issue is not fully resolved, you can add a comment on the ticket page.
`, html.EscapeString(name), html.EscapeString(ticketTitle), ticketURL)) } func renderTicketReplyEmail(name, ticketTitle, ticketURL string) string { @@ -58,7 +61,7 @@ func renderTicketReplyEmail(name, ticketTitle, ticketURL string) string {There is a new reply on your ticket "%s".
`, name, ticketTitle, ticketURL)) +`, html.EscapeString(name), html.EscapeString(ticketTitle), ticketURL)) } func renderAccountApprovedEmail(name, loginURL string) string { @@ -68,7 +71,7 @@ func renderAccountApprovedEmail(name, loginURL string) string {Your account request has been approved. You can now log in and start creating tickets.
Log In -
`, name, loginURL)) +`, html.EscapeString(name), loginURL)) } func renderWelcomeEmail(name, email, tempPassword, loginURL string) string { @@ -83,5 +86,5 @@ func renderWelcomeEmail(name, email, tempPassword, loginURL string) string { -Please change your password after logging in.
`, name, email, tempPassword, loginURL)) +Please change your password after logging in.
`, html.EscapeString(name), html.EscapeString(email), html.EscapeString(tempPassword), loginURL)) } diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go index 14b5a6a..69dd7fe 100644 --- a/internal/forgejo/client.go +++ b/internal/forgejo/client.go @@ -868,7 +868,20 @@ func (c *Client) GetAttachmentURL(apiURL string) (string, error) { } // ProxyDownload fetches a file from the given Forgejo URL with authentication and streams it back. +// The URL host must match the configured Forgejo base URL to prevent SSRF. func (c *Client) ProxyDownload(downloadURL string) (*http.Response, error) { + parsed, err := url.Parse(downloadURL) + if err != nil { + return nil, fmt.Errorf("invalid download URL: %w", err) + } + base, err := url.Parse(c.baseURL) + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + if parsed.Host != base.Host { + return nil, fmt.Errorf("download URL host %q does not match Forgejo host %q", parsed.Host, base.Host) + } + httpReq, err := http.NewRequest("GET", downloadURL, nil) if err != nil { return nil, err diff --git a/internal/forgejo/webhook.go b/internal/forgejo/webhook.go index 75fd0a1..3f3f307 100644 --- a/internal/forgejo/webhook.go +++ b/internal/forgejo/webhook.go @@ -39,7 +39,7 @@ func VerifyWebhookSignature(r *http.Request, secret string) ([]byte, error) { return nil, fmt.Errorf("missing X-Forgejo-Signature header") } - body, err := io.ReadAll(r.Body) + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("read body: %w", err) } diff --git a/internal/handlers/admin/users.go b/internal/handlers/admin/users.go index f8c8a09..c072c53 100644 --- a/internal/handlers/admin/users.go +++ b/internal/handlers/admin/users.go @@ -4,12 +4,12 @@ import ( "crypto/rand" "encoding/hex" "net/http" - "net/url" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/forgejo" + "github.com/mattnite/forgejo-tickets/internal/middleware" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) @@ -202,11 +202,8 @@ func (h *UserHandler) Approve(c *gin.Context) { log.Error().Err(err).Msg("send approval email error") } - redirectURL := "/admin/users/pending?" + url.Values{ - "flash": {"User " + user.Email + " has been approved"}, - "flash_type": {"success"}, - }.Encode() - c.Redirect(http.StatusSeeOther, redirectURL) + middleware.SetFlash(c, "success", "User "+user.Email+" has been approved") + c.Redirect(http.StatusSeeOther, "/admin/users/pending") } func (h *UserHandler) Reject(c *gin.Context) { @@ -222,11 +219,8 @@ func (h *UserHandler) Reject(c *gin.Context) { return } - redirectURL := "/admin/users/pending?" + url.Values{ - "flash": {"User request has been rejected"}, - "flash_type": {"success"}, - }.Encode() - c.Redirect(http.StatusSeeOther, redirectURL) + middleware.SetFlash(c, "success", "User request has been rejected") + c.Redirect(http.StatusSeeOther, "/admin/users/pending") } func (h *UserHandler) UpdateRepos(c *gin.Context) { @@ -257,9 +251,6 @@ func (h *UserHandler) UpdateRepos(c *gin.Context) { h.deps.DB.Create(&models.UserRepo{UserID: userID, RepoID: repoID}) } - redirectURL := "/admin/users/" + userID.String() + "?" + url.Values{ - "flash": {"Project assignments updated"}, - "flash_type": {"success"}, - }.Encode() - c.Redirect(http.StatusSeeOther, redirectURL) + middleware.SetFlash(c, "success", "Project assignments updated") + c.Redirect(http.StatusSeeOther, "/admin/users/"+userID.String()) } diff --git a/internal/handlers/public/auth.go b/internal/handlers/public/auth.go index d61a06b..2b32abc 100644 --- a/internal/handlers/public/auth.go +++ b/internal/handlers/public/auth.go @@ -2,15 +2,38 @@ package public import ( "net/http" - "net/url" "strings" + "unicode" "github.com/gin-gonic/gin" "github.com/mattnite/forgejo-tickets/internal/auth" + "github.com/mattnite/forgejo-tickets/internal/middleware" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) +// validatePassword checks password complexity requirements. +func validatePassword(password string) string { + if len(password) < 8 { + return "Password must be at least 8 characters" + } + var hasUpper, hasLower, hasDigit bool + for _, r := range password { + switch { + case unicode.IsUpper(r): + hasUpper = true + case unicode.IsLower(r): + hasLower = true + case unicode.IsDigit(r): + hasDigit = true + } + } + if !hasUpper || !hasLower || !hasDigit { + return "Password must contain at least one uppercase letter, one lowercase letter, and one digit" + } + return "" +} + type AuthHandler struct { deps Dependencies } @@ -85,8 +108,8 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - if len(password) < 8 { - data["Error"] = "Password must be at least 8 characters" + if errMsg := validatePassword(password); errMsg != "" { + data["Error"] = errMsg h.deps.Renderer.Render(c.Writer, c.Request, "register", data) return } @@ -111,11 +134,8 @@ func (h *AuthHandler) Register(c *gin.Context) { } } - redirectURL := "/login?" + url.Values{ - "flash": {"Account requested! Please check your email to verify your address. After verification, an admin will review your request."}, - "flash_type": {"success"}, - }.Encode() - c.Redirect(http.StatusSeeOther, redirectURL) + middleware.SetFlash(c, "success", "Account requested! Please check your email to verify your address. After verification, an admin will review your request.") + c.Redirect(http.StatusSeeOther, "/login") } func (h *AuthHandler) Logout(c *gin.Context) { @@ -138,11 +158,8 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { return } - redirectURL := "/login?" + url.Values{ - "flash": {"Email verified successfully. Your account is pending admin approval."}, - "flash_type": {"success"}, - }.Encode() - c.Redirect(http.StatusSeeOther, redirectURL) + middleware.SetFlash(c, "success", "Email verified successfully. Your account is pending admin approval.") + c.Redirect(http.StatusSeeOther, "/login") } func (h *AuthHandler) ForgotPasswordForm(c *gin.Context) { @@ -189,10 +206,10 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) { return } - if len(password) < 8 { + if errMsg := validatePassword(password); errMsg != "" { h.deps.Renderer.Render(c.Writer, c.Request, "reset-password", map[string]interface{}{ "Token": token, - "Error": "Password must be at least 8 characters", + "Error": errMsg, }) return } @@ -206,9 +223,6 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) { return } - redirectURL := "/login?" + url.Values{ - "flash": {"Password reset successfully. You can now log in."}, - "flash_type": {"success"}, - }.Encode() - c.Redirect(http.StatusSeeOther, redirectURL) + middleware.SetFlash(c, "success", "Password reset successfully. You can now log in.") + c.Redirect(http.StatusSeeOther, "/login") } diff --git a/internal/handlers/public/oauth.go b/internal/handlers/public/oauth.go index 839f4b0..60b9a10 100644 --- a/internal/handlers/public/oauth.go +++ b/internal/handlers/public/oauth.go @@ -4,11 +4,11 @@ import ( "crypto/rand" "encoding/hex" "net/http" - "net/url" "strings" "github.com/gin-gonic/gin" "github.com/mattnite/forgejo-tickets/internal/auth" + "github.com/mattnite/forgejo-tickets/internal/middleware" "github.com/rs/zerolog/log" "golang.org/x/oauth2" ) @@ -53,12 +53,16 @@ func (h *OAuthHandler) Login(c *gin.Context) { } state := generateState() - session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state") - session.Values["state"] = state - session.Values["user_id"] = "00000000-0000-0000-0000-000000000000" // placeholder for save - if err := session.Save(c.Request, c.Writer); err != nil { - log.Error().Err(err).Msg("save oauth state error") - } + isSecure := strings.HasPrefix(h.deps.Config.BaseURL, "https") + http.SetCookie(c.Writer, &http.Cookie{ + Name: "oauth_state", + Value: state, + Path: "/", + MaxAge: 600, // 10 minutes + HttpOnly: true, + Secure: isSecure, + SameSite: http.SameSiteLaxMode, + }) url := provider.Config.AuthCodeURL(state, oauth2.AccessTypeOffline) c.Redirect(http.StatusTemporaryRedirect, url) @@ -73,12 +77,17 @@ func (h *OAuthHandler) Callback(c *gin.Context) { } // Verify state - session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state") - expectedState, _ := session.Values["state"].(string) - if c.Query("state") != expectedState { + stateCookie, err := c.Request.Cookie("oauth_state") + if err != nil || c.Query("state") != stateCookie.Value { c.String(http.StatusBadRequest, "Invalid state parameter") return } + // Clear the state cookie + http.SetCookie(c.Writer, &http.Cookie{ + Name: "oauth_state", + Path: "/", + MaxAge: -1, + }) code := c.Query("code") token, err := provider.Config.Exchange(c.Request.Context(), code) @@ -98,11 +107,8 @@ func (h *OAuthHandler) Callback(c *gin.Context) { user, err := h.deps.Auth.FindOrCreateOAuthUser(c.Request.Context(), provider.Name, info) if err != nil { if strings.Contains(err.Error(), "pending admin approval") { - redirectURL := "/login?" + url.Values{ - "flash": {err.Error()}, - "flash_type": {"info"}, - }.Encode() - c.Redirect(http.StatusSeeOther, redirectURL) + middleware.SetFlash(c, "info", err.Error()) + c.Redirect(http.StatusSeeOther, "/login") return } log.Error().Err(err).Msg("find or create oauth user error") @@ -137,12 +143,16 @@ func (h *OAuthHandler) appleLogin(c *gin.Context) { } state := generateState() - session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state") - session.Values["state"] = state - session.Values["user_id"] = "00000000-0000-0000-0000-000000000000" - if err := session.Save(c.Request, c.Writer); err != nil { - log.Error().Err(err).Msg("save oauth state error") - } + isSecure := strings.HasPrefix(h.deps.Config.BaseURL, "https") + http.SetCookie(c.Writer, &http.Cookie{ + Name: "oauth_state", + Value: state, + Path: "/", + MaxAge: 600, // 10 minutes + HttpOnly: true, + Secure: isSecure, + SameSite: http.SameSiteNoneMode, // Apple uses form_post cross-origin + }) url := appleProvider.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, auth.AppleAuthCodeOption()) c.Redirect(http.StatusTemporaryRedirect, url) @@ -164,12 +174,17 @@ func (h *OAuthHandler) AppleCallback(c *gin.Context) { code := c.PostForm("code") state := c.PostForm("state") - session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state") - expectedState, _ := session.Values["state"].(string) - if state != expectedState { + stateCookie, err := c.Request.Cookie("oauth_state") + if err != nil || state != stateCookie.Value { c.String(http.StatusBadRequest, "Invalid state parameter") return } + // Clear the state cookie + http.SetCookie(c.Writer, &http.Cookie{ + Name: "oauth_state", + Path: "/", + MaxAge: -1, + }) token, err := appleProvider.ExchangeCode(c.Request.Context(), code) if err != nil { @@ -196,11 +211,8 @@ func (h *OAuthHandler) AppleCallback(c *gin.Context) { user, err := h.deps.Auth.FindOrCreateOAuthUser(c.Request.Context(), "apple", info) if err != nil { if strings.Contains(err.Error(), "pending admin approval") { - redirectURL := "/login?" + url.Values{ - "flash": {err.Error()}, - "flash_type": {"info"}, - }.Encode() - c.Redirect(http.StatusSeeOther, redirectURL) + middleware.SetFlash(c, "info", err.Error()) + c.Redirect(http.StatusSeeOther, "/login") return } log.Error().Err(err).Msg("find or create apple user error") diff --git a/internal/handlers/public/routes.go b/internal/handlers/public/routes.go index beccad6..3185e3e 100644 --- a/internal/handlers/public/routes.go +++ b/internal/handlers/public/routes.go @@ -3,6 +3,7 @@ package public import ( "net/http" "strings" + "time" "github.com/gin-gonic/gin" "github.com/mattnite/forgejo-tickets/internal/auth" @@ -31,6 +32,7 @@ func NewRouter(deps Dependencies) *gin.Engine { r.Use(middleware.RequestID) r.Use(middleware.Logging) r.Use(middleware.Recovery) + r.Use(middleware.SecurityHeaders(strings.HasPrefix(deps.Config.BaseURL, "https"))) r.Use(deps.Auth.SessionMiddleware) csrfSecret := []byte(deps.Config.SessionSecret) @@ -41,7 +43,7 @@ func NewRouter(deps Dependencies) *gin.Engine { c.String(http.StatusOK, "ok") }) - r.Static("/static", "web/static") + r.StaticFS("/static", gin.Dir("web/static", false)) webhookHandler := &WebhookHandler{deps: deps} r.POST("/webhooks/forgejo/:repoSlug", webhookHandler.HandleForgejoWebhook) @@ -49,6 +51,8 @@ func NewRouter(deps Dependencies) *gin.Engine { ssoHandler := &SSOHandler{deps: deps} r.GET("/sso/:slug", ssoHandler.HandleSSO) + authRateLimiter := middleware.NewRateLimiter(10, 1*time.Minute) + csrf := r.Group("/") csrf.Use(csrfMiddleware) { @@ -57,13 +61,13 @@ func NewRouter(deps Dependencies) *gin.Engine { authHandler := &AuthHandler{deps: deps} csrf.GET("/login", authHandler.LoginForm) - csrf.POST("/login", authHandler.Login) + csrf.POST("/login", authRateLimiter.Middleware(), authHandler.Login) csrf.GET("/register", authHandler.RegisterForm) - csrf.POST("/register", authHandler.Register) + csrf.POST("/register", authRateLimiter.Middleware(), authHandler.Register) csrf.POST("/logout", authHandler.Logout) csrf.GET("/verify-email", authHandler.VerifyEmail) csrf.GET("/forgot-password", authHandler.ForgotPasswordForm) - csrf.POST("/forgot-password", authHandler.ForgotPassword) + csrf.POST("/forgot-password", authRateLimiter.Middleware(), authHandler.ForgotPassword) csrf.GET("/reset-password", authHandler.ResetPasswordForm) csrf.POST("/reset-password", authHandler.ResetPassword) diff --git a/internal/handlers/public/sso.go b/internal/handlers/public/sso.go index 940a315..5adbb47 100644 --- a/internal/handlers/public/sso.go +++ b/internal/handlers/public/sso.go @@ -98,7 +98,11 @@ func (h *SSOHandler) HandleSSO(c *gin.Context) { c.String(http.StatusInternalServerError, "failed to create user") return } + } else { + log.Info().Str("email", email).Str("name", name).Str("repo", slug).Msg("SSO: created new user") } + } else { + log.Info().Str("email", email).Str("repo", slug).Msg("SSO: existing user logged in") } // Update existing user if needed diff --git a/internal/handlers/public/tickets.go b/internal/handlers/public/tickets.go index de704bb..a8ebfcc 100644 --- a/internal/handlers/public/tickets.go +++ b/internal/handlers/public/tickets.go @@ -2,6 +2,7 @@ package public import ( "io" + "mime" "net/http" "sort" "strconv" @@ -531,7 +532,7 @@ func (h *TicketHandler) proxyAssetDownload(c *gin.Context, assetURL, filename st contentType = "application/octet-stream" } c.Header("Content-Type", contentType) - c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"") + c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) if cl := resp.Header.Get("Content-Length"); cl != "" { c.Header("Content-Length", cl) } diff --git a/internal/middleware/flash.go b/internal/middleware/flash.go new file mode 100644 index 0000000..fd3167c --- /dev/null +++ b/internal/middleware/flash.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "encoding/base64" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// SetFlash sets a flash message cookie that will be consumed on the next render. +func SetFlash(c *gin.Context, flashType, message string) { + // Encode as "type:message" in base64 to avoid cookie value issues + value := base64.StdEncoding.EncodeToString([]byte(flashType + ":" + message)) + http.SetCookie(c.Writer, &http.Cookie{ + Name: "flash", + Value: value, + Path: "/", + MaxAge: 60, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// GetFlash reads and clears the flash message cookie. +func GetFlash(r *http.Request, w http.ResponseWriter) (flashType, message string) { + cookie, err := r.Cookie("flash") + if err != nil || cookie.Value == "" { + return "", "" + } + // Clear the cookie + http.SetCookie(w, &http.Cookie{ + Name: "flash", + Path: "/", + MaxAge: -1, + }) + data, err := base64.StdEncoding.DecodeString(cookie.Value) + if err != nil { + return "", "" + } + parts := strings.SplitN(string(data), ":", 2) + if len(parts) != 2 { + return "", "" + } + return parts[0], parts[1] +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 7fc3d03..48378a1 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -44,3 +44,15 @@ func Recovery(c *gin.Context) { }() c.Next() } + +func SecurityHeaders(secure bool) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + if secure { + c.Header("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + } + c.Next() + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..da1c670 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,86 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type ipRecord struct { + mu sync.Mutex + timestamps []time.Time +} + +// RateLimiter holds the per-IP rate limiting state. +type RateLimiter struct { + ips sync.Map // map[string]*ipRecord + limit int + window time.Duration +} + +// NewRateLimiter creates a rate limiter that allows `limit` requests per `window` per IP. +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + rl := &RateLimiter{ + limit: limit, + window: window, + } + // Periodically clean up stale entries + go rl.cleanup() + return rl +} + +// cleanup removes entries that have no recent timestamps every 5 minutes. +func (rl *RateLimiter) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + now := time.Now() + rl.ips.Range(func(key, value any) bool { + rec := value.(*ipRecord) + rec.mu.Lock() + if len(rec.timestamps) == 0 || now.Sub(rec.timestamps[len(rec.timestamps)-1]) > rl.window { + rec.mu.Unlock() + rl.ips.Delete(key) + } else { + rec.mu.Unlock() + } + return true + }) + } +} + +// Middleware returns a Gin middleware that enforces the rate limit. +func (rl *RateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + + val, _ := rl.ips.LoadOrStore(ip, &ipRecord{}) + rec := val.(*ipRecord) + + rec.mu.Lock() + now := time.Now() + cutoff := now.Add(-rl.window) + + // Remove timestamps outside the sliding window + valid := 0 + for _, t := range rec.timestamps { + if t.After(cutoff) { + rec.timestamps[valid] = t + valid++ + } + } + rec.timestamps = rec.timestamps[:valid] + + if len(rec.timestamps) >= rl.limit { + rec.mu.Unlock() + c.AbortWithStatus(http.StatusTooManyRequests) + return + } + + rec.timestamps = append(rec.timestamps, now) + rec.mu.Unlock() + + c.Next() + } +} diff --git a/internal/templates/render.go b/internal/templates/render.go index f9872c7..3e1d1e7 100644 --- a/internal/templates/render.go +++ b/internal/templates/render.go @@ -12,6 +12,7 @@ import ( "github.com/gorilla/csrf" "github.com/mattnite/forgejo-tickets/internal/auth" + "github.com/mattnite/forgejo-tickets/internal/middleware" "github.com/mattnite/forgejo-tickets/internal/models" ) @@ -103,12 +104,8 @@ func (r *Renderer) Render(w http.ResponseWriter, req *http.Request, name string, Data: data, } - if msg := req.URL.Query().Get("flash"); msg != "" { - flashType := req.URL.Query().Get("flash_type") - if flashType == "" { - flashType = "info" - } - pd.Flash = &Flash{Type: flashType, Message: msg} + if flashType, flashMsg := middleware.GetFlash(req, w); flashMsg != "" { + pd.Flash = &Flash{Type: flashType, Message: flashMsg} } var buf bytes.Buffer diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index d8dc45f..3e1d79d 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -15,11 +15,17 @@ -