diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index cc6184d..b574208 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -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), diff --git a/api/handlers/auth/acceptInviteHandler.go b/api/handlers/auth/acceptInviteHandler.go index f8f3f97..79610c8 100644 --- a/api/handlers/auth/acceptInviteHandler.go +++ b/api/handlers/auth/acceptInviteHandler.go @@ -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, diff --git a/api/handlers/auth/loginHandler.go b/api/handlers/auth/loginHandler.go index f2dc9d2..4a5357a 100644 --- a/api/handlers/auth/loginHandler.go +++ b/api/handlers/auth/loginHandler.go @@ -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: "/", diff --git a/api/middleware/apiKey.go b/api/middleware/apiKey.go index 1f4596b..87ede94 100644 --- a/api/middleware/apiKey.go +++ b/api/middleware/apiKey.go @@ -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 { diff --git a/api/middleware/cors.go b/api/middleware/cors.go new file mode 100644 index 0000000..5f063ec --- /dev/null +++ b/api/middleware/cors.go @@ -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) +} diff --git a/api/middleware/requireSignedIn.go b/api/middleware/requireSignedIn.go index c5af9ee..38c653b 100644 --- a/api/middleware/requireSignedIn.go +++ b/api/middleware/requireSignedIn.go @@ -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 } diff --git a/api/pkg/authtoken/authtoken.go b/api/pkg/authtoken/authtoken.go new file mode 100644 index 0000000..38de3df --- /dev/null +++ b/api/pkg/authtoken/authtoken.go @@ -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) +} diff --git a/api/pkg/authtoken/middleware.go b/api/pkg/authtoken/middleware.go new file mode 100644 index 0000000..3ffa525 --- /dev/null +++ b/api/pkg/authtoken/middleware.go @@ -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 +} diff --git a/api/pkg/claims/claims.go b/api/pkg/claims/claims.go index 46558e8..6a610ca 100644 --- a/api/pkg/claims/claims.go +++ b/api/pkg/claims/claims.go @@ -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 } diff --git a/api/router.go b/api/router.go index a85b49b..7865566 100644 --- a/api/router.go +++ b/api/router.go @@ -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 { diff --git a/cmd/server/main.go b/cmd/server/main.go index 28167fb..15f03b0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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") } diff --git a/docs/architecture.md b/docs/architecture.md index 636326f..13185ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). diff --git a/docs/configuration.md b/docs/configuration.md index 4a6c27b..b263f7c 100644 --- a/docs/configuration.md +++ b/docs/configuration.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 ""`. 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). | diff --git a/docs/image-variants.md b/docs/image-variants.md index b814b7b..f810f75 100644 --- a/docs/image-variants.md +++ b/docs/image-variants.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 diff --git a/go.mod b/go.mod index 9f5123f..aae8f3c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3dfeff4..e6e37d6 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/infrastructure/config/dotenv.go b/internal/infrastructure/config/dotenv.go new file mode 100644 index 0000000..62162f0 --- /dev/null +++ b/internal/infrastructure/config/dotenv.go @@ -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") +} diff --git a/internal/infrastructure/mailer/auth.go b/internal/infrastructure/mailer/auth.go new file mode 100644 index 0000000..16a2b60 --- /dev/null +++ b/internal/infrastructure/mailer/auth.go @@ -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} +} diff --git a/internal/infrastructure/mailer/mailRepository.go b/internal/infrastructure/mailer/mailRepository.go index f1df97e..4d68f7b 100644 --- a/internal/infrastructure/mailer/mailRepository.go +++ b/internal/infrastructure/mailer/mailRepository.go @@ -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 } diff --git a/internal/infrastructure/mailer/message.go b/internal/infrastructure/mailer/message.go new file mode 100644 index 0000000..8cdac3d --- /dev/null +++ b/internal/infrastructure/mailer/message.go @@ -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 +} diff --git a/internal/infrastructure/mailer/transport.go b/internal/infrastructure/mailer/transport.go new file mode 100644 index 0000000..bdeed0c --- /dev/null +++ b/internal/infrastructure/mailer/transport.go @@ -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() +} diff --git a/internal/infrastructure/useCases/ServeVariant.go b/internal/infrastructure/useCases/ServeVariant.go index 23e731d..7d69bde 100644 --- a/internal/infrastructure/useCases/ServeVariant.go +++ b/internal/infrastructure/useCases/ServeVariant.go @@ -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 diff --git a/internal/infrastructure/useCases/flightGroup.go b/internal/infrastructure/useCases/flightGroup.go new file mode 100644 index 0000000..906aba0 --- /dev/null +++ b/internal/infrastructure/useCases/flightGroup.go @@ -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 +} diff --git a/test/accept_invitation_atomic_test.go b/test/accept_invitation_atomic_test.go index 47e5199..6f4a9b6 100644 --- a/test/accept_invitation_atomic_test.go +++ b/test/accept_invitation_atomic_test.go @@ -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() diff --git a/test/accept_invitation_test.go b/test/accept_invitation_test.go index 9449ab3..66910be 100644 --- a/test/accept_invitation_test.go +++ b/test/accept_invitation_test.go @@ -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"]) } diff --git a/test/account_verification_http_test.go b/test/account_verification_http_test.go index 664e3a0..71ff1dc 100644 --- a/test/account_verification_http_test.go +++ b/test/account_verification_http_test.go @@ -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) diff --git a/test/activity_log_backend_http_test.go b/test/activity_log_backend_http_test.go index 4a53ee7..1aa99a4 100644 --- a/test/activity_log_backend_http_test.go +++ b/test/activity_log_backend_http_test.go @@ -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) { diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 8b6f608..34cd1c1 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -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) } diff --git a/test/article_body_format_test.go b/test/article_body_format_test.go index 0f0a5cd..eefe612 100644 --- a/test/article_body_format_test.go +++ b/test/article_body_format_test.go @@ -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) } diff --git a/test/article_body_unlimited_http_test.go b/test/article_body_unlimited_http_test.go index 64663ca..84c6fa9 100644 --- a/test/article_body_unlimited_http_test.go +++ b/test/article_body_unlimited_http_test.go @@ -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 { diff --git a/test/article_publishing_http_test.go b/test/article_publishing_http_test.go index fd39f8d..e0a1721 100644 --- a/test/article_publishing_http_test.go +++ b/test/article_publishing_http_test.go @@ -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() diff --git a/test/article_title_rules_test.go b/test/article_title_rules_test.go index c768b08..404e314 100644 --- a/test/article_title_rules_test.go +++ b/test/article_title_rules_test.go @@ -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 diff --git a/test/articles_list_http_test.go b/test/articles_list_http_test.go index e9bf8bf..98a8029 100644 --- a/test/articles_list_http_test.go +++ b/test/articles_list_http_test.go @@ -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() diff --git a/test/articles_pagination_api_http_test.go b/test/articles_pagination_api_http_test.go index 48d93fa..ca5b31c 100644 --- a/test/articles_pagination_api_http_test.go +++ b/test/articles_pagination_api_http_test.go @@ -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() diff --git a/test/authtoken_middleware_test.go b/test/authtoken_middleware_test.go new file mode 100644 index 0000000..9752612 --- /dev/null +++ b/test/authtoken_middleware_test.go @@ -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") + } + }) +} diff --git a/test/authtoken_test.go b/test/authtoken_test.go new file mode 100644 index 0000000..cea53b1 --- /dev/null +++ b/test/authtoken_test.go @@ -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) +} diff --git a/test/cancel_pending_registration_http_test.go b/test/cancel_pending_registration_http_test.go index 5fe7dc1..89954d8 100644 --- a/test/cancel_pending_registration_http_test.go +++ b/test/cancel_pending_registration_http_test.go @@ -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) diff --git a/test/claims_test.go b/test/claims_test.go index bd84382..7302f10 100644 --- a/test/claims_test.go +++ b/test/claims_test.go @@ -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)}) diff --git a/test/container_update_guidance_http_test.go b/test/container_update_guidance_http_test.go index c64d8eb..85ba71e 100644 --- a/test/container_update_guidance_http_test.go +++ b/test/container_update_guidance_http_test.go @@ -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", diff --git a/test/cors_middleware_test.go b/test/cors_middleware_test.go new file mode 100644 index 0000000..a9bb878 --- /dev/null +++ b/test/cors_middleware_test.go @@ -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") + } + }) +} diff --git a/test/custom_content_types_support_http_test.go b/test/custom_content_types_support_http_test.go index 3d00f5c..9ff5546 100644 --- a/test/custom_content_types_support_http_test.go +++ b/test/custom_content_types_support_http_test.go @@ -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 { diff --git a/test/custom_content_types_support_test.go b/test/custom_content_types_support_test.go index 6f85c61..67d1bc9 100644 --- a/test/custom_content_types_support_test.go +++ b/test/custom_content_types_support_test.go @@ -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) } diff --git a/test/dotenv_test.go b/test/dotenv_test.go new file mode 100644 index 0000000..9bd5114 --- /dev/null +++ b/test/dotenv_test.go @@ -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) + } + }) +} diff --git a/test/edit_article_body_http_test.go b/test/edit_article_body_http_test.go index c92e385..31b13d7 100644 --- a/test/edit_article_body_http_test.go +++ b/test/edit_article_body_http_test.go @@ -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() diff --git a/test/get_current_user_http_test.go b/test/get_current_user_http_test.go index 96bc57c..710eeaa 100644 --- a/test/get_current_user_http_test.go +++ b/test/get_current_user_http_test.go @@ -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) diff --git a/test/hasher_test.go b/test/hasher_test.go index e786fa3..ab92dd3 100644 --- a/test/hasher_test.go +++ b/test/hasher_test.go @@ -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, diff --git a/test/image_variants_env_test.go b/test/image_variants_env_test.go index 4ff622e..e4a1b12 100644 --- a/test/image_variants_env_test.go +++ b/test/image_variants_env_test.go @@ -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) } diff --git a/test/invitations_test.go b/test/invitations_test.go index b0c63e9..3c77f15 100644 --- a/test/invitations_test.go +++ b/test/invitations_test.go @@ -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, diff --git a/test/invite_management_test.go b/test/invite_management_test.go index 8bfef95..29cb5b4 100644 --- a/test/invite_management_test.go +++ b/test/invite_management_test.go @@ -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) diff --git a/test/login_and_password_reset_rate_limiting_http_test.go b/test/login_and_password_reset_rate_limiting_http_test.go index 6b03339..3f468f7 100644 --- a/test/login_and_password_reset_rate_limiting_http_test.go +++ b/test/login_and_password_reset_rate_limiting_http_test.go @@ -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), diff --git a/test/login_logout_cookie_test.go b/test/login_logout_cookie_test.go index 361d51e..06d66ee 100644 --- a/test/login_logout_cookie_test.go +++ b/test/login_logout_cookie_test.go @@ -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() diff --git a/test/media_env_test.go b/test/media_env_test.go index f96a20e..9e52e03 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -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) } diff --git a/test/media_merge_hardening_test.go b/test/media_merge_hardening_test.go index 5bc8a03..b089f12 100644 --- a/test/media_merge_hardening_test.go +++ b/test/media_merge_hardening_test.go @@ -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) } diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index 6182cbc..0b350b9 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -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() diff --git a/test/public_article_listing_test.go b/test/public_article_listing_test.go index 619503c..df2713b 100644 --- a/test/public_article_listing_test.go +++ b/test/public_article_listing_test.go @@ -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) diff --git a/test/public_article_read_test.go b/test/public_article_read_test.go index ba2c87f..6380e99 100644 --- a/test/public_article_read_test.go +++ b/test/public_article_read_test.go @@ -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) diff --git a/test/registration_gate_and_verification_email_test.go b/test/registration_gate_and_verification_email_test.go index 9a188e6..e20e21b 100644 --- a/test/registration_gate_and_verification_email_test.go +++ b/test/registration_gate_and_verification_email_test.go @@ -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, diff --git a/test/registration_status_endpoint_http_test.go b/test/registration_status_endpoint_http_test.go index 1d037ae..6d1c18f 100644 --- a/test/registration_status_endpoint_http_test.go +++ b/test/registration_status_endpoint_http_test.go @@ -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) diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index 32b0b79..a076446 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -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, diff --git a/test/settings_http_test.go b/test/settings_http_test.go index 934ce36..f5b20a4 100644 --- a/test/settings_http_test.go +++ b/test/settings_http_test.go @@ -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") diff --git a/test/smtp_mailer_test.go b/test/smtp_mailer_test.go new file mode 100644 index 0000000..dae39ad --- /dev/null +++ b/test/smtp_mailer_test.go @@ -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" ` { + t.Errorf("unexpected From %q", got) + } + if got := message.Header.Get("To"); got != "" { + 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())) + } + }) + } +} diff --git a/test/smtp_server_support_test.go b/test/smtp_server_support_test.go new file mode 100644 index 0000000..bb65038 --- /dev/null +++ b/test/smtp_server_support_test.go @@ -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, ".")) + } +} diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go index ba028d7..8349ff8 100644 --- a/test/stale_token_invalidation_http_test.go +++ b/test/stale_token_invalidation_http_test.go @@ -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"}) diff --git a/test/trusted_proxy_client_ip_for_rate_limiting_http_test.go b/test/trusted_proxy_client_ip_for_rate_limiting_http_test.go index 659ac2d..e63d4cf 100644 --- a/test/trusted_proxy_client_ip_for_rate_limiting_http_test.go +++ b/test/trusted_proxy_client_ip_for_rate_limiting_http_test.go @@ -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), diff --git a/test/unverified_users_and_reregistration_test.go b/test/unverified_users_and_reregistration_test.go index 7571740..92766e8 100644 --- a/test/unverified_users_and_reregistration_test.go +++ b/test/unverified_users_and_reregistration_test.go @@ -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() diff --git a/test/update_download_http_test.go b/test/update_download_http_test.go index d99323d..1f692a5 100644 --- a/test/update_download_http_test.go +++ b/test/update_download_http_test.go @@ -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 } diff --git a/test/update_http_test.go b/test/update_http_test.go index 542a883..1f79b0f 100644 --- a/test/update_http_test.go +++ b/test/update_http_test.go @@ -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) } diff --git a/test/update_restart_http_test.go b/test/update_restart_http_test.go index 7ec4d14..5c3dff4 100644 --- a/test/update_restart_http_test.go +++ b/test/update_restart_http_test.go @@ -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"}) diff --git a/test/user_display_name_test.go b/test/user_display_name_test.go index 07db31c..ddf876f 100644 --- a/test/user_display_name_test.go +++ b/test/user_display_name_test.go @@ -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() diff --git a/test/user_response_field_hygiene_test.go b/test/user_response_field_hygiene_test.go index 8d82f3a..e930310 100644 --- a/test/user_response_field_hygiene_test.go +++ b/test/user_response_field_hygiene_test.go @@ -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) diff --git a/test/user_roles_test.go b/test/user_roles_test.go index f776271..b326b33 100644 --- a/test/user_roles_test.go +++ b/test/user_roles_test.go @@ -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") diff --git a/test/verified_account_required_http_test.go b/test/verified_account_required_http_test.go index cd8ec37..1e89717 100644 --- a/test/verified_account_required_http_test.go +++ b/test/verified_account_required_http_test.go @@ -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 diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index bd5270d..1968bbe 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -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()