package handlers import ( "encoding/json" "errors" "log/slog" "net/http" "github.com/alexedwards/scs/v2" "golang.org/x/crypto/bcrypt" "git.hamidsoltani.com/hamid/go-simple-api/internal/middleware" "git.hamidsoltani.com/hamid/go-simple-api/internal/models" "git.hamidsoltani.com/hamid/go-simple-api/internal/session" ) // AuthHandler groups every password-authentication-related handler // (Register, Login, Logout, Me) together, and holds the dependencies they // all share as struct fields: the user repository (to read/write users), // the session manager (to start/end sessions), and the logger. // // This is Go's version of "dependency injection": instead of handlers // reaching for global variables, every dependency they need is explicit, // passed in once at construction time via NewAuthHandler, and stored on // the struct. That makes each handler's requirements obvious from the // struct definition, and makes the whole thing straightforward to test // later (swap in a fake UserRepository, etc). type AuthHandler struct { userRepo *models.UserRepository sessions *scs.SessionManager logger *slog.Logger } // NewAuthHandler is the constructor - see the same NewXxx convention used // throughout this project (NewUserRepository, NewMySQL, ...). func NewAuthHandler(userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *AuthHandler { return &AuthHandler{userRepo: userRepo, sessions: sessions, logger: logger} } // registerRequest is the expected JSON body for POST /register. // It's intentionally a separate, small struct from models.User - the wire // format of an API request should not be tightly coupled to the database // model. For example, a register request should never be able to set // PasswordHash or ID directly. type registerRequest struct { Email string `json:"email"` Password string `json:"password"` } // Register handles POST /register: creates a new user account with a // bcrypt-hashed password. func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { var req registerRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Email == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "email and password are required") return } if len(req.Password) < 8 { writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } // Check whether this email is already registered. err == nil means we // FOUND a user - i.e. the email is taken - which is the failure case // here. _, err := h.userRepo.FindByEmail(r.Context(), req.Email) if err == nil { writeError(w, http.StatusConflict, "email already registered") return } // Any error OTHER than "not found" is unexpected (e.g. the database is // down) and deserves a 500 + a log line, not a generic 400. if !errors.Is(err, models.ErrUserNotFound) { h.logger.Error("find user by email failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } // bcrypt.GenerateFromPassword hashes the password with a random salt // baked into the output, using DefaultCost rounds of internal hashing // (intentionally slow, to resist brute-force attacks). We NEVER store // the plaintext password anywhere past this point. hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { h.logger.Error("hash password failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } user := &models.User{ Email: req.Email, PasswordHash: string(hash), } if err := h.userRepo.Create(r.Context(), user); err != nil { h.logger.Error("create user failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusCreated, map[string]any{ "id": user.ID, "email": user.Email, }) } // loginRequest is the expected JSON body for POST /login. type loginRequest struct { Email string `json:"email"` Password string `json:"password"` } // Login handles POST /login: verifies email + password, and if correct, // starts a new server-side session for the user. func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } user, err := h.userRepo.FindByEmail(r.Context(), req.Email) if errors.Is(err, models.ErrUserNotFound) { // Deliberately the SAME generic message as a wrong password below. // If we said "no such email" here and something different for a // bad password, an attacker could use that difference to figure // out which emails are registered (an "enumeration" attack). writeError(w, http.StatusUnauthorized, "invalid email or password") return } if err != nil { h.logger.Error("find user by email failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } // bcrypt.CompareHashAndPassword re-derives the hash using the salt // embedded in the stored hash, and compares. This is the ONLY correct // way to check a password - there is no way to "unhash" it back to // plaintext, which is the entire point. if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { writeError(w, http.StatusUnauthorized, "invalid email or password") return } // Session fixation defense: issue a brand new session token now that // the user's privilege level is about to change (anonymous -> // authenticated), while keeping any existing session data intact. // This should be called right before any privilege change (login here; // the same applies to e.g. password changes). if err := h.sessions.RenewToken(r.Context()); err != nil { h.logger.Error("renew token failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } // We store ONLY the user's ID in the session - not their email or any // other data. Everything else about the user is looked up fresh from // the database whenever needed (see Me, and middleware.RequireAuth), // which avoids ever serving stale cached user data from the session. h.sessions.Put(r.Context(), session.UserIDKey, user.ID) writeJSON(w, http.StatusOK, map[string]any{ "id": user.ID, "email": user.Email, }) } // Logout handles POST /logout: destroys the current session, which both // deletes the session data from Redis and tells the browser (via response // headers) to remove the session cookie. func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { if err := h.sessions.Destroy(r.Context()); err != nil { h.logger.Error("destroy session failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) } // Me handles GET /me: returns the currently authenticated user. // // Note this handler does NOT check the session itself - that work is done // once, generically, by middleware.RequireAuth, which is applied to this // route in router.go. By the time Me runs, the user has already been // looked up and stashed in the request's context; Me just reads it back // out via middleware.CurrentUser. func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { user := middleware.CurrentUser(r) if user == nil { // Defensive fallback only - this should never actually trigger as // long as RequireAuth is correctly applied to this route in the // router. It protects against a future refactor accidentally // wiring this handler up without the middleware. writeError(w, http.StatusUnauthorized, "not logged in") return } writeJSON(w, http.StatusOK, map[string]any{ "id": user.ID, "email": user.Email, }) }