208 lines
7.8 KiB
Go
208 lines
7.8 KiB
Go
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,
|
||
|
|
})
|
||
|
|
}
|