Merge branch 'main' into unify-ui

This commit is contained in:
Matthew Knight 2026-02-17 16:46:48 -08:00
commit d9eede4c15
No known key found for this signature in database
26 changed files with 473 additions and 121 deletions

View File

@ -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 ./...

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# Binary
forgejo-tickets
server
# Environment
.env

View File

@ -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

View File

@ -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,

12
go.mod
View File

@ -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

9
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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
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 {

View File

@ -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)

View File

@ -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[:])

View File

@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
"strings"
)
type Config struct {
@ -41,6 +42,7 @@ type Config struct {
// Admin
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

View File

@ -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)
}
}

View File

@ -1,6 +1,9 @@
package email
import "fmt"
import (
"fmt"
"html"
)
func emailWrapper(content string) string {
return fmt.Sprintf(`<!DOCTYPE html>
@ -24,7 +27,7 @@ func renderVerificationEmail(name, verifyURL string) string {
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #2563eb;">%s</p>
<p>This link expires in 24 hours.</p>`, name, verifyURL, verifyURL))
<p>This link expires in 24 hours.</p>`, html.EscapeString(name), verifyURL, verifyURL))
}
func renderPasswordResetEmail(name, resetURL string) string {
@ -37,7 +40,7 @@ func renderPasswordResetEmail(name, resetURL string) string {
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #2563eb;">%s</p>
<p>This link expires in 1 hour. If you didn't request this, please ignore this email.</p>`, name, resetURL, resetURL))
<p>This link expires in 1 hour. If you didn't request this, please ignore this email.</p>`, html.EscapeString(name), resetURL, resetURL))
}
func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {
@ -48,7 +51,7 @@ func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">View Ticket</a>
</p>
<p>If you believe the issue is not fully resolved, you can add a comment on the ticket page.</p>`, name, ticketTitle, ticketURL))
<p>If you believe the issue is not fully resolved, you can add a comment on the ticket page.</p>`, 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 {
<p>There is a new reply on your ticket <strong>"%s"</strong>.</p>
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">View Ticket</a>
</p>`, name, ticketTitle, ticketURL))
</p>`, html.EscapeString(name), html.EscapeString(ticketTitle), ticketURL))
}
func renderAccountApprovedEmail(name, loginURL string) string {
@ -68,7 +71,7 @@ func renderAccountApprovedEmail(name, loginURL string) string {
<p>Your account request has been approved. You can now log in and start creating tickets.</p>
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">Log In</a>
</p>`, name, loginURL))
</p>`, html.EscapeString(name), loginURL))
}
func renderWelcomeEmail(name, email, tempPassword, loginURL string) string {
@ -83,5 +86,5 @@ func renderWelcomeEmail(name, email, tempPassword, loginURL string) string {
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">Log In</a>
</p>
<p>Please change your password after logging in.</p>`, name, email, tempPassword, loginURL))
<p>Please change your password after logging in.</p>`, html.EscapeString(name), html.EscapeString(email), html.EscapeString(tempPassword), loginURL))
}

View File

@ -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

View File

@ -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)
}

View File

@ -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())
}

View File

@ -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")
}

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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)
}

View File

@ -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]
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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

View File

@ -15,11 +15,17 @@
</main>
</div>
<span class="hidden ring-blue-400 bg-blue-50 border-blue-400 hover:text-red-500"></span>
<script type="module">
<script>
if (document.querySelector('pre.mermaid')) {
const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.min.js';
s.integrity = 'sha384-rbtjAdnIQE/aQJGEgXrVUlMibdfTSa4PQju4HDhN3sR2PmaKFzhEafuePsl9H/9I';
s.crossOrigin = 'anonymous';
s.onload = function() {
mermaid.initialize({ startOnLoad: false, theme: 'default' });
await mermaid.run();
mermaid.run();
};
document.body.appendChild(s);
}
</script>
</body>