refac: reamaining auth surface rewrite, T0

This commit is contained in:
fsylvain committed 2026-09-24 05:11:49 +02:00
1 parent 6d510d2805
commit aa865af2e9
50 files changed
+3723 -149

No files matched your search

+38 -30
View File
@@ -20,21 +20,25 @@ import (
)
type UseCases struct {
Token *jwtauth.JWTAuth
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
UpdateUserUseCase *useCases.UpdateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
DeleteUserUseCase *useCases.DeleteUserUseCase
ListUsersUseCase *useCases.ListUsersUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase
Token *jwtauth.JWTAuth
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
UpdateUserUseCase *useCases.UpdateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
VerifyUserUseCase *useCases.VerifyUserUseCase
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
DeleteUserUseCase *useCases.DeleteUserUseCase
ListUsersUseCase *useCases.ListUsersUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase
}
type Repositories struct {
@@ -73,21 +77,25 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
return &UseCases{
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
UpdateUserUseCase: useCases.NewUpdateUserUseCase(repos.UserRepo, hasher),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(repos.UserRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
UpdateUserUseCase: useCases.NewUpdateUserUseCase(repos.UserRepo, hasher),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(repos.UserRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
}
}
+27 -16
View File
@@ -2,6 +2,7 @@ package article
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
@@ -13,15 +14,15 @@ import (
"strconv"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateArticleUseCase
GetUseCase *useCases.GetArticleUseCase
ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
CreateUseCase *useCases.CreateArticleUseCase
GetUseCase *useCases.GetArticleUseCase
ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
}
type PostArticle struct {
@@ -29,13 +30,14 @@ type PostArticle struct {
Body string `json:"body" validate:"required,max=10000"`
}
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler {
return &Handler{
CreateUseCase: create,
GetUseCase: get,
ListUseCase: list,
DeleteUseCase: delete,
UpdateUseCase: update,
CreateUseCase: create,
GetUseCase: get,
ListUseCase: list,
DeleteUseCase: delete,
UpdateUseCase: update,
GetUserUseCase: getUser,
}
}
@@ -184,15 +186,24 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
h.setIsOnline(w, r, false)
}
func requestHasValidJwt(r *http.Request) bool {
token, _, err := jwtauth.FromContext(r.Context())
return token != nil && err == nil
func (h *Handler) callerIsVerified(r *http.Request) bool {
userId, ok := claims.UserId(r)
if !ok {
return false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
return false
}
return currentUser.IsVerified
}
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
var articles []domain.Article
var err error
if requestHasValidJwt(r) {
if h.callerIsVerified(r) {
articles, err = h.ListUseCase.ListAllArticles()
} else {
articles, err = h.ListUseCase.ListOnlineArticles()
+148 -28
View File
@@ -2,6 +2,7 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
@@ -14,12 +15,16 @@ import (
)
type Handler struct {
Token *jwtauth.JWTAuth
GetUseCase *useCases.GetUserUseCase
ListUseCase *useCases.ListUsersUseCase
CreateUseCase *useCases.CreateUserUseCase
AuthenticateUseCase *useCases.AuthenticateUserUseCase
IsProduction bool
Token *jwtauth.JWTAuth
GetUseCase *useCases.GetUserUseCase
ListUseCase *useCases.ListUsersUseCase
CreateUseCase *useCases.CreateUserUseCase
AuthenticateUseCase *useCases.AuthenticateUserUseCase
VerifyUseCase *useCases.VerifyUserUseCase
CancelUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
IsProduction bool
}
type RegisterCredentials struct {
@@ -33,6 +38,20 @@ type LoginCredentials struct {
Password string `json:"password" validate:"required,min=8"`
}
type VerifyCredentials struct {
Code string `json:"code" validate:"required"`
}
type RequestPasswordResetCredentials struct {
Email string `json:"email" validate:"required,email"`
}
type ResetPasswordCredentials struct {
Email string `json:"email" validate:"required,email"`
Code string `json:"code" validate:"required"`
Password string `json:"password" validate:"required,min=8"`
}
func (h *Handler) removeJwtCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "jwt",
@@ -45,14 +64,18 @@ func (h *Handler) removeJwtCookie(w http.ResponseWriter) {
})
}
func NewHandler(token *jwtauth.JWTAuth, get *useCases.GetUserUseCase, list *useCases.ListUsersUseCase, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *Handler {
func NewHandler(token *jwtauth.JWTAuth, get *useCases.GetUserUseCase, list *useCases.ListUsersUseCase, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, verify *useCases.VerifyUserUseCase, cancel *useCases.CancelRegistrationUseCase, requestPasswordReset *useCases.RequestPasswordResetUseCase, resetPassword *useCases.ResetPasswordUseCase, isProduction bool) *Handler {
return &Handler{
Token: token,
GetUseCase: get,
ListUseCase: list,
CreateUseCase: create,
AuthenticateUseCase: authenticate,
IsProduction: isProduction,
Token: token,
GetUseCase: get,
ListUseCase: list,
CreateUseCase: create,
AuthenticateUseCase: authenticate,
VerifyUseCase: verify,
CancelUseCase: cancel,
RequestPasswordResetUseCase: requestPasswordReset,
ResetPasswordUseCase: resetPassword,
IsProduction: isProduction,
}
}
@@ -87,21 +110,6 @@ func (h *Handler) IsLoggedIn(r *http.Request) bool {
return token != nil && err == nil
}
func (h *Handler) IsVerified(r *http.Request) bool {
token, err := jwtauth.VerifyRequest(
h.Token,
r,
jwtauth.TokenFromCookie,
jwtauth.TokenFromHeader,
jwtauth.TokenFromQuery)
if err != nil {
return false
}
userId := token.PrivateClaims()["user_id"].(float64)
currentUser, _ := h.GetUseCase.GetUser(uint32(userId))
return currentUser.IsVerified
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var credentials LoginCredentials
@@ -163,6 +171,118 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(message)
}
func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
var credentials VerifyCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
verifiedUser, err := h.VerifyUseCase.Verify(userId, credentials.Code)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if errors.Is(err, user.ErrInvalidVerificationCode) || errors.Is(err, user.ErrVerificationCodeExpired) {
http.Error(w, apperr.VerificationFailedErrorMessage, http.StatusForbidden)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, _ := json.Marshal(verifiedUser)
_, _ = w.Write(body)
}
func (h *Handler) CancelRegistration(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
err := h.CancelUseCase.CancelRegistration(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if errors.Is(err, user.ErrAlreadyVerified) {
http.Error(w, apperr.AlreadyVerifiedErrorMessage, http.StatusForbidden)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.removeJwtCookie(w)
message, _ := json.Marshal(map[string]any{"message": "Registration cancelled! Account deleted and HTTPonly jwt cookie cleared"})
_, _ = w.Write(message)
}
func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
var credentials RequestPasswordResetCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = h.RequestPasswordResetUseCase.RequestPasswordReset(credentials.Email)
message, _ := json.Marshal(map[string]any{"message": apperr.PasswordResetRequestedMessage})
_, _ = w.Write(message)
}
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
var credentials ResetPasswordCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = h.ResetPasswordUseCase.ResetPassword(credentials.Email, credentials.Code, credentials.Password)
if err != nil {
if errors.Is(err, user.ErrInvalidPasswordResetCode) || errors.Is(err, user.ErrPasswordResetCodeExpired) {
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
message, _ := json.Marshal(map[string]any{"message": "Your password has been reset. You can now log in."})
_, _ = w.Write(message)
}
func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) {
h.removeJwtCookie(w)
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
+37 -1
View File
@@ -1,6 +1,13 @@
package middleware
import "net/http"
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
)
func JsonContentTypeMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -8,3 +15,32 @@ func JsonContentTypeMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
currentUser, err := getUser.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !currentUser.IsVerified {
http.Error(w, apperr.VerificationRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+6
View File
@@ -7,3 +7,9 @@ const ArticleNotFoundErrorMessage = "Article not found"
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
const RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
const UnauthorizedErrorMessage = "You must be logged in to access this resource."
const VerificationFailedErrorMessage = "The verification code is invalid or has expired."
const AlreadyVerifiedErrorMessage = "This account is already verified and cannot be cancelled this way."
const VerificationRequiredErrorMessage = "You must verify your account to access this resource."
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
+26
View File
@@ -0,0 +1,26 @@
package claims
import (
"net/http"
"github.com/go-chi/jwtauth/v5"
)
func UserId(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return 0, false
}
rawUserId, ok := claims["user_id"]
if !ok {
return 0, false
}
userIdFloat, ok := rawUserId.(float64)
if !ok {
return 0, false
}
return uint32(userIdFloat), true
}
+11 -2
View File
@@ -31,7 +31,7 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
func NewArticleRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase)
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase)
r.Group(func(r chi.Router) {
@@ -39,6 +39,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/{id}", articleHandler.GetArticle)
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -53,11 +54,19 @@ func NewArticleRouter(container *UseCases) http.Handler {
func NewAuthRouter(container *UseCases, isProduction bool) http.Handler {
r := chi.NewRouter()
authHandler := auth.NewHandler(container.Token, container.GetUserUseCase, container.ListUsersUseCase, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
authHandler := auth.NewHandler(container.Token, container.GetUserUseCase, container.ListUsersUseCase, container.CreateUserUseCase, container.AuthenticateUserUseCase, container.VerifyUserUseCase, container.CancelRegistrationUseCase, container.RequestPasswordResetUseCase, container.ResetPasswordUseCase, isProduction)
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
r.Post("/logout", authHandler.Logout)
r.Post("/password-reset", authHandler.RequestPasswordReset)
r.Post("/password-reset/confirm", authHandler.ResetPassword)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Post("/verify", authHandler.Verify)
r.Delete("/register", authHandler.CancelRegistration)
})
return r
}