first commit
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package handlers contains all HTTP handlers - the functions/methods that
|
||||
// actually receive a request and write a response. Handlers are kept thin:
|
||||
// they parse input, call into repositories/other packages to do real work,
|
||||
// and format the output. Business logic that isn't purely "HTTP plumbing"
|
||||
// generally belongs elsewhere (e.g. in the models package).
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Health is a simple liveness check endpoint: GET /health.
|
||||
// Useful for load balancers, container orchestrators (Docker/Kubernetes),
|
||||
// and uptime monitors to confirm the process is up and responding, without
|
||||
// needing to touch the database or any other dependency.
|
||||
func Health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
|
||||
)
|
||||
|
||||
// GoogleOAuthHandler implements "Sign in with Google" using the OAuth2
|
||||
// Authorization Code flow:
|
||||
//
|
||||
// 1. GET /auth/google/login - we redirect the browser to Google.
|
||||
// 2. User logs into Google and approves access, entirely on Google's own
|
||||
// site - our server is not involved in that step at all.
|
||||
// 3. GET /auth/google/callback - Google redirects the browser back to us
|
||||
// with a temporary ?code=..., which we exchange (server-to-server,
|
||||
// never visible to the browser) for an access token, then use that
|
||||
// token to ask Google who the user is.
|
||||
type GoogleOAuthHandler struct {
|
||||
config *oauth2.Config
|
||||
userRepo *models.UserRepository
|
||||
sessions *scs.SessionManager
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewGoogleOAuthHandler(config *oauth2.Config, userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *GoogleOAuthHandler {
|
||||
return &GoogleOAuthHandler{config: config, userRepo: userRepo, sessions: sessions, logger: logger}
|
||||
}
|
||||
|
||||
// oauthStateSessionKey is where we temporarily stash the CSRF-protection
|
||||
// "state" value between the /login redirect and the /callback request.
|
||||
const oauthStateSessionKey = "oauth_state"
|
||||
|
||||
// Login handles GET /auth/google/login: builds Google's consent-screen URL
|
||||
// and redirects the browser there.
|
||||
func (h *GoogleOAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
h.logger.Error("generate oauth state failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// We store "state" in the visitor's session (not a package-level
|
||||
// variable!) so it's correctly scoped per-visitor even with many
|
||||
// concurrent users starting the login flow at the same time. It
|
||||
// survives because the session cookie is already set on the browser
|
||||
// before login - sessions work for anonymous visitors too, they just
|
||||
// don't have a UserIDKey set yet.
|
||||
h.sessions.Put(r.Context(), oauthStateSessionKey, state)
|
||||
|
||||
// AuthCodeURL builds the full URL to Google's consent screen,
|
||||
// embedding our client ID, redirect URL, requested scopes, and state.
|
||||
url := h.config.AuthCodeURL(state)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// Callback handles GET /auth/google/callback: Google redirects the browser
|
||||
// here with ?state=...&code=... after the user approves access.
|
||||
func (h *GoogleOAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
// CSRF protection: confirm the state Google sent back matches the one
|
||||
// WE generated for this specific login attempt. Without this check, an
|
||||
// attacker could craft their own callback link using their own
|
||||
// Google account and trick a victim into using it, potentially linking
|
||||
// the attacker's Google account to the victim's session.
|
||||
expectedState := h.sessions.GetString(r.Context(), oauthStateSessionKey)
|
||||
if expectedState == "" || r.URL.Query().Get("state") != expectedState {
|
||||
writeError(w, http.StatusBadRequest, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
// The state value is single-use - remove it from the session now that
|
||||
// we've validated it, so it can't be replayed.
|
||||
h.sessions.Remove(r.Context(), oauthStateSessionKey)
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing code")
|
||||
return
|
||||
}
|
||||
|
||||
// Server-to-server call to Google: exchange the temporary, single-use
|
||||
// code for a real access token. This request includes our
|
||||
// ClientSecret, proving to Google that it's really our registered
|
||||
// application making the request.
|
||||
token, err := h.config.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
h.logger.Error("oauth exchange failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// h.config.Client returns a regular *http.Client, pre-configured to
|
||||
// automatically attach the access token as an "Authorization: Bearer
|
||||
// ..." header on every request it makes - no manual header handling
|
||||
// needed.
|
||||
client := h.config.Client(r.Context(), token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
if err != nil {
|
||||
h.logger.Error("fetch google userinfo failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("read google userinfo failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
var googleUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &googleUser); err != nil {
|
||||
h.logger.Error("parse google userinfo failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.findOrCreateGoogleUser(r, googleUser.ID, googleUser.Email)
|
||||
if err != nil {
|
||||
h.logger.Error("find or create google user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Same session-fixation defense and "store only the ID" pattern as
|
||||
// the password-based Login handler in auth.go.
|
||||
if err := h.sessions.RenewToken(r.Context()); err != nil {
|
||||
h.logger.Error("renew token failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// findOrCreateGoogleUser links a Google identity to a local user account.
|
||||
// There are three possible cases:
|
||||
//
|
||||
// 1. No user exists with this email yet -> create a brand new user,
|
||||
// Google-only (empty PasswordHash).
|
||||
// 2. A user already exists with this email AND already has this
|
||||
// Google account linked -> just return them.
|
||||
// 3. A user already exists with this email but registered via password
|
||||
// (no GoogleID yet) -> link this Google account to that existing
|
||||
// user, so they can log in either way going forward.
|
||||
func (h *GoogleOAuthHandler) findOrCreateGoogleUser(r *http.Request, googleID, email string) (*models.User, error) {
|
||||
existing, err := h.userRepo.FindByEmail(r.Context(), email)
|
||||
|
||||
if errors.Is(err, models.ErrUserNotFound) {
|
||||
newUser := &models.User{
|
||||
Email: email,
|
||||
GoogleID: googleID,
|
||||
// PasswordHash intentionally left empty - this user can only
|
||||
// log in via Google unless they later set a password (not
|
||||
// implemented in this learning project, but would be a
|
||||
// natural next feature).
|
||||
}
|
||||
if createErr := h.userRepo.Create(r.Context(), newUser); createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
return newUser, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A user with this email already exists. If they haven't linked
|
||||
// Google yet, link it now.
|
||||
if existing.GoogleID == "" {
|
||||
if linkErr := h.userRepo.SetGoogleID(r.Context(), existing.ID, googleID); linkErr != nil {
|
||||
return nil, linkErr
|
||||
}
|
||||
existing.GoogleID = googleID
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// generateState creates a cryptographically random, URL-safe string used
|
||||
// as the OAuth2 "state" CSRF-protection parameter.
|
||||
//
|
||||
// Note this uses crypto/rand, NOT math/rand - crypto/rand is suitable for
|
||||
// security-sensitive randomness (unpredictable even to an attacker who
|
||||
// knows previous outputs), while math/rand is not.
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// writeJSON is a tiny shared helper used by every handler in this package
|
||||
// to avoid repeating the "set Content-Type header, write status code,
|
||||
// encode body as JSON" sequence over and over.
|
||||
//
|
||||
// data is typed `any` (Go's built-in alias for interface{}, since Go 1.18)
|
||||
// so this one function can serialize maps, structs, slices - anything
|
||||
// encoding/json knows how to handle.
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
// Encoder writes JSON directly to the ResponseWriter (which is just an
|
||||
// io.Writer under the hood) - no need to build the JSON bytes in a
|
||||
// separate variable first.
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// writeError is a thin wrapper around writeJSON for the extremely common
|
||||
// case of returning a single {"error": "..."} body.
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
Reference in New Issue
Block a user