Merge branch 'main' into unify-ui
This commit is contained in:
commit
d9eede4c15
|
|
@ -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,5 +1,6 @@
|
|||
# Binary
|
||||
forgejo-tickets
|
||||
server
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
12
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
|
||||
|
|
|
|||
9
go.sum
9
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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[:])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue