refac: removed 7 direct and 12 indirect go modules for custom code instead
This commit is contained in:
1 parent
cfcf108488
commit
c68e9a7858
73 files changed
+2455
-428
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
domainAudit "RenewCMS/internal/domain/audit"
|
||||
@@ -37,14 +38,12 @@ import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type UseCases struct {
|
||||
apiKeyAuthOnce sync.Once
|
||||
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||
Token *jwtauth.JWTAuth
|
||||
Token *authtoken.Auth
|
||||
PurgeScheduler *scheduler.Runner
|
||||
ActivityCleanupScheduler *scheduler.Runner
|
||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||
@@ -347,7 +346,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
return &UseCases{
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
||||
Token: authtoken.New([]byte(settings.JWTSecret)),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/user"
|
||||
@@ -9,12 +10,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type AcceptInviteHandler struct {
|
||||
Token *jwtauth.JWTAuth
|
||||
Token *authtoken.Auth
|
||||
GetInviteUseCase *useCases.GetInviteUseCase
|
||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||
IsProduction bool
|
||||
@@ -30,7 +29,7 @@ type inviteEmailResponse struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler {
|
||||
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *authtoken.Auth, isProduction bool) *AcceptInviteHandler {
|
||||
return &AcceptInviteHandler{
|
||||
Token: token,
|
||||
GetInviteUseCase: getInvite,
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
@@ -9,12 +10,10 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type LoginHandler struct {
|
||||
Token *jwtauth.JWTAuth
|
||||
Token *authtoken.Auth
|
||||
CreateUseCase *useCases.CreateUserUseCase
|
||||
AuthenticateUseCase *useCases.AuthenticateUserUseCase
|
||||
IsProduction bool
|
||||
@@ -31,7 +30,7 @@ type LoginCredentials struct {
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
}
|
||||
|
||||
func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
|
||||
func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
|
||||
return &LoginHandler{
|
||||
Token: token,
|
||||
CreateUseCase: create,
|
||||
@@ -52,15 +51,15 @@ func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
|
||||
})
|
||||
}
|
||||
|
||||
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
|
||||
func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
|
||||
tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "jwt",
|
||||
Value: tokenString,
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
Expires: time.Now().Add(authtoken.SessionLifetime),
|
||||
Secure: isProduction,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
@@ -10,9 +11,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
)
|
||||
|
||||
const bearerScheme = "bearer "
|
||||
@@ -79,13 +77,7 @@ func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests in
|
||||
return
|
||||
}
|
||||
|
||||
token, err := sessionTokenFor(authenticated)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := jwtauth.NewContext(r.Context(), token, nil)
|
||||
ctx := authtoken.NewContext(r.Context(), sessionClaimsFor(authenticated), nil)
|
||||
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
|
||||
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
|
||||
limited.ServeHTTP(w, r.WithContext(ctx))
|
||||
@@ -101,15 +93,11 @@ func writeAPIKeyError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
|
||||
token := jwt.New()
|
||||
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
|
||||
return nil, err
|
||||
func sessionClaimsFor(authenticated useCases.AuthenticatedAPIKey) authtoken.Claims {
|
||||
return authtoken.Claims{
|
||||
"user_id": float64(authenticated.User.ID),
|
||||
"token_version": float64(authenticated.User.TokenVersion),
|
||||
}
|
||||
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func apiKeyRateLimitKey(r *http.Request) string {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
corsAllowAnyOrigin = "*"
|
||||
corsWildcardMarker = "*"
|
||||
corsPreflightMaxAge = 300
|
||||
corsPreflightSuccess = http.StatusNoContent
|
||||
)
|
||||
|
||||
var (
|
||||
corsAllowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}
|
||||
corsAllowedHeaders = []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}
|
||||
corsExposedHeaders = []string{"Link", "X-Total-Count"}
|
||||
)
|
||||
|
||||
type originWildcard struct {
|
||||
prefix string
|
||||
suffix string
|
||||
}
|
||||
|
||||
func (w originWildcard) matches(origin string) bool {
|
||||
return len(origin) >= len(w.prefix)+len(w.suffix) && strings.HasPrefix(origin, w.prefix) && strings.HasSuffix(origin, w.suffix)
|
||||
}
|
||||
|
||||
type originPolicy struct {
|
||||
anyOrigin bool
|
||||
exact map[string]struct{}
|
||||
wildcards []originWildcard
|
||||
}
|
||||
|
||||
func newOriginPolicy(origins []string) originPolicy {
|
||||
policy := originPolicy{exact: map[string]struct{}{}}
|
||||
for _, origin := range origins {
|
||||
origin = strings.ToLower(strings.TrimSpace(origin))
|
||||
prefix, suffix, hasWildcard := strings.Cut(origin, corsWildcardMarker)
|
||||
switch {
|
||||
case origin == "":
|
||||
case origin == corsAllowAnyOrigin:
|
||||
policy.anyOrigin = true
|
||||
case hasWildcard:
|
||||
policy.wildcards = append(policy.wildcards, originWildcard{prefix: prefix, suffix: suffix})
|
||||
default:
|
||||
policy.exact[origin] = struct{}{}
|
||||
}
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (p originPolicy) allows(origin string) bool {
|
||||
if p.anyOrigin {
|
||||
return true
|
||||
}
|
||||
origin = strings.ToLower(origin)
|
||||
if _, ok := p.exact[origin]; ok {
|
||||
return true
|
||||
}
|
||||
for _, wildcard := range p.wildcards {
|
||||
if wildcard.matches(origin) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPreflight(r *http.Request) bool {
|
||||
return r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != ""
|
||||
}
|
||||
|
||||
func containsFold(allowed []string, value string) bool {
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(candidate, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requestedHeadersAllowed(r *http.Request) bool {
|
||||
requested := r.Header.Get("Access-Control-Request-Headers")
|
||||
if requested == "" {
|
||||
return true
|
||||
}
|
||||
for _, header := range strings.Split(requested, ",") {
|
||||
if header = strings.TrimSpace(header); header != "" && !containsFold(corsAllowedHeaders, header) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
||||
policy := newOriginPolicy(allowedOrigins)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Vary", "Origin")
|
||||
if isPreflight(r) {
|
||||
answerPreflight(w, r, origin, policy)
|
||||
return
|
||||
}
|
||||
|
||||
if policy.allows(origin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Expose-Headers", strings.Join(corsExposedHeaders, ", "))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func answerPreflight(w http.ResponseWriter, r *http.Request, origin string, policy originPolicy) {
|
||||
w.Header().Add("Vary", "Access-Control-Request-Method")
|
||||
w.Header().Add("Vary", "Access-Control-Request-Headers")
|
||||
|
||||
method := r.Header.Get("Access-Control-Request-Method")
|
||||
if !policy.allows(origin) || !containsFold(corsAllowedMethods, method) || !requestedHeadersAllowed(r) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", strings.ToUpper(method))
|
||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(corsAllowedHeaders, ", "))
|
||||
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsPreflightMaxAge))
|
||||
w.WriteHeader(corsPreflightSuccess)
|
||||
}
|
||||
@@ -2,16 +2,13 @@ package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
)
|
||||
|
||||
func RequireSignedIn(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, _, err := jwtauth.FromContext(r.Context())
|
||||
if err != nil || token == nil || jwt.Validate(token) != nil {
|
||||
if _, err := authtoken.FromContext(r.Context()); err != nil {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package authtoken
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionLifetime = 24 * time.Hour
|
||||
|
||||
algorithmHS256 = "HS256"
|
||||
tokenType = "JWT"
|
||||
maxTokenBytes = 4096
|
||||
segmentCount = 3
|
||||
segmentSep = "."
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoToken = errors.New("no token found")
|
||||
ErrInvalid = errors.New("token is invalid")
|
||||
ErrExpired = errors.New("token is expired")
|
||||
ErrNotYetValid = errors.New("token is not yet valid")
|
||||
)
|
||||
|
||||
type Claims map[string]any
|
||||
|
||||
type header struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ,omitempty"`
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
secret []byte
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
type Option func(*Auth)
|
||||
|
||||
func WithClock(clock func() time.Time) Option {
|
||||
return func(a *Auth) { a.clock = clock }
|
||||
}
|
||||
|
||||
func New(secret []byte, options ...Option) *Auth {
|
||||
auth := &Auth{secret: secret, clock: time.Now}
|
||||
for _, option := range options {
|
||||
option(auth)
|
||||
}
|
||||
return auth
|
||||
}
|
||||
|
||||
func (a *Auth) Encode(claims map[string]any) (string, error) {
|
||||
payload := maps.Clone(claims)
|
||||
if payload == nil {
|
||||
payload = map[string]any{}
|
||||
}
|
||||
now := a.clock()
|
||||
if _, ok := payload["iat"]; !ok {
|
||||
payload["iat"] = now.Unix()
|
||||
}
|
||||
if _, ok := payload["exp"]; !ok {
|
||||
payload["exp"] = now.Add(SessionLifetime).Unix()
|
||||
}
|
||||
|
||||
headerSegment, err := encodeSegment(header{Algorithm: algorithmHS256, Type: tokenType})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payloadSegment, err := encodeSegment(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signingInput := headerSegment + segmentSep + payloadSegment
|
||||
return signingInput + segmentSep + a.sign(signingInput), nil
|
||||
}
|
||||
|
||||
func (a *Auth) Decode(token string) (Claims, error) {
|
||||
if token == "" {
|
||||
return nil, ErrNoToken
|
||||
}
|
||||
if len(token) > maxTokenBytes {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
|
||||
segments := strings.Split(token, segmentSep)
|
||||
if len(segments) != segmentCount {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
|
||||
var tokenHeader header
|
||||
if err := decodeSegment(segments[0], &tokenHeader); err != nil || tokenHeader.Algorithm != algorithmHS256 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
|
||||
signingInput := segments[0] + segmentSep + segments[1]
|
||||
if !hmac.Equal([]byte(a.sign(signingInput)), []byte(segments[2])) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
|
||||
var claims Claims
|
||||
if err := decodeSegment(segments[1], &claims); err != nil || claims == nil {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
|
||||
if err := a.validateTimes(claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (a *Auth) validateTimes(claims Claims) error {
|
||||
now := a.clock()
|
||||
|
||||
expiry, present, err := numericDate(claims, "exp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if present && !now.Before(expiry) {
|
||||
return ErrExpired
|
||||
}
|
||||
|
||||
notBefore, present, err := numericDate(claims, "nbf")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if present && now.Before(notBefore) {
|
||||
return ErrNotYetValid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func numericDate(claims Claims, name string) (time.Time, bool, error) {
|
||||
raw, present := claims[name]
|
||||
if !present {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
seconds, ok := raw.(float64)
|
||||
if !ok {
|
||||
return time.Time{}, false, ErrInvalid
|
||||
}
|
||||
return time.Unix(int64(seconds), 0), true, nil
|
||||
}
|
||||
|
||||
func (a *Auth) sign(signingInput string) string {
|
||||
mac := hmac.New(sha256.New, a.secret)
|
||||
mac.Write([]byte(signingInput))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func encodeSegment(value any) (string, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(encoded), nil
|
||||
}
|
||||
|
||||
func decodeSegment(segment string, into any) error {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(segment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(decoded, into)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package authtoken
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "jwt"
|
||||
bearerPrefix = "bearer "
|
||||
)
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
type verdict struct {
|
||||
claims Claims
|
||||
err error
|
||||
}
|
||||
|
||||
func Verifier(auth *Auth) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := auth.Decode(tokenFromRequest(r))
|
||||
next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), claims, err)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Authenticator(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := FromContext(r.Context()); err != nil {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, claims Claims, err error) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, verdict{claims: claims, err: err})
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) (Claims, error) {
|
||||
stored, ok := ctx.Value(contextKey{}).(verdict)
|
||||
switch {
|
||||
case !ok:
|
||||
return nil, ErrNoToken
|
||||
case stored.err != nil:
|
||||
return nil, stored.err
|
||||
case stored.claims == nil:
|
||||
return nil, ErrNoToken
|
||||
}
|
||||
return stored.claims, nil
|
||||
}
|
||||
|
||||
func tokenFromRequest(r *http.Request) string {
|
||||
if token := tokenFromHeader(r); token != "" {
|
||||
return token
|
||||
}
|
||||
return tokenFromCookie(r)
|
||||
}
|
||||
|
||||
func tokenFromHeader(r *http.Request) string {
|
||||
header := r.Header.Get("Authorization")
|
||||
if len(header) <= len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) {
|
||||
return ""
|
||||
}
|
||||
return header[len(bearerPrefix):]
|
||||
}
|
||||
|
||||
func tokenFromCookie(r *http.Request) string {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
package claims
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func UserId(r *http.Request) (uint32, bool) {
|
||||
_, claims, err := jwtauth.FromContext(r.Context())
|
||||
claims, err := authtoken.FromContext(r.Context())
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
@@ -31,7 +30,7 @@ func UserId(r *http.Request) (uint32, bool) {
|
||||
}
|
||||
|
||||
func TokenVersion(r *http.Request) (uint32, bool) {
|
||||
_, claims, err := jwtauth.FromContext(r.Context())
|
||||
claims, err := authtoken.FromContext(r.Context())
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
+42
-47
@@ -18,21 +18,23 @@ import (
|
||||
"RenewCMS/api/handlers/update"
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/MadAppGang/httplog"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
|
||||
if environment == "development" {
|
||||
r.Use(httplog.LoggerWithName(routerName))
|
||||
r.Use(chimiddleware.RequestLogger(&chimiddleware.DefaultLogFormatter{
|
||||
Logger: log.New(os.Stdout, "["+routerName+"] ", log.LstdFlags),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Get("/{id}/seo", seoHandler.Resolve)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
@@ -83,7 +85,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
|
||||
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
|
||||
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
|
||||
@@ -114,16 +116,16 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
@@ -137,10 +139,10 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
|
||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Patch("/me", userHandler.UpdateMe)
|
||||
@@ -163,19 +165,19 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", settingsHandler.GetSettings)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
@@ -193,19 +195,19 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
|
||||
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", siteHandler.List)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
@@ -223,19 +225,19 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase, container.GetUserUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", authorHandler.List)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/me", authorHandler.CreateMine)
|
||||
@@ -256,11 +258,11 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han
|
||||
|
||||
entryHandler := entry.NewHandler(container.CreateEntryUseCase, container.GetEntryUseCase, container.ListEntriesUseCase, container.UpdateEntryUseCase, container.DeleteEntryUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", contentTypeHandler.List)
|
||||
@@ -284,11 +286,11 @@ func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Han
|
||||
|
||||
builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
@@ -303,10 +305,10 @@ func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
|
||||
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", activityHandler.List)
|
||||
@@ -331,9 +333,9 @@ func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
|
||||
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/update", updateHandler.GetStatus)
|
||||
@@ -360,9 +362,9 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
|
||||
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/", apiKeyHandler.Create)
|
||||
@@ -376,11 +378,11 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
|
||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -413,10 +415,10 @@ func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Ha
|
||||
|
||||
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
@@ -440,7 +442,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Use(middleware.JsonContentTypeMiddleware)
|
||||
r.Get("/", GetHelloWorld)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||
})
|
||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||
@@ -478,14 +480,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
|
||||
if len(origins) == 0 {
|
||||
return
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: origins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link", "X-Total-Count"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Use(middleware.CORS(origins))
|
||||
}
|
||||
|
||||
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
|
||||
+6
-3
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,8 +16,6 @@ import (
|
||||
"slices"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
@@ -106,9 +105,13 @@ func initEnvVariables() {
|
||||
|
||||
func loadEnvFile() {
|
||||
for _, envLocation := range possibleEnvFileLocations {
|
||||
if godotenv.Load(envLocation) == nil {
|
||||
err := config.LoadDotenv(envLocation)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
log.Println("WARNING: Could not parse env file:", err)
|
||||
}
|
||||
}
|
||||
log.Println("WARNING: Could not load any .env file")
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ Media usage has more sources than article bodies: `Article.CoverImageID` and `So
|
||||
|
||||
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
|
||||
|
||||
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under a `singleflight` keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
|
||||
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under an in-process single-flight group keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
|
||||
|
||||
The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepository` (unused totals, top sources by bytes and cursor-paged row listing, implemented by the same persistence type as `VariantFileRepository`), `VariantUsageRepository` (one batched write of request times and per-day rejection counts, and the rejection total of a window) and `VariantUsageTracker` (in-memory hits and rejections). `internal/infrastructure/usage.Tracker` implements the tracker; `GetVariantCacheStatsUseCase` and `PurgeVariantCacheUseCase` use the ports only. See [image-variants.md](image-variants.md).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Configuration
|
||||
|
||||
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`.
|
||||
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`. The parser reads `NAME=value` lines, an optional `export ` prefix, `#` comments (inline ones need a space before the `#`), and single- or double-quoted values (`\n`, `\t`, `\"` and `\\` are unescaped in double quotes). It does not expand `$VARIABLES` and does not support multi-line values. A malformed line makes the whole file ignored, and the server logs the line number.
|
||||
|
||||
Copy [`.env.example`](../.env.example) to `.env` to get started. When a required variable is missing, the server refuses to start and names the variable.
|
||||
|
||||
@@ -29,7 +29,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
|
||||
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
|
||||
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
|
||||
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
|
||||
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. Matching is case-insensitive. An entry may contain one `*` (`https://*.preview.example.com`), and a lone `*` allows every origin. Credentials are never allowed cross-origin. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
|
||||
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
|
||||
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
|
||||
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
|
||||
|
||||
@@ -36,7 +36,7 @@ setting does, so a client holding an old value gets a fresh 200. `Cache-Control`
|
||||
Every generated file is a blob in `image.Storage` (local directory or S3) plus a row in
|
||||
`image_variant_files` (variant, source stored name, cached stored name, content type, size, settings
|
||||
fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation
|
||||
(`singleflight` keyed by variant, settings and source); different variants or different images are
|
||||
(a single-flight group keyed by variant, settings and source); different variants or different images are
|
||||
generated in parallel.
|
||||
|
||||
Only a stored name that has an image record can be a variant source, so generated files and orphan blobs
|
||||
|
||||
@@ -3,7 +3,6 @@ module RenewCMS
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/MadAppGang/httplog v1.3.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
|
||||
@@ -11,20 +10,14 @@ require (
|
||||
github.com/gen2brain/webp v0.6.4
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.3.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/text v0.42.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.3
|
||||
gorm.io/gorm v1.31.2
|
||||
@@ -32,7 +25,6 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
|
||||
@@ -47,36 +39,25 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
|
||||
github.com/aws/smithy-go v1.28.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.4.0 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
modernc.org/libc v1.67.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/MadAppGang/httplog v1.3.0 h1:1XU54TO8kiqTeO+7oZLKAM3RP/cJ7SadzslRcKspVHo=
|
||||
github.com/MadAppGang/httplog v1.3.0/go.mod h1:gpYEdkjh/Cda6YxtDy4AB7KY+fR7mb3SqBZw74A5hJ4=
|
||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
|
||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
|
||||
@@ -41,14 +37,10 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWx
|
||||
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
|
||||
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
|
||||
@@ -59,10 +51,6 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -73,16 +61,12 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
|
||||
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -95,26 +79,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8=
|
||||
github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4=
|
||||
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
@@ -124,16 +90,11 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
@@ -155,11 +116,7 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
dotenvExportPrefix = "export "
|
||||
dotenvCommentMarker = "#"
|
||||
dotenvInlineComment = " #"
|
||||
dotenvByteOrderMark = "\xef\xbb\xbf"
|
||||
dotenvMaxLineBytes = 1 << 20
|
||||
dotenvAssignment = "="
|
||||
dotenvDoubleQuote = '"'
|
||||
dotenvSingleQuote = '\''
|
||||
dotenvEscapeBackslash = '\\'
|
||||
)
|
||||
|
||||
var dotenvDoubleQuoteEscapes = map[byte]byte{
|
||||
'n': '\n',
|
||||
'r': '\r',
|
||||
't': '\t',
|
||||
'"': '"',
|
||||
'\\': '\\',
|
||||
}
|
||||
|
||||
func LoadDotenv(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
values, err := ParseDotenv(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for name, value := range values {
|
||||
if _, isSet := os.LookupEnv(name); !isSet {
|
||||
if err := os.Setenv(name, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseDotenv(reader io.Reader) (map[string]string, error) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), dotenvMaxLineBytes)
|
||||
|
||||
values := map[string]string{}
|
||||
for lineNumber := 1; scanner.Scan(); lineNumber++ {
|
||||
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), dotenvByteOrderMark))
|
||||
if line == "" || strings.HasPrefix(line, dotenvCommentMarker) {
|
||||
continue
|
||||
}
|
||||
name, value, err := parseDotenvAssignment(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: %w", lineNumber, err)
|
||||
}
|
||||
values[name] = value
|
||||
}
|
||||
return values, scanner.Err()
|
||||
}
|
||||
|
||||
func parseDotenvAssignment(line string) (string, string, error) {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, dotenvExportPrefix))
|
||||
name, raw, found := strings.Cut(line, dotenvAssignment)
|
||||
name = strings.TrimSpace(name)
|
||||
if !found || !isDotenvName(name) {
|
||||
return "", "", fmt.Errorf("expected NAME=value")
|
||||
}
|
||||
value, err := parseDotenvValue(strings.TrimSpace(raw))
|
||||
return name, value, err
|
||||
}
|
||||
|
||||
func isDotenvName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
isLetter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
|
||||
isDigit := r >= '0' && r <= '9'
|
||||
if !isLetter && !isDigit && r != '_' && r != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseDotenvValue(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
switch raw[0] {
|
||||
case dotenvDoubleQuote:
|
||||
return parseDoubleQuoted(raw)
|
||||
case dotenvSingleQuote:
|
||||
return parseSingleQuoted(raw)
|
||||
}
|
||||
if comment := strings.Index(raw, dotenvInlineComment); comment >= 0 {
|
||||
raw = raw[:comment]
|
||||
}
|
||||
return strings.TrimSpace(raw), nil
|
||||
}
|
||||
|
||||
func parseSingleQuoted(raw string) (string, error) {
|
||||
end := strings.IndexByte(raw[1:], dotenvSingleQuote)
|
||||
if end < 0 {
|
||||
return "", fmt.Errorf("unterminated single quote")
|
||||
}
|
||||
return raw[1 : 1+end], nil
|
||||
}
|
||||
|
||||
func parseDoubleQuoted(raw string) (string, error) {
|
||||
var value strings.Builder
|
||||
for i := 1; i < len(raw); i++ {
|
||||
switch raw[i] {
|
||||
case dotenvDoubleQuote:
|
||||
return value.String(), nil
|
||||
case dotenvEscapeBackslash:
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return "", fmt.Errorf("unterminated double quote")
|
||||
}
|
||||
if unescaped, known := dotenvDoubleQuoteEscapes[raw[i]]; known {
|
||||
value.WriteByte(unescaped)
|
||||
} else {
|
||||
value.WriteByte(dotenvEscapeBackslash)
|
||||
value.WriteByte(raw[i])
|
||||
}
|
||||
default:
|
||||
value.WriteByte(raw[i])
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unterminated double quote")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"errors"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type loginAuth struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
if !server.TLS && !isLoopback(server.Name) {
|
||||
return "", nil, errors.New("refusing to send credentials over an unencrypted connection")
|
||||
}
|
||||
return "LOGIN", nil, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(string(fromServer))) {
|
||||
case "username:":
|
||||
return []byte(a.username), nil
|
||||
case "password:":
|
||||
return []byte(a.password), nil
|
||||
}
|
||||
return nil, errors.New("unexpected server challenge")
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
func selectAuth(mechanisms string, settings config.SmtpSettings) smtp.Auth {
|
||||
advertised := strings.Fields(strings.ToUpper(mechanisms))
|
||||
for _, mechanism := range advertised {
|
||||
if mechanism == "PLAIN" {
|
||||
return smtp.PlainAuth("", settings.Login, settings.Password, settings.Host)
|
||||
}
|
||||
}
|
||||
return &loginAuth{username: settings.Login, password: settings.Password}
|
||||
}
|
||||
@@ -4,22 +4,47 @@ import (
|
||||
domain "RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MailRepository struct {
|
||||
smtp config.SmtpSettings
|
||||
transport transport
|
||||
sender string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type Option func(*options)
|
||||
|
||||
type options struct {
|
||||
tlsConfig *tls.Config
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func WithTLSConfig(tlsConfig *tls.Config) Option {
|
||||
return func(o *options) { o.tlsConfig = tlsConfig }
|
||||
}
|
||||
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(o *options) { o.now = now }
|
||||
}
|
||||
|
||||
//go:embed templates/*
|
||||
var mailTemplateFiles embed.FS
|
||||
|
||||
func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
|
||||
return &MailRepository{smtp: smtp}
|
||||
func NewMailRepository(smtp config.SmtpSettings, opts ...Option) *MailRepository {
|
||||
configured := options{now: time.Now}
|
||||
for _, opt := range opts {
|
||||
opt(&configured)
|
||||
}
|
||||
return &MailRepository{
|
||||
transport: newTransport(smtp, configured.tlsConfig),
|
||||
sender: smtp.Sender,
|
||||
now: configured.now,
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSubject = "RenewCMS | Action required"
|
||||
@@ -60,28 +85,23 @@ func (m MailRepository) Render(templateName string, data any) (string, string, e
|
||||
}
|
||||
|
||||
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
|
||||
login := m.smtp.Login
|
||||
sender := m.smtp.Sender
|
||||
password := m.smtp.Password
|
||||
smtpHost := m.smtp.Host
|
||||
smtpPort := m.smtp.Port
|
||||
|
||||
d := gomail.NewDialer(smtpHost, smtpPort, login, password)
|
||||
|
||||
subject, body, err := m.Render(templateName, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := gomail.NewMessage()
|
||||
msg.SetHeaders(map[string][]string{
|
||||
"From": {"RenewCMS <" + sender + ">"},
|
||||
"To": {receiverAddress},
|
||||
"Subject": {subject},
|
||||
})
|
||||
msg.SetBody("text/html", body)
|
||||
msg, err := newMessage(m.sender, receiverAddress, subject, body, m.now())
|
||||
if err != nil {
|
||||
log.Println("error when building email:", err)
|
||||
return err
|
||||
}
|
||||
payload, err := msg.bytes()
|
||||
if err != nil {
|
||||
log.Println("error when encoding email:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.DialAndSend(msg); err != nil {
|
||||
if err := m.transport.deliver(msg.from.Address, msg.to.Address, payload); err != nil {
|
||||
log.Println("error when sending email:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
senderDisplayName = "RenewCMS"
|
||||
messageIDBytes = 16
|
||||
lineBreak = "\r\n"
|
||||
)
|
||||
|
||||
type message struct {
|
||||
from mail.Address
|
||||
to mail.Address
|
||||
subject string
|
||||
body string
|
||||
sentAt time.Time
|
||||
}
|
||||
|
||||
func newMessage(sender string, receiver string, subject string, body string, sentAt time.Time) (message, error) {
|
||||
from, err := mail.ParseAddress(sender)
|
||||
if err != nil {
|
||||
return message{}, fmt.Errorf("invalid sender address: %w", err)
|
||||
}
|
||||
to, err := mail.ParseAddress(receiver)
|
||||
if err != nil {
|
||||
return message{}, fmt.Errorf("invalid receiver address: %w", err)
|
||||
}
|
||||
if strings.ContainsAny(subject, "\r\n") {
|
||||
return message{}, errors.New("subject contains a line break")
|
||||
}
|
||||
return message{
|
||||
from: mail.Address{Name: senderDisplayName, Address: from.Address},
|
||||
to: *to,
|
||||
subject: subject,
|
||||
body: body,
|
||||
sentAt: sentAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m message) messageID() (string, error) {
|
||||
_, domain, _ := strings.Cut(m.from.Address, "@")
|
||||
random := make([]byte, messageIDBytes)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "<" + hex.EncodeToString(random) + "@" + domain + ">", nil
|
||||
}
|
||||
|
||||
func (m message) bytes() ([]byte, error) {
|
||||
id, err := m.messageID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
headers := []struct{ name, value string }{
|
||||
{"From", m.from.String()},
|
||||
{"To", m.to.String()},
|
||||
{"Subject", mime.QEncoding.Encode("utf-8", m.subject)},
|
||||
{"Date", m.sentAt.Format(time.RFC1123Z)},
|
||||
{"Message-ID", id},
|
||||
{"MIME-Version", "1.0"},
|
||||
{"Content-Type", "text/html; charset=UTF-8"},
|
||||
{"Content-Transfer-Encoding", "quoted-printable"},
|
||||
}
|
||||
for _, h := range headers {
|
||||
out.WriteString(h.name + ": " + h.value + lineBreak)
|
||||
}
|
||||
out.WriteString(lineBreak)
|
||||
|
||||
encoder := quotedprintable.NewWriter(&out)
|
||||
if _, err := encoder.Write([]byte(m.body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
implicitTLSPort = 465
|
||||
dialTimeout = 10 * time.Second
|
||||
sessionTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type transport struct {
|
||||
settings config.SmtpSettings
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func newTransport(settings config.SmtpSettings, tlsConfig *tls.Config) transport {
|
||||
if tlsConfig == nil {
|
||||
tlsConfig = &tls.Config{}
|
||||
}
|
||||
tlsConfig = tlsConfig.Clone()
|
||||
if tlsConfig.ServerName == "" {
|
||||
tlsConfig.ServerName = settings.Host
|
||||
}
|
||||
if tlsConfig.MinVersion == 0 {
|
||||
tlsConfig.MinVersion = tls.VersionTLS12
|
||||
}
|
||||
return transport{settings: settings, tlsConfig: tlsConfig}
|
||||
}
|
||||
|
||||
func (t transport) deliver(sender string, receiver string, payload []byte) error {
|
||||
client, err := t.connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
if err := t.negotiate(client); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Mail(sender); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(receiver); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeData(client, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (t transport) connect() (*smtp.Client, error) {
|
||||
address := net.JoinHostPort(t.settings.Host, strconv.Itoa(t.settings.Port))
|
||||
dialer := &net.Dialer{Timeout: dialTimeout}
|
||||
|
||||
connection, err := dialer.Dial("tcp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := connection.SetDeadline(time.Now().Add(sessionTimeout)); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if t.settings.Port == implicitTLSPort {
|
||||
connection = tls.Client(connection, t.tlsConfig)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(connection, t.settings.Host)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (t transport) negotiate(client *smtp.Client) error {
|
||||
if t.settings.Port != implicitTLSPort {
|
||||
if supported, _ := client.Extension("STARTTLS"); supported {
|
||||
if err := client.StartTLS(t.tlsConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return t.authenticate(client)
|
||||
}
|
||||
|
||||
func (t transport) authenticate(client *smtp.Client) error {
|
||||
if t.settings.Login == "" {
|
||||
return nil
|
||||
}
|
||||
advertised, mechanisms := client.Extension("AUTH")
|
||||
if !advertised {
|
||||
return errors.New("smtp server does not support authentication")
|
||||
}
|
||||
return client.Auth(selectAuth(mechanisms, t.settings))
|
||||
}
|
||||
|
||||
func writeData(client *smtp.Client, payload []byte) error {
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
return writer.Close()
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,7 +52,7 @@ type ServeVariantUseCase struct {
|
||||
maxSourceBytes int64
|
||||
maxSourcePixels int64
|
||||
renderSlots chan struct{}
|
||||
flights singleflight.Group
|
||||
flights flightGroup
|
||||
tracker image.VariantUsageTracker
|
||||
}
|
||||
|
||||
@@ -214,11 +212,11 @@ func (u *ServeVariantUseCase) lookupCached(variantName string, storedName string
|
||||
func (u *ServeVariantUseCase) generated(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
|
||||
key := strings.Join([]string{variant.Name, fingerprint, storedName}, flightKeySep)
|
||||
for {
|
||||
file, err, _ := u.flights.Do(key, func() (any, error) {
|
||||
file, err := u.flights.do(key, func() (image.VariantFile, error) {
|
||||
return u.generate(ctx, variant, storedName, fingerprint, previous)
|
||||
})
|
||||
if err == nil {
|
||||
return file.(image.VariantFile), nil
|
||||
return file, nil
|
||||
}
|
||||
if ctx.Err() != nil || !errors.Is(err, context.Canceled) {
|
||||
return image.VariantFile{}, err
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var errFlightAbandoned = errors.New("shared call ended without a result")
|
||||
|
||||
type flightCall struct {
|
||||
done chan struct{}
|
||||
value image.VariantFile
|
||||
err error
|
||||
}
|
||||
|
||||
type flightGroup struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]*flightCall
|
||||
}
|
||||
|
||||
func (g *flightGroup) do(key string, work func() (image.VariantFile, error)) (image.VariantFile, error) {
|
||||
g.mu.Lock()
|
||||
if existing, running := g.calls[key]; running {
|
||||
g.mu.Unlock()
|
||||
<-existing.done
|
||||
return existing.value, existing.err
|
||||
}
|
||||
if g.calls == nil {
|
||||
g.calls = map[string]*flightCall{}
|
||||
}
|
||||
call := &flightCall{done: make(chan struct{}), err: errFlightAbandoned}
|
||||
g.calls[key] = call
|
||||
g.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
g.mu.Lock()
|
||||
delete(g.calls, key)
|
||||
g.mu.Unlock()
|
||||
close(call.done)
|
||||
}()
|
||||
|
||||
call.value, call.err = work()
|
||||
return call.value, call.err
|
||||
}
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -117,7 +117,7 @@ func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Rep
|
||||
handler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(invites, hasher),
|
||||
useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder),
|
||||
jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
authtoken.New([]byte("test-secret")),
|
||||
true,
|
||||
)
|
||||
router := chi.NewRouter()
|
||||
|
||||
@@ -17,9 +17,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v3/transform"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,7 +38,7 @@ type acceptInviteFixture struct {
|
||||
mail *mockMailRepo
|
||||
hasher *stubHasher
|
||||
router *chi.Mux
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
}
|
||||
|
||||
func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture {
|
||||
@@ -50,7 +49,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
||||
invites: newInviteFakeRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
hasher: &stubHasher{},
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
f.invites.users = f.users
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
@@ -85,15 +84,15 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
||||
r.Post("/invite/accept", acceptHandler.AcceptInvite)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
})
|
||||
f.router.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Get("/", listHandler.ListUsers)
|
||||
@@ -258,12 +257,10 @@ func TestAcceptInvitationHttp(t *testing.T) {
|
||||
if lifetime := time.Until(cookie.Expires); lifetime < 23*time.Hour || lifetime > 24*time.Hour+time.Minute {
|
||||
t.Errorf("expected a 24 hour cookie, got %v", lifetime)
|
||||
}
|
||||
decoded, err := f.token.Decode(cookie.Value)
|
||||
claims, err := f.token.Decode(cookie.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("cookie does not decode: %v", err)
|
||||
}
|
||||
claims := map[string]interface{}{}
|
||||
_ = transform.AsMap(decoded, claims)
|
||||
if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) {
|
||||
t.Errorf("unexpected claims %v", claims)
|
||||
}
|
||||
@@ -414,12 +411,10 @@ func TestAcceptInvitationHttp(t *testing.T) {
|
||||
}
|
||||
created, _ := f.userNamed("lea")
|
||||
cookie := jwtCookieOf(rec)
|
||||
decoded, err := f.token.Decode(cookie.Value)
|
||||
claims, err := f.token.Decode(cookie.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("cookie does not decode: %v", err)
|
||||
}
|
||||
claims := map[string]interface{}{}
|
||||
_ = transform.AsMap(decoded, claims)
|
||||
if claims["user_id"] != float64(created.ID) || created.ID == 7 {
|
||||
t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"])
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newAccountVerificationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newAccountVerificationRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
verificationHandler := auth.NewVerificationHandler(
|
||||
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
|
||||
@@ -29,8 +29,8 @@ func newAccountVerificationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
})
|
||||
})
|
||||
@@ -51,11 +51,11 @@ func newAccountVerificationTestUserRepo() *mockUserRepo {
|
||||
return repo
|
||||
}
|
||||
|
||||
func sendVerifyRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[string]any, code string) *httptest.ResponseRecorder {
|
||||
func sendVerifyRequest(router *chi.Mux, token *authtoken.Auth, claims map[string]any, code string) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]string{"code": code})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
|
||||
if claims != nil {
|
||||
_, tokenString, _ := token.Encode(claims)
|
||||
tokenString, _ := token.Encode(claims)
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -65,7 +65,7 @@ func sendVerifyRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[strin
|
||||
|
||||
func TestAccountVerificationHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("A logged-in user verifies their account with the correct emailed code", func(t *testing.T) {
|
||||
repo := newAccountVerificationTestUserRepo()
|
||||
@@ -200,7 +200,7 @@ func TestAccountVerificationHttp(t *testing.T) {
|
||||
router := newAccountVerificationRouter(repo, token)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader([]byte("not-json")))
|
||||
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
|
||||
tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -224,7 +224,7 @@ func TestAccountVerificationHttp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A request with no jwt cookie is answered 401 by jwtauth itself", func(t *testing.T) {
|
||||
t.Run("A request with no jwt cookie is answered 401 by the authenticator itself", func(t *testing.T) {
|
||||
repo := newAccountVerificationTestUserRepo()
|
||||
router := newAccountVerificationRouter(repo, token)
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -44,7 +44,7 @@ type activityHTTPEnv struct {
|
||||
keys *mockAPIKeyRepo
|
||||
articles *mockArticleRepo
|
||||
mail *mockMailRepo
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
generator *hashing.SHA256KeyGenerator
|
||||
router http.Handler
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
keys: newMockAPIKeyRepo(),
|
||||
articles: newMockArticleRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
generator: hashing.NewSHA256KeyGenerator(),
|
||||
}
|
||||
e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner}
|
||||
@@ -99,7 +99,7 @@ type activityRequest func(*http.Request)
|
||||
|
||||
func asUser(e *activityHTTPEnv, id uint32) activityRequest {
|
||||
return func(r *http.Request) {
|
||||
_, tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0})
|
||||
tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0})
|
||||
r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
}
|
||||
@@ -1037,7 +1037,7 @@ func TestActivityLogBackendAuditContext(t *testing.T) {
|
||||
t.Helper()
|
||||
probe := &auditOriginProbe{}
|
||||
r := chi.NewRouter()
|
||||
r.Use(jwtauth.Verifier(e.token))
|
||||
r.Use(authtoken.Verifier(e.token))
|
||||
r.Use(middleware.APIKeyAuth(useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock), 100, time.Minute))
|
||||
r.Use(middleware.AuditContext(useCases.NewGetUserUseCase(e.users), trusted))
|
||||
r.Get("/probe", func(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -51,7 +51,7 @@ type apiKeyEnv struct {
|
||||
mail *mockMailRepo
|
||||
feed *fakeReleaseFeed
|
||||
updates *fakeUpdateStore
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
generator *hashing.SHA256KeyGenerator
|
||||
now time.Time
|
||||
settings config.Settings
|
||||
@@ -76,7 +76,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
images: newMockImageRepo(),
|
||||
invites: newInviteFakeRepo(),
|
||||
updates: &fakeUpdateStore{},
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
generator: hashing.NewSHA256KeyGenerator(),
|
||||
now: time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC),
|
||||
settings: config.Settings{AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: 1000, Window: time.Minute}},
|
||||
@@ -172,7 +172,7 @@ type apiKeyRequest struct {
|
||||
|
||||
func (e *apiKeyEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type fakeBodyRenderer struct {
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]`
|
||||
)
|
||||
|
||||
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
@@ -54,11 +54,11 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||
@@ -145,7 +145,7 @@ func assertSameJSON(t *testing.T, actual, expected string) {
|
||||
|
||||
func TestArticleBodyFormatHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux {
|
||||
return newBodyFormatRouter(repo, users, renderer, token)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo())
|
||||
renderer := markdown.NewConverter()
|
||||
articleHandler := article.NewHandler(
|
||||
@@ -34,9 +34,9 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
@@ -48,7 +48,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
||||
return r
|
||||
}
|
||||
|
||||
func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *jwtauth.JWTAuth, method, target, payload string) *httptest.ResponseRecorder {
|
||||
func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *authtoken.Auth, method, target, payload string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := newEditArticleBodyRequest(method, target, payload)
|
||||
setEditArticleBodyJwtCookie(t, req, token)
|
||||
@@ -59,7 +59,7 @@ func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *jwtauth.JWTAuth, m
|
||||
|
||||
func TestArticleBodyUnlimitedHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
const title = "Choosing tiles for a small bathroom"
|
||||
|
||||
createCases := []struct {
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
|
||||
userRepo := newVerifiedUserRepo()
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
@@ -35,10 +35,10 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
@@ -59,9 +59,9 @@ func newPublishingTestArticleRepo() *mockArticleRepo {
|
||||
return repo
|
||||
}
|
||||
|
||||
func setPublishingJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
|
||||
func setPublishingJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth) {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
|
||||
tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func setPublishingJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTA
|
||||
|
||||
func TestArticlePublishingHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("Publishing an offline article makes it online", func(t *testing.T) {
|
||||
repo := newPublishingTestArticleRepo()
|
||||
|
||||
@@ -17,11 +17,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
|
||||
userRepo := newVerifiedUserRepo()
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
@@ -37,8 +37,8 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
@@ -48,7 +48,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
|
||||
return r
|
||||
}
|
||||
|
||||
func postArticleRequest(t *testing.T, token *jwtauth.JWTAuth, title string, body string) *http.Request {
|
||||
func postArticleRequest(t *testing.T, token *authtoken.Auth, title string, body string) *http.Request {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]string{"title": title, "body": body})
|
||||
if err != nil {
|
||||
@@ -122,7 +122,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
|
||||
|
||||
func TestArticleTitleRunesOverHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
cases := []struct {
|
||||
length int
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
)
|
||||
|
||||
func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
|
||||
userRepo := newVerifiedUserRepo()
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
@@ -31,7 +31,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
|
||||
|
||||
func TestArticlesListHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("GET /v1/article/ items carry no images field now that images belong to the library", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
)
|
||||
@@ -71,7 +71,7 @@ func itemIds(envelope paginationEnvelope) []int {
|
||||
|
||||
func TestArticlesPaginationApiHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
get := func(t *testing.T, repo *mockArticleRepo, query string, verified bool) (*httptest.ResponseRecorder, paginationEnvelope) {
|
||||
t.Helper()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type authTokenProbe struct {
|
||||
reached bool
|
||||
claims authtoken.Claims
|
||||
err error
|
||||
}
|
||||
|
||||
func authTokenRouter(auth *authtoken.Auth, probe *authTokenProbe, guarded bool) http.Handler {
|
||||
final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
probe.reached = true
|
||||
probe.claims, probe.err = authtoken.FromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
if guarded {
|
||||
return authtoken.Verifier(auth)(authtoken.Authenticator(final))
|
||||
}
|
||||
return authtoken.Verifier(auth)(final)
|
||||
}
|
||||
|
||||
func serveAuthToken(handler http.Handler, configure func(*http.Request)) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
configure(req)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAuthTokenVerifierAndAuthenticator(t *testing.T) {
|
||||
auth := authtoken.New([]byte("test-secret"))
|
||||
valid, _ := auth.Encode(map[string]any{"user_id": uint32(5)})
|
||||
other, _ := auth.Encode(map[string]any{"user_id": uint32(9)})
|
||||
|
||||
t.Run("A valid jwt cookie reaches the handler with its claims", func(t *testing.T) {
|
||||
probe := &authTokenProbe{}
|
||||
|
||||
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
|
||||
r.AddCookie(&http.Cookie{Name: "jwt", Value: valid})
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusOK || probe.claims["user_id"] != float64(5) {
|
||||
t.Errorf("expected 200 with user 5, got %d and %v", rec.Code, probe.claims)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A bearer header in any letter case is accepted", func(t *testing.T) {
|
||||
for _, scheme := range []string{"Bearer ", "BEARER ", "bearer "} {
|
||||
probe := &authTokenProbe{}
|
||||
|
||||
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
|
||||
r.Header.Set("Authorization", scheme+valid)
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("scheme %q: expected 200, got %d", scheme, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The bearer header wins over the cookie", func(t *testing.T) {
|
||||
probe := &authTokenProbe{}
|
||||
|
||||
serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
|
||||
r.Header.Set("Authorization", "Bearer "+valid)
|
||||
r.AddCookie(&http.Cookie{Name: "jwt", Value: other})
|
||||
})
|
||||
|
||||
if probe.claims["user_id"] != float64(5) {
|
||||
t.Errorf("expected the header token, got %v", probe.claims)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A malformed Authorization header falls back to the cookie", func(t *testing.T) {
|
||||
for _, header := range []string{"Bearer", "Bearer ", "Basic abc", "Token " + valid} {
|
||||
probe := &authTokenProbe{}
|
||||
|
||||
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
|
||||
r.Header.Set("Authorization", header)
|
||||
r.AddCookie(&http.Cookie{Name: "jwt", Value: valid})
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("header %q: expected the cookie to be used, got %d", header, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The authenticator answers 401 with the standard message when no token is sent", func(t *testing.T) {
|
||||
probe := &authTokenProbe{}
|
||||
|
||||
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(*http.Request) {})
|
||||
|
||||
if rec.Code != http.StatusUnauthorized || probe.reached {
|
||||
t.Fatalf("expected a blocked 401, got %d reached=%v", rec.Code, probe.reached)
|
||||
}
|
||||
if got := rec.Body.String(); got != apperr.UnauthorizedErrorMessage+"\n" {
|
||||
t.Errorf("unexpected body %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The authenticator rejects a forged token", func(t *testing.T) {
|
||||
probe := &authTokenProbe{}
|
||||
forged, _ := authtoken.New([]byte("another-secret")).Encode(map[string]any{"user_id": 1})
|
||||
|
||||
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
|
||||
r.AddCookie(&http.Cookie{Name: "jwt", Value: forged})
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusUnauthorized || probe.reached {
|
||||
t.Errorf("expected a blocked 401, got %d reached=%v", rec.Code, probe.reached)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Without the authenticator the handler sees the verification error", func(t *testing.T) {
|
||||
probe := &authTokenProbe{}
|
||||
|
||||
serveAuthToken(authTokenRouter(auth, probe, false), func(r *http.Request) {
|
||||
r.AddCookie(&http.Cookie{Name: "jwt", Value: "garbage"})
|
||||
})
|
||||
|
||||
if !probe.reached || probe.err == nil || probe.claims != nil {
|
||||
t.Errorf("expected an error and no claims, got %v / %v", probe.claims, probe.err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A context that never passed the verifier reports no token", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
if _, err := authtoken.FromContext(req.Context()); err != authtoken.ErrNoToken {
|
||||
t.Errorf("expected ErrNoToken, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A context carrying nil claims and no error still reports no token", func(t *testing.T) {
|
||||
ctx := authtoken.NewContext(httptest.NewRequest(http.MethodGet, "/", nil).Context(), nil, nil)
|
||||
|
||||
if _, err := authtoken.FromContext(ctx); err != authtoken.ErrNoToken {
|
||||
t.Errorf("expected ErrNoToken, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A context injected with claims is authenticated", func(t *testing.T) {
|
||||
probe := &authTokenProbe{}
|
||||
handler := authtoken.Authenticator(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { probe.reached = true }))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(authtoken.NewContext(req.Context(), authtoken.Claims{"user_id": float64(1)}, nil))
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if !probe.reached {
|
||||
t.Error("expected the handler to be reached")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var authTokenEpoch = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func authTokenAt(now time.Time) *authtoken.Auth {
|
||||
return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return now }))
|
||||
}
|
||||
|
||||
func signedAuthTokenSegments(secret string, header string, payload string) string {
|
||||
encode := base64.RawURLEncoding.EncodeToString
|
||||
signingInput := encode([]byte(header)) + "." + encode([]byte(payload))
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signingInput))
|
||||
return signingInput + "." + encode(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func TestAuthTokenEncodeDecode(t *testing.T) {
|
||||
t.Run("A token round-trips its claims", func(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
|
||||
encoded, err := auth.Encode(map[string]any{"user_id": uint32(7), "token_version": uint32(3)})
|
||||
if err != nil {
|
||||
t.Fatalf("encode failed: %v", err)
|
||||
}
|
||||
claims, err := auth.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
|
||||
if claims["user_id"] != float64(7) || claims["token_version"] != float64(3) {
|
||||
t.Errorf("unexpected claims %v", claims)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Encoding stamps iat and an exp one session lifetime ahead", func(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
|
||||
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
claims, _ := auth.Decode(encoded)
|
||||
|
||||
if claims["iat"] != float64(authTokenEpoch.Unix()) {
|
||||
t.Errorf("expected iat %d, got %v", authTokenEpoch.Unix(), claims["iat"])
|
||||
}
|
||||
if claims["exp"] != float64(authTokenEpoch.Add(authtoken.SessionLifetime).Unix()) {
|
||||
t.Errorf("unexpected exp %v", claims["exp"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Encoding keeps an explicit exp", func(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
explicit := authTokenEpoch.Add(time.Minute).Unix()
|
||||
|
||||
encoded, _ := auth.Encode(map[string]any{"exp": explicit})
|
||||
claims, _ := auth.Decode(encoded)
|
||||
|
||||
if claims["exp"] != float64(explicit) {
|
||||
t.Errorf("expected exp %d, got %v", explicit, claims["exp"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Encoding does not mutate the caller's claims", func(t *testing.T) {
|
||||
input := map[string]any{"user_id": 1}
|
||||
|
||||
_, _ = authTokenAt(authTokenEpoch).Encode(input)
|
||||
|
||||
if len(input) != 1 {
|
||||
t.Errorf("input was mutated: %v", input)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Encoding nil claims still yields a decodable token", func(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
|
||||
encoded, err := auth.Encode(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("encode failed: %v", err)
|
||||
}
|
||||
if _, err := auth.Decode(encoded); err != nil {
|
||||
t.Errorf("decode failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Encoding reports claims that cannot be serialized", func(t *testing.T) {
|
||||
if _, err := authTokenAt(authTokenEpoch).Encode(map[string]any{"bad": make(chan int)}); err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthTokenDecodeRejections(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
valid, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
segments := strings.Split(valid, ".")
|
||||
header := `{"alg":"HS256","typ":"JWT"}`
|
||||
|
||||
cases := map[string]struct {
|
||||
token string
|
||||
want error
|
||||
}{
|
||||
"empty": {"", authtoken.ErrNoToken},
|
||||
"one segment": {"abc", authtoken.ErrInvalid},
|
||||
"four segments": {valid + ".x", authtoken.ErrInvalid},
|
||||
"not base64 header": {"!!!." + segments[1] + "." + segments[2], authtoken.ErrInvalid},
|
||||
"signed with another secret": {signedAuthTokenSegments("other", header, `{"user_id":1}`), authtoken.ErrInvalid},
|
||||
"alg none": {signedAuthTokenSegments("test-secret", `{"alg":"none"}`, `{"user_id":1}`), authtoken.ErrInvalid},
|
||||
"alg HS512": {signedAuthTokenSegments("test-secret", `{"alg":"HS512"}`, `{"user_id":1}`), authtoken.ErrInvalid},
|
||||
"alg missing": {signedAuthTokenSegments("test-secret", `{}`, `{"user_id":1}`), authtoken.ErrInvalid},
|
||||
"tampered payload": {segments[0] + "." + base64.RawURLEncoding.EncodeToString([]byte(`{"user_id":2}`)) + "." + segments[2], authtoken.ErrInvalid},
|
||||
"truncated signature": {segments[0] + "." + segments[1] + "." + segments[2][:10], authtoken.ErrInvalid},
|
||||
"empty signature": {segments[0] + "." + segments[1] + ".", authtoken.ErrInvalid},
|
||||
"payload not an object": {signedAuthTokenSegments("test-secret", header, `[1]`), authtoken.ErrInvalid},
|
||||
"payload null": {signedAuthTokenSegments("test-secret", header, `null`), authtoken.ErrInvalid},
|
||||
"exp not a number": {signedAuthTokenSegments("test-secret", header, `{"exp":"soon"}`), authtoken.ErrInvalid},
|
||||
"nbf not a number": {signedAuthTokenSegments("test-secret", header, `{"nbf":"soon"}`), authtoken.ErrInvalid},
|
||||
"expired": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`), authtoken.ErrExpired},
|
||||
"expiring exactly now": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Unix())+`}`), authtoken.ErrExpired},
|
||||
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
|
||||
"larger than the size budget": {strings.Repeat("a", 5000), authtoken.ErrInvalid},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run("Rejects "+name, func(t *testing.T) {
|
||||
claims, err := auth.Decode(tc.token)
|
||||
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("expected %v, got %v", tc.want, err)
|
||||
}
|
||||
if claims != nil {
|
||||
t.Errorf("expected no claims, got %v", claims)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Accepts a token with no exp so sessions issued before expiry existed keep working", func(t *testing.T) {
|
||||
legacy := signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`)
|
||||
|
||||
claims, err := auth.Decode(legacy)
|
||||
|
||||
if err != nil || claims["user_id"] != float64(4) {
|
||||
t.Errorf("expected legacy token accepted, got %v, %v", claims, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Accepts a token whose nbf has passed", func(t *testing.T) {
|
||||
token := signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
|
||||
|
||||
if _, err := auth.Decode(token); err != nil {
|
||||
t.Errorf("expected acceptance, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A token expires once the clock passes its lifetime", func(t *testing.T) {
|
||||
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
|
||||
justBefore := authTokenAt(authTokenEpoch.Add(authtoken.SessionLifetime - time.Second))
|
||||
justAfter := authTokenAt(authTokenEpoch.Add(authtoken.SessionLifetime))
|
||||
|
||||
if _, err := justBefore.Decode(encoded); err != nil {
|
||||
t.Errorf("expected valid before expiry, got %v", err)
|
||||
}
|
||||
if _, err := justAfter.Decode(encoded); !errors.Is(err, authtoken.ErrExpired) {
|
||||
t.Errorf("expected expired, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func authTokenUnix(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
@@ -34,8 +34,8 @@ func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/register", loginHandler.Register)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
})
|
||||
@@ -56,10 +56,10 @@ func newCancelRegistrationTestUserRepo(isVerified bool) *mockUserRepo {
|
||||
return repo
|
||||
}
|
||||
|
||||
func sendCancelRegistrationRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[string]any) *httptest.ResponseRecorder {
|
||||
func sendCancelRegistrationRequest(router *chi.Mux, token *authtoken.Auth, claims map[string]any) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/v1/auth/register", nil)
|
||||
if claims != nil {
|
||||
_, tokenString, _ := token.Encode(claims)
|
||||
tokenString, _ := token.Encode(claims)
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -83,7 +83,7 @@ func jwtSetCookie(t *testing.T, rec *httptest.ResponseRecorder) (*http.Cookie, b
|
||||
|
||||
func TestCancelPendingRegistrationHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("An unverified user cancels their pending registration", func(t *testing.T) {
|
||||
repo := newCancelRegistrationTestUserRepo(false)
|
||||
|
||||
+6
-6
@@ -7,12 +7,12 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
)
|
||||
|
||||
func requestWithJwtClaims(t *testing.T, token *jwtauth.JWTAuth, claimsToEncode map[string]any) *http.Request {
|
||||
func requestWithJwtClaims(t *testing.T, token *authtoken.Auth, claimsToEncode map[string]any) *http.Request {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(claimsToEncode)
|
||||
tokenString, err := token.Encode(claimsToEncode)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -21,11 +21,11 @@ func requestWithJwtClaims(t *testing.T, token *jwtauth.JWTAuth, claimsToEncode m
|
||||
t.Fatalf("failed to decode jwt: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
return req.WithContext(jwtauth.NewContext(req.Context(), decoded, nil))
|
||||
return req.WithContext(authtoken.NewContext(req.Context(), decoded, nil))
|
||||
}
|
||||
|
||||
func TestClaimsTokenVersion(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("returns the numeric token_version claim", func(t *testing.T) {
|
||||
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(3)})
|
||||
@@ -68,7 +68,7 @@ func TestClaimsTokenVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClaimsUserId(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("returns the numeric user_id claim", func(t *testing.T) {
|
||||
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7)})
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
)
|
||||
|
||||
type containerHarness struct {
|
||||
@@ -28,7 +28,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
|
||||
rig.helper.installed = helperInstalled
|
||||
helper := infraupdate.NewContainerHelperDetector(rig.helper, container)
|
||||
now := func() time.Time { return updateTestCheckedAt }
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
feed := &fakeReleaseFeed{release: update.Release{
|
||||
Tag: "v2.3.0-beta",
|
||||
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/middleware"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type corsExchange struct {
|
||||
rec *httptest.ResponseRecorder
|
||||
reached bool
|
||||
}
|
||||
|
||||
func corsRequest(origins []string, method string, headers map[string]string) corsExchange {
|
||||
exchange := corsExchange{}
|
||||
handler := middleware.CORS(origins)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
exchange.reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(method, "/v1/article", nil)
|
||||
for name, value := range headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
exchange.rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(exchange.rec, req)
|
||||
return exchange
|
||||
}
|
||||
|
||||
func corsPreflight(origins []string, origin string, method string, requestHeaders string) corsExchange {
|
||||
headers := map[string]string{"Origin": origin, "Access-Control-Request-Method": method}
|
||||
if requestHeaders != "" {
|
||||
headers["Access-Control-Request-Headers"] = requestHeaders
|
||||
}
|
||||
return corsRequest(origins, http.MethodOptions, headers)
|
||||
}
|
||||
|
||||
func TestCORSActualRequests(t *testing.T) {
|
||||
origins := []string{"https://app.example.com", "https://*.preview.example.com"}
|
||||
|
||||
t.Run("An allowed origin is echoed and the exposed headers are listed", func(t *testing.T) {
|
||||
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://app.example.com" {
|
||||
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if got.rec.Header().Get("Access-Control-Expose-Headers") != "Link, X-Total-Count" {
|
||||
t.Errorf("unexpected expose-headers %q", got.rec.Header().Get("Access-Control-Expose-Headers"))
|
||||
}
|
||||
if !got.reached {
|
||||
t.Error("the request must reach the handler")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Credentials are never allowed", func(t *testing.T) {
|
||||
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Credentials") != "" {
|
||||
t.Error("Access-Control-Allow-Credentials must not be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The match is case-insensitive", func(t *testing.T) {
|
||||
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "HTTPS://APP.EXAMPLE.COM"})
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") == "" {
|
||||
t.Error("expected the origin to be allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A wildcard subdomain pattern matches", func(t *testing.T) {
|
||||
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://pr-12.preview.example.com"})
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://pr-12.preview.example.com" {
|
||||
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A wildcard pattern does not match a look-alike", func(t *testing.T) {
|
||||
for _, origin := range []string{"https://preview.example.com", "https://x.preview.example.com.evil.net", "http://x.preview.example.com"} {
|
||||
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": origin})
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Errorf("origin %q must not be allowed", origin)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown origin gets no CORS headers but still reaches the handler", func(t *testing.T) {
|
||||
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://evil.example.net"})
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || !got.reached {
|
||||
t.Errorf("unexpected exchange %v reached=%v", got.rec.Header(), got.reached)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A request with no Origin is untouched", func(t *testing.T) {
|
||||
got := corsRequest(origins, http.MethodGet, nil)
|
||||
|
||||
if got.rec.Header().Get("Vary") != "" || got.rec.Header().Get("Access-Control-Allow-Origin") != "" || !got.reached {
|
||||
t.Errorf("unexpected headers %v", got.rec.Header())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Responses vary on Origin so shared caches stay correct", func(t *testing.T) {
|
||||
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://evil.example.net"})
|
||||
|
||||
if got.rec.Header().Get("Vary") != "Origin" {
|
||||
t.Errorf("unexpected Vary %q", got.rec.Header().Get("Vary"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A lone star allows any origin", func(t *testing.T) {
|
||||
got := corsRequest([]string{"*"}, http.MethodGet, map[string]string{"Origin": "https://anything.example.net"})
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://anything.example.net" {
|
||||
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Blank entries never match the empty origin", func(t *testing.T) {
|
||||
got := corsRequest([]string{"", " "}, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Error("blank entries must be ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCORSPreflight(t *testing.T) {
|
||||
origins := []string{"https://app.example.com"}
|
||||
|
||||
t.Run("An allowed preflight is answered 204 without reaching the handler", func(t *testing.T) {
|
||||
got := corsPreflight(origins, "https://app.example.com", http.MethodPatch, "Content-Type, Authorization")
|
||||
|
||||
header := got.rec.Header()
|
||||
if got.rec.Code != http.StatusNoContent || got.reached {
|
||||
t.Fatalf("expected 204 and no handler, got %d reached=%v", got.rec.Code, got.reached)
|
||||
}
|
||||
if header.Get("Access-Control-Allow-Origin") != "https://app.example.com" {
|
||||
t.Errorf("unexpected allow-origin %q", header.Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if header.Get("Access-Control-Allow-Methods") != "PATCH" {
|
||||
t.Errorf("unexpected allow-methods %q", header.Get("Access-Control-Allow-Methods"))
|
||||
}
|
||||
if header.Get("Access-Control-Max-Age") != "300" {
|
||||
t.Errorf("unexpected max-age %q", header.Get("Access-Control-Max-Age"))
|
||||
}
|
||||
for _, allowed := range []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"} {
|
||||
if !strings.Contains(header.Get("Access-Control-Allow-Headers"), allowed) {
|
||||
t.Errorf("allow-headers misses %s: %q", allowed, header.Get("Access-Control-Allow-Headers"))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Every supported method is allowed", func(t *testing.T) {
|
||||
for _, method := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} {
|
||||
got := corsPreflight(origins, "https://app.example.com", method, "")
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Methods") != method {
|
||||
t.Errorf("method %s not allowed", method)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The requested method is matched case-insensitively", func(t *testing.T) {
|
||||
got := corsPreflight(origins, "https://app.example.com", "patch", "")
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Methods") != "PATCH" {
|
||||
t.Errorf("unexpected allow-methods %q", got.rec.Header().Get("Access-Control-Allow-Methods"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A method outside the list is refused", func(t *testing.T) {
|
||||
got := corsPreflight(origins, "https://app.example.com", "TRACE", "")
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || got.rec.Header().Get("Access-Control-Allow-Methods") != "" {
|
||||
t.Errorf("unexpected headers %v", got.rec.Header())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A header outside the list is refused", func(t *testing.T) {
|
||||
got := corsPreflight(origins, "https://app.example.com", "GET", "Content-Type, X-Evil")
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Errorf("unexpected headers %v", got.rec.Header())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Requested headers are matched case-insensitively and tolerate spacing", func(t *testing.T) {
|
||||
got := corsPreflight(origins, "https://app.example.com", "GET", "content-type ,AUTHORIZATION,")
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") == "" {
|
||||
t.Error("expected the preflight to be allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown origin is refused", func(t *testing.T) {
|
||||
got := corsPreflight(origins, "https://evil.example.net", "GET", "")
|
||||
|
||||
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || got.reached {
|
||||
t.Errorf("unexpected exchange %v reached=%v", got.rec.Header(), got.reached)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The preflight response varies on every request header it depends on", func(t *testing.T) {
|
||||
got := corsPreflight(origins, "https://app.example.com", "GET", "")
|
||||
|
||||
vary := strings.Join(got.rec.Header().Values("Vary"), ",")
|
||||
for _, field := range []string{"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"} {
|
||||
if !strings.Contains(vary, field) {
|
||||
t.Errorf("Vary misses %s: %q", field, vary)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An OPTIONS request without a requested method is treated as a normal request", func(t *testing.T) {
|
||||
got := corsRequest(origins, http.MethodOptions, map[string]string{"Origin": "https://app.example.com"})
|
||||
|
||||
if !got.reached {
|
||||
t.Error("expected the handler to be reached")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const supportRecipeKey = "recipe"
|
||||
@@ -377,7 +377,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
|
||||
t.Run("The built-in routes are read-only and restricted", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
stale := func(req *http.Request) {
|
||||
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
|
||||
tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
apiKey := env.issueKey(ctAdminId)
|
||||
@@ -414,7 +414,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
users := newMockUserRepo()
|
||||
users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
now := func() time.Time { return time.Now() }
|
||||
generator := hashing.NewSHA256KeyGenerator()
|
||||
container := &api.UseCases{
|
||||
@@ -428,7 +428,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
|
||||
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(ctAdminId), "token_version": 0})
|
||||
tokenString, _ := token.Encode(map[string]any{"user_id": uint32(ctAdminId), "token_version": 0})
|
||||
for _, path := range []string{"/v1/builtin-type", "/v1/builtin-type/site"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
@@ -576,7 +576,7 @@ func TestUserLookupHTTP(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
apiKey := env.issueKey(ctAdminId)
|
||||
stale := func(req *http.Request) {
|
||||
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
|
||||
tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
cases := []struct {
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -238,7 +238,7 @@ type contentTypeEnv struct {
|
||||
users *mockUserRepo
|
||||
keys *mockAPIKeyRepo
|
||||
generator *hashing.SHA256KeyGenerator
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
clock time.Time
|
||||
router http.Handler
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
|
||||
users: newMockUserRepo(),
|
||||
keys: newMockAPIKeyRepo(),
|
||||
generator: hashing.NewSHA256KeyGenerator(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
|
||||
}
|
||||
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
@@ -313,7 +313,7 @@ func (e *contentTypeEnv) asUser(t *testing.T, userId uint32) func(*http.Request)
|
||||
if userId == 0 {
|
||||
return
|
||||
}
|
||||
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": 0})
|
||||
tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": 0})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDotenv(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
input string
|
||||
want map[string]string
|
||||
}{
|
||||
"plain assignment": {"A=1", map[string]string{"A": "1"}},
|
||||
"spaces around the equals sign": {"A = 1", map[string]string{"A": "1"}},
|
||||
"export prefix": {"export A=1", map[string]string{"A": "1"}},
|
||||
"empty value": {"A=", map[string]string{"A": ""}},
|
||||
"blank and comment lines": {"\n# note\n \nA=1\n # indented\n", map[string]string{"A": "1"}},
|
||||
"inline comment on an unquoted value": {"A=value # trailing", map[string]string{"A": "value"}},
|
||||
"hash without a leading space": {"A=pass#word", map[string]string{"A": "pass#word"}},
|
||||
"equals sign inside the value": {"A=b=c", map[string]string{"A": "b=c"}},
|
||||
"URL value": {"A=http://localhost:5173/x?y=1", map[string]string{"A": "http://localhost:5173/x?y=1"}},
|
||||
"double quoted keeps spaces and hash": {`A=" a # b "`, map[string]string{"A": " a # b "}},
|
||||
"double quoted escapes": {`A="l1\nl2\t\"q\" \\ \x"`, map[string]string{"A": "l1\nl2\t\"q\" \\ \\x"}},
|
||||
"double quoted then comment": {`A="v" # note`, map[string]string{"A": "v"}},
|
||||
"single quoted is literal": {`A='a\nb # c'`, map[string]string{"A": `a\nb # c`}},
|
||||
"dollar signs are not expanded": {"A=pa$$word${B}", map[string]string{"A": "pa$$word${B}"}},
|
||||
"dotted names": {"a.b_c1=2", map[string]string{"a.b_c1": "2"}},
|
||||
"windows line endings": {"A=1\r\nB=2\r\n", map[string]string{"A": "1", "B": "2"}},
|
||||
"byte order mark": {"\xef\xbb\xbfA=1", map[string]string{"A": "1"}},
|
||||
"last duplicate wins": {"A=1\nA=2", map[string]string{"A": "2"}},
|
||||
"no trailing newline": {"A=1\nB=2", map[string]string{"A": "1", "B": "2"}},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run("Parses "+name, func(t *testing.T) {
|
||||
got, err := config.ParseDotenv(strings.NewReader(tc.input))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("expected %v, got %v", tc.want, got)
|
||||
}
|
||||
for key, value := range tc.want {
|
||||
if got[key] != value {
|
||||
t.Errorf("%s: expected %q, got %q", key, value, got[key])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rejected := map[string]string{
|
||||
"a line with no equals sign": "JUST_A_WORD",
|
||||
"an empty name": "=value",
|
||||
"a name with a space": "MY VAR=1",
|
||||
"a name with a dash": "MY-VAR=1",
|
||||
"an unterminated double quote": `A="open`,
|
||||
"a trailing backslash": `A="open\`,
|
||||
"an unterminated single quote": `A='open`,
|
||||
}
|
||||
for name, input := range rejected {
|
||||
t.Run("Rejects "+name+" and names the line", func(t *testing.T) {
|
||||
_, err := config.ParseDotenv(strings.NewReader("OK=1\n" + input))
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "line 2") {
|
||||
t.Errorf("expected a line 2 error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Rejects a line longer than the budget", func(t *testing.T) {
|
||||
_, err := config.ParseDotenv(strings.NewReader("A=" + strings.Repeat("x", 2<<20)))
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadDotenv(t *testing.T) {
|
||||
write := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
t.Run("Sets variables that are not already set", func(t *testing.T) {
|
||||
t.Setenv("DOTENV_TEST_NEW", "")
|
||||
_ = os.Unsetenv("DOTENV_TEST_NEW")
|
||||
|
||||
err := config.LoadDotenv(write(t, "DOTENV_TEST_NEW=from-file"))
|
||||
|
||||
if err != nil || os.Getenv("DOTENV_TEST_NEW") != "from-file" {
|
||||
t.Errorf("expected from-file, got %q (%v)", os.Getenv("DOTENV_TEST_NEW"), err)
|
||||
}
|
||||
_ = os.Unsetenv("DOTENV_TEST_NEW")
|
||||
})
|
||||
|
||||
t.Run("Never overrides a variable already in the environment", func(t *testing.T) {
|
||||
t.Setenv("DOTENV_TEST_KEEP", "from-env")
|
||||
|
||||
_ = config.LoadDotenv(write(t, "DOTENV_TEST_KEEP=from-file"))
|
||||
|
||||
if os.Getenv("DOTENV_TEST_KEEP") != "from-env" {
|
||||
t.Errorf("environment was overridden: %q", os.Getenv("DOTENV_TEST_KEEP"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Keeps an explicitly empty environment variable", func(t *testing.T) {
|
||||
t.Setenv("DOTENV_TEST_EMPTY", "")
|
||||
|
||||
_ = config.LoadDotenv(write(t, "DOTENV_TEST_EMPTY=from-file"))
|
||||
|
||||
if value, set := os.LookupEnv("DOTENV_TEST_EMPTY"); !set || value != "" {
|
||||
t.Errorf("expected an empty value, got %q set=%v", value, set)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Applies nothing when any line is malformed", func(t *testing.T) {
|
||||
_ = os.Unsetenv("DOTENV_TEST_ATOMIC")
|
||||
|
||||
err := config.LoadDotenv(write(t, "DOTENV_TEST_ATOMIC=1\nbroken line"))
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if _, set := os.LookupEnv("DOTENV_TEST_ATOMIC"); set {
|
||||
t.Error("a partially applied file must not leak variables")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Names the file in a parse error", func(t *testing.T) {
|
||||
path := write(t, "broken line")
|
||||
|
||||
err := config.LoadDotenv(path)
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), path) {
|
||||
t.Errorf("expected the path in the error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reports a missing file as fs.ErrNotExist", func(t *testing.T) {
|
||||
err := config.LoadDotenv(filepath.Join(t.TempDir(), "absent.env"))
|
||||
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("expected fs.ErrNotExist, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -15,11 +15,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
|
||||
userRepo := newVerifiedUserRepo()
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
@@ -36,9 +36,9 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||
})
|
||||
@@ -55,9 +55,9 @@ func newEditArticleBodyTestRepo() *mockArticleRepo {
|
||||
return repo
|
||||
}
|
||||
|
||||
func setEditArticleBodyJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
|
||||
func setEditArticleBodyJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth) {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
|
||||
tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func newEditArticleBodyRequest(method, target string, body string) *http.Request
|
||||
|
||||
func TestEditArticleBodyHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("Editing an existing article's body updates only the body", func(t *testing.T) {
|
||||
repo := newEditArticleBodyTestRepo()
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
verificationHandler := auth.NewVerificationHandler(
|
||||
@@ -29,8 +29,8 @@ func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUserUseCase))
|
||||
r.Get("/me", meHandler.Me)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
@@ -39,9 +39,9 @@ func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *
|
||||
return r
|
||||
}
|
||||
|
||||
func mintGetCurrentUserCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
func mintGetCurrentUserCookie(t *testing.T, token *authtoken.Auth, userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
|
||||
t.Run("A verified user fetches their own profile", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{
|
||||
ID: 7,
|
||||
@@ -92,7 +92,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("An unverified user still fetches their own profile, with is_verified false, not a 403", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[8] = user.User{
|
||||
ID: 8,
|
||||
@@ -122,7 +122,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("No jwt cookie at all is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
@@ -135,7 +135,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
@@ -152,7 +152,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: false, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
@@ -173,7 +173,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
|
||||
+3
-3
@@ -15,8 +15,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
var errStubHashFailure = errors.New("stub hash failure")
|
||||
@@ -153,7 +153,7 @@ func TestBcryptHasherHashAndCompare(t *testing.T) {
|
||||
}
|
||||
|
||||
func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
@@ -170,7 +170,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
|
||||
}
|
||||
|
||||
func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -49,7 +49,7 @@ type variantEnv struct {
|
||||
articles *mockArticleRepo
|
||||
keys *mockAPIKeyRepo
|
||||
logs *bytes.Buffer
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
now time.Time
|
||||
usage *mockVariantUsageRepo
|
||||
tracker *usage.Tracker
|
||||
@@ -82,7 +82,7 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
|
||||
articles: newMockArticleRepo(),
|
||||
keys: newMockAPIKeyRepo(),
|
||||
logs: &bytes.Buffer{},
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC),
|
||||
usage: newMockVariantUsageRepo(),
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (e *variantEnv) issueAPIKey() string {
|
||||
|
||||
func (e *variantEnv) cookie(userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
e.t.Helper()
|
||||
_, signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
e.t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const invitePublicURL = "https://cms.example.com"
|
||||
@@ -137,7 +137,7 @@ type inviteFixture struct {
|
||||
mail *mockMailRepo
|
||||
hasher *stubHasher
|
||||
router *chi.Mux
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
}
|
||||
|
||||
func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
|
||||
@@ -148,7 +148,7 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
|
||||
invites: newInviteFakeRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
hasher: &stubHasher{},
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
handler := userhandler.NewInviteHandler(
|
||||
@@ -159,8 +159,8 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
|
||||
)
|
||||
f.router = chi.NewRouter()
|
||||
f.router.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Get("/", handler.ListUsers)
|
||||
@@ -535,7 +535,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
|
||||
newRegistration := func(users *mockUserRepo, mail *mockMailRepo) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
handler := auth.NewLoginHandler(
|
||||
jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
authtoken.New([]byte("test-secret")),
|
||||
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder),
|
||||
true,
|
||||
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -34,7 +34,7 @@ type inviteManagementFixture struct {
|
||||
mail *mockMailRepo
|
||||
hasher *stubHasher
|
||||
router *chi.Mux
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
}
|
||||
|
||||
func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagementFixture {
|
||||
@@ -45,7 +45,7 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
|
||||
invites: newInviteFakeRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
hasher: &stubHasher{},
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
f.invites.users = f.users
|
||||
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin}
|
||||
@@ -71,8 +71,8 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
|
||||
f.router.Get("/v1/auth/invite", acceptHandler.GetInvite)
|
||||
f.router.Post("/v1/auth/invite/accept", acceptHandler.AcceptInvite)
|
||||
f.router.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Get("/", handler.ListUsers)
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type countingAuthenticateUserRepo struct {
|
||||
@@ -33,7 +33,7 @@ func (r *countingAuthenticateUserRepo) GetByUsername(username string) (user.User
|
||||
func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, maxAttempts int, window time.Duration) *chi.Mux {
|
||||
t.Helper()
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
@@ -32,7 +32,7 @@ func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
|
||||
|
||||
func TestLoginSetsA24HourJwtCookieAndLogoutClearsIt(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("Login sets a jwt cookie that expires about 24 hours from now", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -38,7 +38,7 @@ type mediaEnv struct {
|
||||
purgeSettings *mockPurgeSettingsRepo
|
||||
clock time.Time
|
||||
logs *bytes.Buffer
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
router http.Handler
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
variants: newMockVariantFileRepo(),
|
||||
storage: newMockImageStorage(),
|
||||
users: newMockUserRepo(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
purgeSettings: newMockPurgeSettingsRepo(),
|
||||
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
|
||||
logs: &bytes.Buffer{},
|
||||
@@ -93,7 +93,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
|
||||
func (e *mediaEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -71,7 +71,7 @@ type hardeningEnv struct {
|
||||
settings *mockPurgeSettingsRepo
|
||||
logs *bytes.Buffer
|
||||
users *mockUserRepo
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
router http.Handler
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func newHardeningEnv(t *testing.T) *hardeningEnv {
|
||||
settings: newMockPurgeSettingsRepo(),
|
||||
logs: &bytes.Buffer{},
|
||||
users: newMockUserRepo(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries)
|
||||
@@ -124,7 +124,7 @@ func (e *hardeningEnv) send(t *testing.T, method string, path string, body strin
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
_, tokenString, err := e.token.Encode(map[string]any{"user_id": hardeningAdminID, "token_version": 0})
|
||||
tokenString, err := e.token.Encode(map[string]any{"user_id": hardeningAdminID, "token_version": 0})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
@@ -144,7 +144,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
})
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
@@ -154,9 +154,9 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
||||
return r
|
||||
}
|
||||
|
||||
func setJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth, userId uint32) {
|
||||
func setJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth, userId uint32) {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": userId})
|
||||
tokenString, err := token.Encode(map[string]any{"user_id": userId})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func setJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth, userI
|
||||
|
||||
func TestNotFoundErrorsHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
newArticleRepo := func() *mockArticleRepo {
|
||||
repo := newMockArticleRepo()
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
)
|
||||
@@ -37,7 +37,7 @@ func (w *erroringResponseWriter) Write([]byte) (int, error) {
|
||||
|
||||
func (w *erroringResponseWriter) WriteHeader(int) {}
|
||||
|
||||
func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
|
||||
userRepo := newVerifiedUserRepo()
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
@@ -53,13 +53,13 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
r.Delete("/{id}", articleHandler.DeleteArticle)
|
||||
@@ -78,7 +78,7 @@ func seedPublicArticleListingRepo() *mockArticleRepo {
|
||||
|
||||
func TestPublicArticleListingHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("An anonymous visitor only sees online articles", func(t *testing.T) {
|
||||
router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token)
|
||||
|
||||
@@ -17,11 +17,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
@@ -35,11 +35,11 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
r.Delete("/{id}", articleHandler.DeleteArticle)
|
||||
@@ -65,9 +65,9 @@ func newPublicArticleReadUserRepo(isVerified bool) *mockUserRepo {
|
||||
return repo
|
||||
}
|
||||
|
||||
func mintPublicArticleReadCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32) *http.Cookie {
|
||||
func mintPublicArticleReadCookie(t *testing.T, token *authtoken.Auth, userId uint32) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": userId})
|
||||
tokenString, err := token.Encode(map[string]any{"user_id": userId})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func assertPublicArticleReadNotFound(t *testing.T, rec *httptest.ResponseRecorde
|
||||
|
||||
func TestPublicArticleReadHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("An anonymous visitor can read an online article", func(t *testing.T) {
|
||||
router := newPublicArticleReadRouter(seedPublicArticleReadRepo(), newMockUserRepo(), token)
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type sequentialHasher struct {
|
||||
@@ -38,7 +38,7 @@ func (s *sequentialHasher) Compare(hashed string, value string) error {
|
||||
}
|
||||
|
||||
func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, publicURL string) *chi.Mux {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
hasher := &stubHasher{}
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
hasher := &stubHasher{}
|
||||
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true)
|
||||
|
||||
@@ -18,13 +18,13 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mockUserRepo) {
|
||||
t.Helper()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
hashed, err := hasher.Hash("hunter2example")
|
||||
@@ -125,7 +125,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
imageRepo := newMockImageRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
return &api.UseCases{
|
||||
Token: token,
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
|
||||
func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *authtoken.Auth, version string) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
settingsHandler := settings.NewHandler(
|
||||
useCases.NewGetSettingsUseCase(version),
|
||||
@@ -25,8 +25,8 @@ func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTA
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1/settings", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Get("/", settingsHandler.GetSettings)
|
||||
@@ -43,7 +43,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
|
||||
for _, version := range []string{"v2.2.0-beta", "dev"} {
|
||||
t.Run("A verified caller gets the running build's version: "+version, func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
router := newSettingsRouter(t, userRepo, token, version)
|
||||
@@ -61,7 +61,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("The response does not depend on the environment at request time", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
|
||||
@@ -79,7 +79,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("A request without a jwt cookie is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
router := newSettingsRouter(t, newMockUserRepo(), token, "v2.2.0-beta")
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil)
|
||||
@@ -93,7 +93,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("A token whose version no longer matches the account is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
|
||||
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
|
||||
@@ -110,7 +110,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("An unverified account is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0}
|
||||
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/mailer"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var smtpMailerClock = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func smtpMailerFor(server *fakeSMTPServer, settings config.SmtpSettings) *mailer.MailRepository {
|
||||
if settings.Host == "" {
|
||||
settings.Host = "127.0.0.1"
|
||||
}
|
||||
settings.Port = server.port()
|
||||
if settings.Sender == "" {
|
||||
settings.Sender = "notify@example.com"
|
||||
}
|
||||
return mailer.NewMailRepository(settings, mailer.WithTLSConfig(server.clientTLS()), mailer.WithClock(func() time.Time { return smtpMailerClock }))
|
||||
}
|
||||
|
||||
func verificationData() useCases.MailValidationData {
|
||||
return useCases.MailValidationData{Host: "https://cms.example.com", VerificationCode: "abc123"}
|
||||
}
|
||||
|
||||
func parseDeliveredMail(t *testing.T, raw string) (*mail.Message, string) {
|
||||
t.Helper()
|
||||
message, err := mail.ReadMessage(strings.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("delivered data is not a valid message: %v\n%s", err, raw)
|
||||
}
|
||||
decoded, err := io.ReadAll(quotedprintable.NewReader(message.Body))
|
||||
if err != nil {
|
||||
t.Fatalf("body is not valid quoted-printable: %v", err)
|
||||
}
|
||||
return message, string(decoded)
|
||||
}
|
||||
|
||||
func TestSMTPMailerDelivery(t *testing.T) {
|
||||
t.Run("Delivers a well-formed HTML message to a plain local server", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
||||
|
||||
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("send failed: %v", err)
|
||||
}
|
||||
sessions := server.received()
|
||||
if len(sessions) != 1 || sessions[0].from != "notify@example.com" || sessions[0].to != "reader@example.org" {
|
||||
t.Fatalf("unexpected envelope %+v", sessions)
|
||||
}
|
||||
message, body := parseDeliveredMail(t, sessions[0].data)
|
||||
if got := message.Header.Get("From"); got != `"RenewCMS" <notify@example.com>` {
|
||||
t.Errorf("unexpected From %q", got)
|
||||
}
|
||||
if got := message.Header.Get("To"); got != "<reader@example.org>" {
|
||||
t.Errorf("unexpected To %q", got)
|
||||
}
|
||||
subject, _ := new(mime.WordDecoder).DecodeHeader(message.Header.Get("Subject"))
|
||||
if subject != "Verify your RenewCMS account" {
|
||||
t.Errorf("unexpected Subject %q", subject)
|
||||
}
|
||||
if got := message.Header.Get("Content-Type"); got != "text/html; charset=UTF-8" {
|
||||
t.Errorf("unexpected Content-Type %q", got)
|
||||
}
|
||||
if got := message.Header.Get("MIME-Version"); got != "1.0" {
|
||||
t.Errorf("unexpected MIME-Version %q", got)
|
||||
}
|
||||
if !strings.Contains(body, "https://cms.example.com/register/validate?c=abc123") {
|
||||
t.Errorf("verification link missing from body %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Stamps a Date from the clock and a unique Message-ID on the sender's domain", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
||||
repo := smtpMailerFor(server, config.SmtpSettings{})
|
||||
|
||||
_ = repo.Send("a@example.org", "verification", verificationData())
|
||||
_ = repo.Send("a@example.org", "verification", verificationData())
|
||||
|
||||
sessions := server.received()
|
||||
first, _ := parseDeliveredMail(t, sessions[0].data)
|
||||
second, _ := parseDeliveredMail(t, sessions[1].data)
|
||||
date, err := first.Header.Date()
|
||||
if err != nil || !date.Equal(smtpMailerClock) {
|
||||
t.Errorf("expected Date %v, got %v (%v)", smtpMailerClock, date, err)
|
||||
}
|
||||
id := first.Header.Get("Message-ID")
|
||||
if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") {
|
||||
t.Errorf("unexpected Message-ID %q", id)
|
||||
}
|
||||
if id == second.Header.Get("Message-ID") {
|
||||
t.Error("Message-IDs must be unique")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Encodes a non-ASCII subject and body without corrupting them", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
||||
repo := smtpMailerFor(server, config.SmtpSettings{})
|
||||
|
||||
err := repo.Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := server.received()[0].data
|
||||
headerBlock, _, _ := strings.Cut(raw, "\r\n\r\n")
|
||||
for _, r := range headerBlock {
|
||||
if r > 127 {
|
||||
t.Fatalf("header contains a raw non-ASCII rune %q", r)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Authenticates with PLAIN over STARTTLS", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "PLAIN LOGIN"})
|
||||
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
|
||||
|
||||
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("send failed: %v", err)
|
||||
}
|
||||
session := server.received()[0]
|
||||
if !session.usedTLS || session.username != "user@example.com" || session.password != "s3cret" {
|
||||
t.Errorf("unexpected session %+v", session)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Falls back to LOGIN when PLAIN is not offered", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "LOGIN"})
|
||||
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
|
||||
|
||||
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("send failed: %v", err)
|
||||
}
|
||||
session := server.received()[0]
|
||||
if session.username != "user@example.com" || session.password != "s3cret" {
|
||||
t.Errorf("unexpected credentials %+v", session)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Skips authentication when no login is configured", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: "PLAIN"})
|
||||
|
||||
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, command := range server.received()[0].commands {
|
||||
if strings.HasPrefix(strings.ToUpper(command), "AUTH") {
|
||||
t.Errorf("unexpected %q", command)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, mechanisms := range []string{"PLAIN", "LOGIN"} {
|
||||
t.Run("Refuses to send "+mechanisms+" credentials in clear text to a non-loopback server", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: mechanisms, listenAddress: "127.0.0.2:0"})
|
||||
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret", Host: "127.0.0.2"}
|
||||
|
||||
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if session := server.received()[0]; session.password != "" || session.data != "" {
|
||||
t.Errorf("credentials or data leaked: %+v", session)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Fails when credentials are configured but the server offers no AUTH", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
||||
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
|
||||
|
||||
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if len(server.received()[0].data) != 0 {
|
||||
t.Error("no message may be delivered")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Fails the STARTTLS handshake against an untrusted certificate", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true})
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: server.port(), Sender: "notify@example.com"})
|
||||
|
||||
err := repo.Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected a certificate error")
|
||||
}
|
||||
if len(server.received()[0].data) != 0 {
|
||||
t.Error("no message may be delivered")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Uses implicit TLS on port 465", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{implicitTLS: true, listenAddress: "127.0.0.1:465"})
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: 465, Sender: "notify@example.com"}, mailer.WithTLSConfig(server.clientTLS()))
|
||||
|
||||
err := repo.Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("send failed: %v", err)
|
||||
}
|
||||
if !server.received()[0].usedTLS {
|
||||
t.Error("expected a TLS session")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Surfaces a rejected recipient", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{rejectRcpt: true})
|
||||
|
||||
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Surfaces a rejected DATA command", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{rejectData: true})
|
||||
|
||||
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Surfaces an unreachable server", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
||||
repo := smtpMailerFor(server, config.SmtpSettings{})
|
||||
_ = server.listener.Close()
|
||||
|
||||
if err := repo.Send("reader@example.org", "verification", verificationData()); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSMTPMailerRejectsUnsafeInput(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
sender string
|
||||
receiver string
|
||||
template string
|
||||
}{
|
||||
"receiver with an injected header": {"notify@example.com", "a@example.org\r\nBcc: evil@example.net", "verification"},
|
||||
"receiver that is not an address": {"notify@example.com", "not-an-address", "verification"},
|
||||
"empty receiver": {"notify@example.com", "", "verification"},
|
||||
"sender that is not an address": {"nope", "a@example.org", "verification"},
|
||||
"unknown template": {"notify@example.com", "a@example.org", "does-not-exist"},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run("Rejects a "+name+" before opening a connection", func(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
||||
repo := smtpMailerFor(server, config.SmtpSettings{Sender: tc.sender})
|
||||
|
||||
err := repo.Send(tc.receiver, tc.template, verificationData())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if len(server.received()) != 0 {
|
||||
t.Errorf("no connection may be made, got %d", len(server.received()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeSMTPOptions struct {
|
||||
authMechanisms string
|
||||
startTLS bool
|
||||
rejectRcpt bool
|
||||
rejectData bool
|
||||
implicitTLS bool
|
||||
listenAddress string
|
||||
}
|
||||
|
||||
type fakeSMTPSession struct {
|
||||
commands []string
|
||||
from string
|
||||
to string
|
||||
data string
|
||||
username string
|
||||
password string
|
||||
usedTLS bool
|
||||
}
|
||||
|
||||
type fakeSMTPServer struct {
|
||||
listener net.Listener
|
||||
options fakeSMTPOptions
|
||||
cert tls.Certificate
|
||||
roots *x509.CertPool
|
||||
mu sync.Mutex
|
||||
sessions []*fakeSMTPSession
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func newFakeSMTPServer(t *testing.T, options fakeSMTPOptions) *fakeSMTPServer {
|
||||
t.Helper()
|
||||
address := options.listenAddress
|
||||
if address == "" {
|
||||
address = "127.0.0.1:0"
|
||||
}
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
t.Skipf("cannot listen on %s: %v", address, err)
|
||||
}
|
||||
cert, roots := selfSignedLoopbackCert(t)
|
||||
server := &fakeSMTPServer{listener: listener, options: options, cert: cert, roots: roots}
|
||||
server.wg.Add(1)
|
||||
go server.acceptLoop()
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
server.wg.Wait()
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
func selfSignedLoopbackCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(parsed)
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, roots
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) port() int {
|
||||
return s.listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) clientTLS() *tls.Config {
|
||||
return &tls.Config{RootCAs: s.roots}
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) received() []*fakeSMTPSession {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]*fakeSMTPSession(nil), s.sessions...)
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) acceptLoop() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
connection, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.serve(connection)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) serve(connection net.Conn) {
|
||||
defer func() { _ = connection.Close() }()
|
||||
_ = connection.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
session := &fakeSMTPSession{}
|
||||
s.mu.Lock()
|
||||
s.sessions = append(s.sessions, session)
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.options.implicitTLS {
|
||||
connection = tls.Server(connection, &tls.Config{Certificates: []tls.Certificate{s.cert}})
|
||||
session.usedTLS = true
|
||||
}
|
||||
reader := bufio.NewReader(connection)
|
||||
reply := func(line string) { _, _ = connection.Write([]byte(line + "\r\n")) }
|
||||
reply("220 fake ESMTP")
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
s.record(session, line)
|
||||
verb := strings.ToUpper(strings.Fields(line + " ")[0])
|
||||
|
||||
switch verb {
|
||||
case "EHLO":
|
||||
s.replyEhlo(reply, session)
|
||||
case "STARTTLS":
|
||||
reply("220 ready")
|
||||
connection = tls.Server(connection, &tls.Config{Certificates: []tls.Certificate{s.cert}})
|
||||
reader = bufio.NewReader(connection)
|
||||
reply = func(line string) { _, _ = connection.Write([]byte(line + "\r\n")) }
|
||||
session.usedTLS = true
|
||||
case "AUTH":
|
||||
if !s.authenticate(line, reader, reply, session) {
|
||||
return
|
||||
}
|
||||
case "MAIL":
|
||||
session.from = smtpAngleContent(line, "<", ">")
|
||||
reply("250 ok")
|
||||
case "RCPT":
|
||||
if s.options.rejectRcpt {
|
||||
reply("550 no such user")
|
||||
continue
|
||||
}
|
||||
session.to = smtpAngleContent(line, "<", ">")
|
||||
reply("250 ok")
|
||||
case "DATA":
|
||||
if s.options.rejectData {
|
||||
reply("554 refused")
|
||||
continue
|
||||
}
|
||||
reply("354 go ahead")
|
||||
session.data = readSMTPData(reader)
|
||||
reply("250 queued")
|
||||
case "QUIT":
|
||||
reply("221 bye")
|
||||
return
|
||||
default:
|
||||
reply("250 ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) record(session *fakeSMTPSession, line string) {
|
||||
s.mu.Lock()
|
||||
session.commands = append(session.commands, line)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) replyEhlo(reply func(string), session *fakeSMTPSession) {
|
||||
lines := []string{"250-fake"}
|
||||
if s.options.startTLS && !session.usedTLS {
|
||||
lines = append(lines, "250-STARTTLS")
|
||||
}
|
||||
if s.options.authMechanisms != "" {
|
||||
lines = append(lines, "250-AUTH "+s.options.authMechanisms)
|
||||
}
|
||||
lines = append(lines, "250 8BITMIME")
|
||||
for _, line := range lines {
|
||||
reply(line)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) authenticate(line string, reader *bufio.Reader, reply func(string), session *fakeSMTPSession) bool {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
reply("501 syntax")
|
||||
return false
|
||||
}
|
||||
switch strings.ToUpper(fields[1]) {
|
||||
case "PLAIN":
|
||||
raw, _ := base64.StdEncoding.DecodeString(fields[2])
|
||||
parts := strings.Split(string(raw), "\x00")
|
||||
session.username, session.password = parts[1], parts[2]
|
||||
case "LOGIN":
|
||||
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
username, _ := reader.ReadString('\n')
|
||||
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
password, _ := reader.ReadString('\n')
|
||||
session.username = decodeBase64Line(username)
|
||||
session.password = decodeBase64Line(password)
|
||||
default:
|
||||
reply("504 unsupported")
|
||||
return false
|
||||
}
|
||||
reply("235 ok")
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeBase64Line(line string) string {
|
||||
raw, _ := base64.StdEncoding.DecodeString(strings.TrimSpace(line))
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func smtpAngleContent(line string, open string, close string) string {
|
||||
start := strings.Index(line, open)
|
||||
end := strings.LastIndex(line, close)
|
||||
if start < 0 || end <= start {
|
||||
return ""
|
||||
}
|
||||
return line[start+1 : end]
|
||||
}
|
||||
|
||||
func readSMTPData(reader *bufio.Reader) string {
|
||||
var data strings.Builder
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil || line == ".\r\n" {
|
||||
return data.String()
|
||||
}
|
||||
data.WriteString(strings.TrimPrefix(line, "."))
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
@@ -50,8 +50,8 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
@@ -63,8 +63,8 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
||||
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
|
||||
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUserUseCase))
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
@@ -90,9 +90,9 @@ func newStaleTokenAliceRepo(isVerified bool) *mockUserRepo {
|
||||
return repo
|
||||
}
|
||||
|
||||
func mintStaleTokenCookie(t *testing.T, token *jwtauth.JWTAuth, claims map[string]any) *http.Cookie {
|
||||
func mintStaleTokenCookie(t *testing.T, token *authtoken.Auth, claims map[string]any) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(claims)
|
||||
tokenString, err := token.Encode(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
|
||||
t.Run("A jwt minted before a password reset is refused afterward, but a fresh login's jwt is accepted", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newStaleTokenAliceRepo(true)
|
||||
articleRepo := newMockArticleRepo()
|
||||
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
|
||||
@@ -211,7 +211,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("A stale jwt is also refused on the auth group's own protected routes, not just article routes", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newStaleTokenAliceRepo(false)
|
||||
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
|
||||
preResetCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
|
||||
@@ -242,7 +242,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Resetting the password twice invalidates both the original jwt and the jwt from the interim login", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newStaleTokenAliceRepo(true)
|
||||
articleRepo := newMockArticleRepo()
|
||||
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
|
||||
@@ -298,7 +298,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Resetting one user's password never invalidates another user's jwt", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newStaleTokenAliceRepo(true)
|
||||
userRepo.users[9] = user.User{ID: 9, Username: "bob", IsVerified: true, TokenVersion: 0}
|
||||
articleRepo := newMockArticleRepo()
|
||||
@@ -329,7 +329,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cancelledRegistrationCases {
|
||||
t.Run("A cancelled registration still refuses a jwt minted before the cancellation, on every protected route: "+tc.method+" "+tc.path, func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newStaleTokenAliceRepo(false)
|
||||
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
|
||||
preCancelCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
|
||||
@@ -350,7 +350,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("A jwt with no token_version claim at all is refused, treated the same as a mismatch", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newStaleTokenAliceRepo(true)
|
||||
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
|
||||
cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7)})
|
||||
@@ -365,7 +365,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("A jwt whose token_version claim is a string, not a number, is refused instead of crashing", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newStaleTokenAliceRepo(true)
|
||||
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
|
||||
cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": "0"})
|
||||
|
||||
@@ -13,14 +13,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, trustedProxies []string) *chi.Mux {
|
||||
t.Helper()
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newMigratedUserRepository(t *testing.T) *persistence.UserRepository {
|
||||
@@ -151,7 +151,7 @@ func TestUnverifiedUsersAndReregistrationRepository(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
@@ -184,7 +184,7 @@ func registerViaHttp(t *testing.T, router *chi.Mux, username, password, email st
|
||||
|
||||
func TestUnverifiedUsersAndReregistrationHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("Two unverified users can register one after another without a reset-code collision", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
)
|
||||
|
||||
const downloadPath = "/update/download"
|
||||
@@ -31,7 +31,7 @@ const downloadPath = "/update/download"
|
||||
func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
users := newUpdateUserRepo()
|
||||
feed := &fakeReleaseFeed{}
|
||||
now := func() time.Time { return updateTestCheckedAt }
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,7 +32,7 @@ var updateStatusKeys = []string{"available", "can_apply", "checked_at", "contain
|
||||
|
||||
type updateHarness struct {
|
||||
router http.Handler
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
feed *fakeReleaseFeed
|
||||
store *fakeUpdateStore
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
|
||||
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
|
||||
}}
|
||||
store := &fakeUpdateStore{}
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
now := func() time.Time { return updateTestCheckedAt }
|
||||
container := &api.UseCases{
|
||||
Token: token,
|
||||
@@ -74,7 +74,7 @@ func (h *updateHarness) send(t *testing.T, method, path string, userId uint32, t
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if userId != 0 {
|
||||
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -20,14 +20,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
)
|
||||
|
||||
const restartPath = "/update/restart"
|
||||
|
||||
type restartHTTP struct {
|
||||
router http.Handler
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
dataDir string
|
||||
markerPath string
|
||||
resultPath string
|
||||
@@ -44,7 +44,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
|
||||
validator.InitValidator()
|
||||
dataDir := t.TempDir()
|
||||
h := &restartHTTP{
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
dataDir: dataDir,
|
||||
markerPath: filepath.Join(dataDir, update.RestartRequestFileName),
|
||||
resultPath: filepath.Join(dataDir, update.ResultFileName),
|
||||
@@ -86,7 +86,7 @@ func (h *restartHTTP) request(t *testing.T, method string, target string, userId
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
if userId != 0 {
|
||||
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -554,7 +554,7 @@ func TestRestartResultLivesUntilTheNextJob(t *testing.T) {
|
||||
func TestHealthzIsUnchanged(t *testing.T) {
|
||||
t.Run("Healthz is unchanged", func(t *testing.T) {
|
||||
container := &api.UseCases{
|
||||
Token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
Token: authtoken.New([]byte("test-secret")),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
|
||||
}
|
||||
router := api.InitRoutes(container, config.Settings{Environment: "production"})
|
||||
@@ -615,7 +615,7 @@ func TestRunningInfoFileAtStartup(t *testing.T) {
|
||||
|
||||
t.Run("the file is not served by any route", func(t *testing.T) {
|
||||
container := &api.UseCases{
|
||||
Token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
Token: authtoken.New([]byte("test-secret")),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
|
||||
}
|
||||
router := api.InitRoutes(container, config.Settings{Environment: "production"})
|
||||
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"}
|
||||
|
||||
func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
getUser := useCases.NewGetUserUseCase(userRepo)
|
||||
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
|
||||
@@ -37,8 +37,8 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Patch("/me", handler.UpdateMe)
|
||||
@@ -46,8 +46,8 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
|
||||
r.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Post("/login", loginHandler.Login)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
@@ -55,9 +55,9 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
|
||||
return r
|
||||
}
|
||||
|
||||
func displayNameCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
func displayNameCookie(t *testing.T, token *authtoken.Auth, userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func TestUpdateUserProfileUseCase(t *testing.T) {
|
||||
|
||||
func TestUserDisplayNameHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
patch := func(t *testing.T, repo *mockUserRepo, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
@@ -44,8 +44,8 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
|
||||
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
|
||||
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,7 @@ func assertNoForbiddenKeys(t *testing.T, body []byte) {
|
||||
|
||||
func TestUserResponseFieldHygiene(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("A successful verification response carries only public fields", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
@@ -111,7 +111,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"code": "abc123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
|
||||
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
|
||||
tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -164,7 +164,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"code": "wrong-code"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
|
||||
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
|
||||
tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
+10
-10
@@ -16,9 +16,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -33,7 +33,7 @@ type userRolesFixture struct {
|
||||
invites *inviteFakeRepo
|
||||
mail *mockMailRepo
|
||||
router *chi.Mux
|
||||
token *jwtauth.JWTAuth
|
||||
token *authtoken.Auth
|
||||
}
|
||||
|
||||
func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
@@ -43,7 +43,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
users: newMockUserRepo(),
|
||||
invites: newInviteFakeRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
f.invites.users = f.users
|
||||
hasher := &stubHasher{}
|
||||
@@ -73,15 +73,15 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
r.Post("/register", loginHandler.Register)
|
||||
r.Post("/invite/accept", acceptHandler.AcceptInvite)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
})
|
||||
f.router.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Get("/", inviteHandler.ListUsers)
|
||||
@@ -386,12 +386,12 @@ func TestUserRolesUseCases(t *testing.T) {
|
||||
|
||||
func TestRequireOwnerRepositoryFailure(t *testing.T) {
|
||||
t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
repo := newMockUserRepo()
|
||||
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
r := chi.NewRouter()
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireOwner(useCases.NewGetUserUseCase(repo)))
|
||||
r.Post("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
repo.fail.failNext("Get")
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
@@ -32,11 +32,11 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
r.Delete("/{id}", articleHandler.DeleteArticle)
|
||||
@@ -61,9 +61,9 @@ func seedVerifiedAccountRequiredUserRepo(isVerified bool) *mockUserRepo {
|
||||
return repo
|
||||
}
|
||||
|
||||
func mintVerifiedAccountRequiredCookie(t *testing.T, token *jwtauth.JWTAuth, claims map[string]any) *http.Cookie {
|
||||
func mintVerifiedAccountRequiredCookie(t *testing.T, token *authtoken.Auth, claims map[string]any) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(claims)
|
||||
tokenString, err := token.Encode(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func newVerifiedAccountRequiredRequest(method, target string, body string) *http
|
||||
|
||||
func TestVerifiedAccountRequiredHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
protectedRouteCases := []struct {
|
||||
method string
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -208,7 +208,7 @@ func TestTheFakesMirrorTheRealRepositoriesOnEveryWriteNotFoundPath(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
|
||||
userRepo := newVerifiedUserRepo()
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
@@ -224,8 +224,8 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Delete("/{id}", articleHandler.DeleteArticle)
|
||||
@@ -237,7 +237,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
||||
|
||||
func TestWriteNotFoundErrorsHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
|
||||
t.Run("Deleting an unknown article answers 404, not a silent success or a 500", func(t *testing.T) {
|
||||
articleRepo := newMockArticleRepo()
|
||||
|
||||
Reference in new issue
Block a user