Merge branch 'main' into unify-ui
This commit is contained in:
commit
d9eede4c15
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version: '1.25'
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Binary
|
# Binary
|
||||||
forgejo-tickets
|
forgejo-tickets
|
||||||
|
server
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.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
|
RUN npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --minify
|
||||||
|
|
||||||
# Stage 2: Build Go binary
|
# Stage 2: Build Go binary
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -53,13 +54,14 @@ func main() {
|
||||||
log.Info().Str("bot_login", forgejoClient.BotLogin).Msg("forgejo bot login initialized")
|
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)
|
authService := auth.NewService(db, sessionStore, emailClient)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
go sessionStore.Cleanup(ctx, 30*time.Minute)
|
go sessionStore.Cleanup(ctx, 30*time.Minute)
|
||||||
|
go authService.CleanupExpiredTokens(ctx, 1*time.Hour)
|
||||||
|
|
||||||
router := publichandlers.NewRouter(publichandlers.Dependencies{
|
router := publichandlers.NewRouter(publichandlers.Dependencies{
|
||||||
DB: db,
|
DB: db,
|
||||||
|
|
|
||||||
12
go.mod
12
go.mod
|
|
@ -1,16 +1,19 @@
|
||||||
module github.com/mattnite/forgejo-tickets
|
module github.com/mattnite/forgejo-tickets
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.11.0
|
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/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/securecookie v1.1.2
|
||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/mrz1836/postmark v1.6.5
|
github.com/mrz1836/postmark v1.6.5
|
||||||
github.com/rs/zerolog v1.34.0
|
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/crypto v0.40.0
|
||||||
golang.org/x/oauth2 v0.25.0
|
golang.org/x/oauth2 v0.25.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
|
|
@ -44,7 +47,6 @@ require (
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // 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
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.25.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=
|
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 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY=
|
||||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
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/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 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
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 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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/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.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -81,11 +85,61 @@ func (p *AppleProvider) getUserInfo(ctx context.Context, token *oauth2.Token) (*
|
||||||
return nil, fmt.Errorf("missing id_token")
|
return nil, fmt.Errorf("missing id_token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse without verification since we already got the token from Apple
|
// Fetch Apple's JWKS
|
||||||
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
resp, err := http.Get("https://appleid.apple.com/auth/keys")
|
||||||
parsed, _, err := parser.ParseUnverified(idToken, jwt.MapClaims{})
|
|
||||||
if err != nil {
|
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)
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||||
|
|
@ -99,7 +153,7 @@ func (p *AppleProvider) getUserInfo(ctx context.Context, token *oauth2.Token) (*
|
||||||
return &OAuthUserInfo{
|
return &OAuthUserInfo{
|
||||||
ProviderUserID: sub,
|
ProviderUserID: sub,
|
||||||
Email: email,
|
Email: email,
|
||||||
Name: email, // Apple may not provide name in id_token
|
Name: email,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/email"
|
"github.com/mattnite/forgejo-tickets/internal/email"
|
||||||
|
|
@ -12,10 +14,21 @@ import (
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxLoginAttempts = 5
|
||||||
|
lockoutDuration = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginAttempt struct {
|
||||||
|
count int
|
||||||
|
lastFail time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
store *PGStore
|
store *PGStore
|
||||||
email *email.Client
|
email *email.Client
|
||||||
|
loginAttempts sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service {
|
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) {
|
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
|
var user models.User
|
||||||
if err := s.db.WithContext(ctx).Where("email = ?", emailAddr).First(&user).Error; err != nil {
|
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")
|
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 {
|
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
s.recordFailedAttempt(emailAddr)
|
||||||
return nil, fmt.Errorf("invalid email or password")
|
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")
|
return nil, fmt.Errorf("your account is pending admin approval")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear failed attempts on successful login
|
||||||
|
s.loginAttempts.Delete(emailAddr)
|
||||||
|
|
||||||
return &user, nil
|
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 {
|
func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error {
|
||||||
session, err := s.store.Get(r, sessionCookieName)
|
session, err := s.store.Get(r, sessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ type PGStore struct {
|
||||||
options *sessions.Options
|
options *sessions.Options
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPGStore(db *gorm.DB, keyPairs ...[]byte) *PGStore {
|
func NewPGStore(db *gorm.DB, secure bool, keyPairs ...[]byte) *PGStore {
|
||||||
return &PGStore{
|
return &PGStore{
|
||||||
db: db,
|
db: db,
|
||||||
codecs: securecookie.CodecsFromPairs(keyPairs...),
|
codecs: securecookie.CodecsFromPairs(keyPairs...),
|
||||||
|
|
@ -36,6 +36,7 @@ func NewPGStore(db *gorm.DB, keyPairs ...[]byte) *PGStore {
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: sessionMaxAge,
|
MaxAge: sessionMaxAge,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
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{
|
result := s.db.Where("token = ?", session.ID).Assign(models.Session{
|
||||||
|
UserID: userID,
|
||||||
Data: buf.Bytes(),
|
Data: buf.Bytes(),
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
}).FirstOrCreate(&dbSession)
|
}).FirstOrCreate(&dbSession)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/models"
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -85,6 +86,23 @@ func (s *Service) redeemToken(ctx context.Context, plainToken string, tokenType
|
||||||
return &user, nil
|
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 {
|
func hashToken(plainToken string) string {
|
||||||
h := sha256.Sum256([]byte(plainToken))
|
h := sha256.Sum256([]byte(plainToken))
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package config
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|
@ -40,7 +41,8 @@ type Config struct {
|
||||||
AppleKeyPath string
|
AppleKeyPath string
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
InitialAdminEmail string
|
InitialAdminEmail string
|
||||||
|
TailscaleAllowedUsers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
|
|
@ -66,11 +68,18 @@ func Load() (*Config, error) {
|
||||||
|
|
||||||
cfg.InitialAdminEmail = getEnv("INITIAL_ADMIN_EMAIL", "")
|
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 == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
return nil, fmt.Errorf("DATABASE_URL is required")
|
||||||
}
|
}
|
||||||
if cfg.SessionSecret == "" {
|
if len(cfg.SessionSecret) < 32 {
|
||||||
return nil, fmt.Errorf("SESSION_SECRET is required")
|
return nil, fmt.Errorf("SESSION_SECRET must be at least 32 characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ func clearConfigEnv(t *testing.T) {
|
||||||
|
|
||||||
func TestLoad_MissingDatabaseURL(t *testing.T) {
|
func TestLoad_MissingDatabaseURL(t *testing.T) {
|
||||||
clearConfigEnv(t)
|
clearConfigEnv(t)
|
||||||
t.Setenv("SESSION_SECRET", "secret-value")
|
t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch")
|
||||||
// DATABASE_URL is not set
|
// DATABASE_URL is not set
|
||||||
|
|
||||||
_, err := Load()
|
_, err := Load()
|
||||||
|
|
@ -49,7 +49,7 @@ func TestLoad_MissingSessionSecret(t *testing.T) {
|
||||||
t.Fatal("expected error when SESSION_SECRET is missing, got nil")
|
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 {
|
if err.Error() != expected {
|
||||||
t.Errorf("expected error %q, got %q", expected, err.Error())
|
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) {
|
func TestLoad_Success(t *testing.T) {
|
||||||
clearConfigEnv(t)
|
clearConfigEnv(t)
|
||||||
t.Setenv("DATABASE_URL", "postgres://localhost/test")
|
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()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -68,15 +68,15 @@ func TestLoad_Success(t *testing.T) {
|
||||||
if cfg.DatabaseURL != "postgres://localhost/test" {
|
if cfg.DatabaseURL != "postgres://localhost/test" {
|
||||||
t.Errorf("expected DatabaseURL %q, got %q", "postgres://localhost/test", cfg.DatabaseURL)
|
t.Errorf("expected DatabaseURL %q, got %q", "postgres://localhost/test", cfg.DatabaseURL)
|
||||||
}
|
}
|
||||||
if cfg.SessionSecret != "my-secret" {
|
if cfg.SessionSecret != "test-session-secret-that-is-32ch" {
|
||||||
t.Errorf("expected SessionSecret %q, got %q", "my-secret", cfg.SessionSecret)
|
t.Errorf("expected SessionSecret %q, got %q", "test-session-secret-that-is-32ch", cfg.SessionSecret)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_DefaultValues(t *testing.T) {
|
func TestLoad_DefaultValues(t *testing.T) {
|
||||||
clearConfigEnv(t)
|
clearConfigEnv(t)
|
||||||
t.Setenv("DATABASE_URL", "postgres://localhost/test")
|
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()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -97,7 +97,7 @@ func TestLoad_DefaultValues(t *testing.T) {
|
||||||
func TestLoad_OverrideDefaults(t *testing.T) {
|
func TestLoad_OverrideDefaults(t *testing.T) {
|
||||||
clearConfigEnv(t)
|
clearConfigEnv(t)
|
||||||
t.Setenv("DATABASE_URL", "postgres://localhost/test")
|
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("PUBLIC_ADDR", ":9090")
|
||||||
t.Setenv("BASE_URL", "https://example.com")
|
t.Setenv("BASE_URL", "https://example.com")
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ func TestLoad_OverrideDefaults(t *testing.T) {
|
||||||
func TestLoad_InitialAdminEmail(t *testing.T) {
|
func TestLoad_InitialAdminEmail(t *testing.T) {
|
||||||
clearConfigEnv(t)
|
clearConfigEnv(t)
|
||||||
t.Setenv("DATABASE_URL", "postgres://localhost/test")
|
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")
|
t.Setenv("INITIAL_ADMIN_EMAIL", "admin@example.com")
|
||||||
|
|
||||||
cfg, err := Load()
|
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)
|
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
|
package email
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
)
|
||||||
|
|
||||||
func emailWrapper(content string) string {
|
func emailWrapper(content string) string {
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
|
@ -24,7 +27,7 @@ func renderVerificationEmail(name, verifyURL string) string {
|
||||||
</p>
|
</p>
|
||||||
<p>Or copy and paste this link into your browser:</p>
|
<p>Or copy and paste this link into your browser:</p>
|
||||||
<p style="word-break: break-all; color: #2563eb;">%s</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 {
|
func renderPasswordResetEmail(name, resetURL string) string {
|
||||||
|
|
@ -37,7 +40,7 @@ func renderPasswordResetEmail(name, resetURL string) string {
|
||||||
</p>
|
</p>
|
||||||
<p>Or copy and paste this link into your browser:</p>
|
<p>Or copy and paste this link into your browser:</p>
|
||||||
<p style="word-break: break-all; color: #2563eb;">%s</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 {
|
func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {
|
||||||
|
|
@ -48,7 +51,7 @@ func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {
|
||||||
<p style="margin: 30px 0;">
|
<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>
|
<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>
|
||||||
<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 {
|
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>There is a new reply on your ticket <strong>"%s"</strong>.</p>
|
||||||
<p style="margin: 30px 0;">
|
<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>
|
<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 {
|
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>Your account request has been approved. You can now log in and start creating tickets.</p>
|
||||||
<p style="margin: 30px 0;">
|
<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>
|
<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 {
|
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;">
|
<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>
|
<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>
|
||||||
<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.
|
// 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) {
|
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)
|
httpReq, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read body: %w", err)
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/forgejo"
|
"github.com/mattnite/forgejo-tickets/internal/forgejo"
|
||||||
|
"github.com/mattnite/forgejo-tickets/internal/middleware"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/models"
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
||||||
"github.com/rs/zerolog/log"
|
"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")
|
log.Error().Err(err).Msg("send approval email error")
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := "/admin/users/pending?" + url.Values{
|
middleware.SetFlash(c, "success", "User "+user.Email+" has been approved")
|
||||||
"flash": {"User " + user.Email + " has been approved"},
|
c.Redirect(http.StatusSeeOther, "/admin/users/pending")
|
||||||
"flash_type": {"success"},
|
|
||||||
}.Encode()
|
|
||||||
c.Redirect(http.StatusSeeOther, redirectURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) Reject(c *gin.Context) {
|
func (h *UserHandler) Reject(c *gin.Context) {
|
||||||
|
|
@ -222,11 +219,8 @@ func (h *UserHandler) Reject(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := "/admin/users/pending?" + url.Values{
|
middleware.SetFlash(c, "success", "User request has been rejected")
|
||||||
"flash": {"User request has been rejected"},
|
c.Redirect(http.StatusSeeOther, "/admin/users/pending")
|
||||||
"flash_type": {"success"},
|
|
||||||
}.Encode()
|
|
||||||
c.Redirect(http.StatusSeeOther, redirectURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) UpdateRepos(c *gin.Context) {
|
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})
|
h.deps.DB.Create(&models.UserRepo{UserID: userID, RepoID: repoID})
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := "/admin/users/" + userID.String() + "?" + url.Values{
|
middleware.SetFlash(c, "success", "Project assignments updated")
|
||||||
"flash": {"Project assignments updated"},
|
c.Redirect(http.StatusSeeOther, "/admin/users/"+userID.String())
|
||||||
"flash_type": {"success"},
|
|
||||||
}.Encode()
|
|
||||||
c.Redirect(http.StatusSeeOther, redirectURL)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,38 @@ package public
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/auth"
|
"github.com/mattnite/forgejo-tickets/internal/auth"
|
||||||
|
"github.com/mattnite/forgejo-tickets/internal/middleware"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/models"
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
||||||
"github.com/rs/zerolog/log"
|
"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 {
|
type AuthHandler struct {
|
||||||
deps Dependencies
|
deps Dependencies
|
||||||
}
|
}
|
||||||
|
|
@ -85,8 +108,8 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(password) < 8 {
|
if errMsg := validatePassword(password); errMsg != "" {
|
||||||
data["Error"] = "Password must be at least 8 characters"
|
data["Error"] = errMsg
|
||||||
h.deps.Renderer.Render(c.Writer, c.Request, "register", data)
|
h.deps.Renderer.Render(c.Writer, c.Request, "register", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -111,11 +134,8 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := "/login?" + url.Values{
|
middleware.SetFlash(c, "success", "Account requested! Please check your email to verify your address. After verification, an admin will review your request.")
|
||||||
"flash": {"Account requested! Please check your email to verify your address. After verification, an admin will review your request."},
|
c.Redirect(http.StatusSeeOther, "/login")
|
||||||
"flash_type": {"success"},
|
|
||||||
}.Encode()
|
|
||||||
c.Redirect(http.StatusSeeOther, redirectURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
|
@ -138,11 +158,8 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := "/login?" + url.Values{
|
middleware.SetFlash(c, "success", "Email verified successfully. Your account is pending admin approval.")
|
||||||
"flash": {"Email verified successfully. Your account is pending admin approval."},
|
c.Redirect(http.StatusSeeOther, "/login")
|
||||||
"flash_type": {"success"},
|
|
||||||
}.Encode()
|
|
||||||
c.Redirect(http.StatusSeeOther, redirectURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) ForgotPasswordForm(c *gin.Context) {
|
func (h *AuthHandler) ForgotPasswordForm(c *gin.Context) {
|
||||||
|
|
@ -189,10 +206,10 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(password) < 8 {
|
if errMsg := validatePassword(password); errMsg != "" {
|
||||||
h.deps.Renderer.Render(c.Writer, c.Request, "reset-password", map[string]interface{}{
|
h.deps.Renderer.Render(c.Writer, c.Request, "reset-password", map[string]interface{}{
|
||||||
"Token": token,
|
"Token": token,
|
||||||
"Error": "Password must be at least 8 characters",
|
"Error": errMsg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -206,9 +223,6 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := "/login?" + url.Values{
|
middleware.SetFlash(c, "success", "Password reset successfully. You can now log in.")
|
||||||
"flash": {"Password reset successfully. You can now log in."},
|
c.Redirect(http.StatusSeeOther, "/login")
|
||||||
"flash_type": {"success"},
|
|
||||||
}.Encode()
|
|
||||||
c.Redirect(http.StatusSeeOther, redirectURL)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/auth"
|
"github.com/mattnite/forgejo-tickets/internal/auth"
|
||||||
|
"github.com/mattnite/forgejo-tickets/internal/middleware"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
@ -53,12 +53,16 @@ func (h *OAuthHandler) Login(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
state := generateState()
|
state := generateState()
|
||||||
session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state")
|
isSecure := strings.HasPrefix(h.deps.Config.BaseURL, "https")
|
||||||
session.Values["state"] = state
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
session.Values["user_id"] = "00000000-0000-0000-0000-000000000000" // placeholder for save
|
Name: "oauth_state",
|
||||||
if err := session.Save(c.Request, c.Writer); err != nil {
|
Value: state,
|
||||||
log.Error().Err(err).Msg("save oauth state error")
|
Path: "/",
|
||||||
}
|
MaxAge: 600, // 10 minutes
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
url := provider.Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
url := provider.Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||||
|
|
@ -73,12 +77,17 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify state
|
// Verify state
|
||||||
session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state")
|
stateCookie, err := c.Request.Cookie("oauth_state")
|
||||||
expectedState, _ := session.Values["state"].(string)
|
if err != nil || c.Query("state") != stateCookie.Value {
|
||||||
if c.Query("state") != expectedState {
|
|
||||||
c.String(http.StatusBadRequest, "Invalid state parameter")
|
c.String(http.StatusBadRequest, "Invalid state parameter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Clear the state cookie
|
||||||
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
|
Name: "oauth_state",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
token, err := provider.Config.Exchange(c.Request.Context(), 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)
|
user, err := h.deps.Auth.FindOrCreateOAuthUser(c.Request.Context(), provider.Name, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "pending admin approval") {
|
if strings.Contains(err.Error(), "pending admin approval") {
|
||||||
redirectURL := "/login?" + url.Values{
|
middleware.SetFlash(c, "info", err.Error())
|
||||||
"flash": {err.Error()},
|
c.Redirect(http.StatusSeeOther, "/login")
|
||||||
"flash_type": {"info"},
|
|
||||||
}.Encode()
|
|
||||||
c.Redirect(http.StatusSeeOther, redirectURL)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Error().Err(err).Msg("find or create oauth user error")
|
log.Error().Err(err).Msg("find or create oauth user error")
|
||||||
|
|
@ -137,12 +143,16 @@ func (h *OAuthHandler) appleLogin(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
state := generateState()
|
state := generateState()
|
||||||
session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state")
|
isSecure := strings.HasPrefix(h.deps.Config.BaseURL, "https")
|
||||||
session.Values["state"] = state
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
session.Values["user_id"] = "00000000-0000-0000-0000-000000000000"
|
Name: "oauth_state",
|
||||||
if err := session.Save(c.Request, c.Writer); err != nil {
|
Value: state,
|
||||||
log.Error().Err(err).Msg("save oauth state error")
|
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())
|
url := appleProvider.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, auth.AppleAuthCodeOption())
|
||||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||||
|
|
@ -164,12 +174,17 @@ func (h *OAuthHandler) AppleCallback(c *gin.Context) {
|
||||||
code := c.PostForm("code")
|
code := c.PostForm("code")
|
||||||
state := c.PostForm("state")
|
state := c.PostForm("state")
|
||||||
|
|
||||||
session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state")
|
stateCookie, err := c.Request.Cookie("oauth_state")
|
||||||
expectedState, _ := session.Values["state"].(string)
|
if err != nil || state != stateCookie.Value {
|
||||||
if state != expectedState {
|
|
||||||
c.String(http.StatusBadRequest, "Invalid state parameter")
|
c.String(http.StatusBadRequest, "Invalid state parameter")
|
||||||
return
|
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)
|
token, err := appleProvider.ExchangeCode(c.Request.Context(), code)
|
||||||
if err != nil {
|
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)
|
user, err := h.deps.Auth.FindOrCreateOAuthUser(c.Request.Context(), "apple", info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "pending admin approval") {
|
if strings.Contains(err.Error(), "pending admin approval") {
|
||||||
redirectURL := "/login?" + url.Values{
|
middleware.SetFlash(c, "info", err.Error())
|
||||||
"flash": {err.Error()},
|
c.Redirect(http.StatusSeeOther, "/login")
|
||||||
"flash_type": {"info"},
|
|
||||||
}.Encode()
|
|
||||||
c.Redirect(http.StatusSeeOther, redirectURL)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Error().Err(err).Msg("find or create apple user error")
|
log.Error().Err(err).Msg("find or create apple user error")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package public
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/auth"
|
"github.com/mattnite/forgejo-tickets/internal/auth"
|
||||||
|
|
@ -31,6 +32,7 @@ func NewRouter(deps Dependencies) *gin.Engine {
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.Logging)
|
r.Use(middleware.Logging)
|
||||||
r.Use(middleware.Recovery)
|
r.Use(middleware.Recovery)
|
||||||
|
r.Use(middleware.SecurityHeaders(strings.HasPrefix(deps.Config.BaseURL, "https")))
|
||||||
r.Use(deps.Auth.SessionMiddleware)
|
r.Use(deps.Auth.SessionMiddleware)
|
||||||
|
|
||||||
csrfSecret := []byte(deps.Config.SessionSecret)
|
csrfSecret := []byte(deps.Config.SessionSecret)
|
||||||
|
|
@ -41,7 +43,7 @@ func NewRouter(deps Dependencies) *gin.Engine {
|
||||||
c.String(http.StatusOK, "ok")
|
c.String(http.StatusOK, "ok")
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Static("/static", "web/static")
|
r.StaticFS("/static", gin.Dir("web/static", false))
|
||||||
|
|
||||||
webhookHandler := &WebhookHandler{deps: deps}
|
webhookHandler := &WebhookHandler{deps: deps}
|
||||||
r.POST("/webhooks/forgejo/:repoSlug", webhookHandler.HandleForgejoWebhook)
|
r.POST("/webhooks/forgejo/:repoSlug", webhookHandler.HandleForgejoWebhook)
|
||||||
|
|
@ -49,6 +51,8 @@ func NewRouter(deps Dependencies) *gin.Engine {
|
||||||
ssoHandler := &SSOHandler{deps: deps}
|
ssoHandler := &SSOHandler{deps: deps}
|
||||||
r.GET("/sso/:slug", ssoHandler.HandleSSO)
|
r.GET("/sso/:slug", ssoHandler.HandleSSO)
|
||||||
|
|
||||||
|
authRateLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
|
||||||
|
|
||||||
csrf := r.Group("/")
|
csrf := r.Group("/")
|
||||||
csrf.Use(csrfMiddleware)
|
csrf.Use(csrfMiddleware)
|
||||||
{
|
{
|
||||||
|
|
@ -57,13 +61,13 @@ func NewRouter(deps Dependencies) *gin.Engine {
|
||||||
|
|
||||||
authHandler := &AuthHandler{deps: deps}
|
authHandler := &AuthHandler{deps: deps}
|
||||||
csrf.GET("/login", authHandler.LoginForm)
|
csrf.GET("/login", authHandler.LoginForm)
|
||||||
csrf.POST("/login", authHandler.Login)
|
csrf.POST("/login", authRateLimiter.Middleware(), authHandler.Login)
|
||||||
csrf.GET("/register", authHandler.RegisterForm)
|
csrf.GET("/register", authHandler.RegisterForm)
|
||||||
csrf.POST("/register", authHandler.Register)
|
csrf.POST("/register", authRateLimiter.Middleware(), authHandler.Register)
|
||||||
csrf.POST("/logout", authHandler.Logout)
|
csrf.POST("/logout", authHandler.Logout)
|
||||||
csrf.GET("/verify-email", authHandler.VerifyEmail)
|
csrf.GET("/verify-email", authHandler.VerifyEmail)
|
||||||
csrf.GET("/forgot-password", authHandler.ForgotPasswordForm)
|
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.GET("/reset-password", authHandler.ResetPasswordForm)
|
||||||
csrf.POST("/reset-password", authHandler.ResetPassword)
|
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")
|
c.String(http.StatusInternalServerError, "failed to create user")
|
||||||
return
|
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
|
// Update existing user if needed
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package public
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -531,7 +532,7 @@ func (h *TicketHandler) proxyAssetDownload(c *gin.Context, assetURL, filename st
|
||||||
contentType = "application/octet-stream"
|
contentType = "application/octet-stream"
|
||||||
}
|
}
|
||||||
c.Header("Content-Type", contentType)
|
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 != "" {
|
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
||||||
c.Header("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()
|
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/gorilla/csrf"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/auth"
|
"github.com/mattnite/forgejo-tickets/internal/auth"
|
||||||
|
"github.com/mattnite/forgejo-tickets/internal/middleware"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/models"
|
"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,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg := req.URL.Query().Get("flash"); msg != "" {
|
if flashType, flashMsg := middleware.GetFlash(req, w); flashMsg != "" {
|
||||||
flashType := req.URL.Query().Get("flash_type")
|
pd.Flash = &Flash{Type: flashType, Message: flashMsg}
|
||||||
if flashType == "" {
|
|
||||||
flashType = "info"
|
|
||||||
}
|
|
||||||
pd.Flash = &Flash{Type: flashType, Message: msg}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,17 @@
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<span class="hidden ring-blue-400 bg-blue-50 border-blue-400 hover:text-red-500"></span>
|
<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')) {
|
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');
|
||||||
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
s.src = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.min.js';
|
||||||
await mermaid.run();
|
s.integrity = 'sha384-rbtjAdnIQE/aQJGEgXrVUlMibdfTSa4PQju4HDhN3sR2PmaKFzhEafuePsl9H/9I';
|
||||||
|
s.crossOrigin = 'anonymous';
|
||||||
|
s.onload = function() {
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
||||||
|
mermaid.run();
|
||||||
|
};
|
||||||
|
document.body.appendChild(s);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue