Files
Tim Wundenberg 6c92206b3c
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m5s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m36s
fix(observabillity): propagate ctx to every log call and add resource to logging
2025-06-17 12:59:43 +02:00

407 lines
11 KiB
Go

package handler
import (
"errors"
"log/slog"
"net/http"
"net/url"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template/auth"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"time"
)
type Auth interface {
Handle(router *http.ServeMux)
}
type AuthImpl struct {
service service.Auth
render *Render
}
func NewAuth(service service.Auth, render *Render) Auth {
return AuthImpl{
service: service,
render: render,
}
}
func (handler AuthImpl) Handle(router *http.ServeMux) {
router.Handle("GET /auth/signin", handler.handleSignInPage())
router.Handle("POST /api/auth/signin", handler.handleSignIn())
router.Handle("/auth/signup", handler.handleSignUpPage())
router.Handle("/auth/verify", handler.handleSignUpVerifyPage())
router.Handle("/api/auth/verify-resend", handler.handleVerifyResendComp())
router.Handle("/auth/verify-email", handler.handleSignUpVerifyResponsePage())
router.Handle("/api/auth/signup", handler.handleSignUp())
router.Handle("POST /api/auth/signout", handler.handleSignOut())
router.Handle("/auth/delete-account", handler.handleDeleteAccountPage())
router.Handle("/api/auth/delete-account", handler.handleDeleteAccountComp())
router.Handle("GET /auth/change-password", handler.handleChangePasswordPage())
router.Handle("POST /api/auth/change-password", handler.handleChangePasswordComp())
router.Handle("GET /auth/forgot-password", handler.handleForgotPasswordPage())
router.Handle("POST /api/auth/forgot-password", handler.handleForgotPasswordComp())
router.Handle("POST /api/auth/forgot-password-actual", handler.handleForgotPasswordResponseComp())
}
var (
securityWaitDuration = 250 * time.Millisecond
)
func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user != nil {
if !user.EmailVerified {
utils.DoRedirect(w, r, "/auth/verify")
} else {
utils.DoRedirect(w, r, "/")
}
return
}
comp := auth.SignInOrUpComp(true)
handler.render.RenderLayout(r, w, comp, nil)
}
}
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) {
session := middleware.GetSession(r)
email := r.FormValue("email")
password := r.FormValue("password")
session, user, err := handler.service.SignIn(r.Context(), session, email, password)
if err != nil {
return nil, err
}
cookie := middleware.CreateSessionCookie(session.Id)
http.SetCookie(w, &cookie)
return user, nil
})
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
} else {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
}
return
}
if user.EmailVerified {
utils.DoRedirect(w, r, "/")
} else {
utils.DoRedirect(w, r, "/auth/verify")
}
}
}
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user != nil {
if !user.EmailVerified {
utils.DoRedirect(w, r, "/auth/verify")
} else {
utils.DoRedirect(w, r, "/")
}
return
}
signUpComp := auth.SignInOrUpComp(false)
handler.render.RenderLayout(r, w, signUpComp, nil)
}
}
func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
if user.EmailVerified {
utils.DoRedirect(w, r, "/")
return
}
signIn := auth.VerifyComp()
handler.render.RenderLayout(r, w, signIn, user)
}
}
func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
_, err := w.Write([]byte("<p class=\"mt-8\">Verification email sent</p>"))
if err != nil {
slog.ErrorContext(r.Context(), "Could not write response", "err", err)
}
}
}
func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
token := r.URL.Query().Get("token")
err := handler.service.VerifyUserEmail(r.Context(), token)
isVerified := err == nil
comp := auth.VerifyResponseComp(isVerified)
var status int
if isVerified {
status = http.StatusOK
} else {
status = http.StatusBadRequest
}
handler.render.RenderLayoutWithStatus(r, w, comp, nil, status)
}
}
func (handler AuthImpl) handleSignUp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
var email = r.FormValue("email")
var password = r.FormValue("password")
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
slog.InfoContext(r.Context(), "signing up", "email", email)
user, err := handler.service.SignUp(r.Context(), email, password)
if err != nil {
return nil, err
}
slog.InfoContext(r.Context(), "Sending verification email", "to", user.Email)
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
return nil, nil
})
if err != nil {
switch {
case errors.Is(err, types.ErrInternal):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
return
case errors.Is(err, service.ErrInvalidEmail):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest)
return
case errors.Is(err, service.ErrInvalidPassword):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
return
}
// If err is "service.ErrAccountExists", then just continue
}
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
}
}
func (handler AuthImpl) handleSignOut() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
session := middleware.GetSession(r)
if session != nil {
err := handler.service.SignOut(r.Context(), session.Id)
if err != nil {
http.Error(w, "An error occurred", http.StatusInternalServerError)
return
}
}
c := http.Cookie{
Name: "id",
Value: "",
MaxAge: -1,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
}
http.SetCookie(w, &c)
utils.DoRedirect(w, r, "/")
}
}
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
comp := auth.DeleteAccountComp()
handler.render.RenderLayout(r, w, comp, user)
}
}
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
password := r.FormValue("password")
err := handler.service.DeleteAccount(r.Context(), user, password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
} else {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
}
return
}
utils.DoRedirect(w, r, "/")
}
}
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
isPasswordReset := r.URL.Query().Has("token")
user := middleware.GetUser(r)
if user == nil && !isPasswordReset {
utils.DoRedirect(w, r, "/auth/signin")
return
}
comp := auth.ChangePasswordComp(isPasswordReset)
handler.render.RenderLayout(r, w, comp, user)
}
}
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
session := middleware.GetSession(r)
user := middleware.GetUser(r)
if session == nil || user == nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
return
}
currPass := r.FormValue("current-password")
newPass := r.FormValue("new-password")
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
return
}
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
}
}
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user != nil {
utils.DoRedirect(w, r, "/")
return
}
comp := auth.ResetPasswordComp()
handler.render.RenderLayout(r, w, comp, user)
}
}
func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
email := r.FormValue("email")
if email == "" {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
return
}
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
err := handler.service.SendForgotPasswordMail(r.Context(), email)
return nil, err
})
if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} else {
utils.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
}
}
}
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
if err != nil {
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return
}
token := pageUrl.Query().Get("token")
newPass := r.FormValue("new-password")
err = handler.service.ForgotPassword(r.Context(), token, newPass)
if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
} else {
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
}
}
}