first commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// Package config is responsible for one thing only: reading configuration
|
||||
// from environment variables and handing back a single, typed Config struct
|
||||
// that the rest of the app can use.
|
||||
//
|
||||
// Why centralize this instead of calling os.Getenv() all over the codebase?
|
||||
// - One place to see every setting the app needs.
|
||||
// - One place to define sane defaults for local development.
|
||||
// - Easy to swap the *source* later (e.g. read from a file, from Vault,
|
||||
// from AWS Secrets Manager) without touching any other package.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds every piece of runtime configuration the application needs.
|
||||
// It is built once, in main(), and then passed down (by value - it's a
|
||||
// small, read-only struct) into whichever package needs it: the router,
|
||||
// the database connector, the session manager, the OAuth config, etc.
|
||||
type Config struct {
|
||||
// Port is the TCP port the HTTP server listens on.
|
||||
Port string
|
||||
// Env distinguishes "development" from "production". Currently used
|
||||
// to decide whether the session cookie requires HTTPS (Secure flag).
|
||||
Env string
|
||||
|
||||
// --- MySQL connection settings ---
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
|
||||
// --- Redis connection settings (used for session storage) ---
|
||||
RedisAddr string
|
||||
|
||||
// --- Google OAuth2 settings ---
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GoogleRedirectURL string
|
||||
|
||||
// AllowedOrigins is the CORS allowlist: which frontend origins are
|
||||
// permitted to call this API from browser JavaScript.
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// Load reads every setting from the process environment, falling back to
|
||||
// sensible local-development defaults when a variable isn't set. In
|
||||
// production you would set all of these explicitly (e.g. via
|
||||
// docker-compose "environment:", a systemd unit, or your orchestrator's
|
||||
// secret/config mechanism) rather than relying on the defaults.
|
||||
func Load() Config {
|
||||
return Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
Env: getEnv("ENV", "development"),
|
||||
|
||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "root"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "devpass"),
|
||||
DBName: getEnv("DB_NAME", "go_simple_api"),
|
||||
|
||||
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
|
||||
|
||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
|
||||
|
||||
// ALLOWED_ORIGINS is a comma-separated list, e.g.:
|
||||
// ALLOWED_ORIGINS=http://localhost:3000,https://myapp.com
|
||||
// strings.Split on a single-value default still works fine and
|
||||
// yields a one-element slice.
|
||||
AllowedOrigins: strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv reads a single environment variable, returning fallback if it's
|
||||
// unset or empty. It's unexported (lowercase) because nothing outside this
|
||||
// package needs to read raw env vars directly - everyone else should go
|
||||
// through the Config struct instead.
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Migrate creates the tables this application needs, if they don't already
|
||||
// exist. This is intentionally the simplest possible approach
|
||||
// (CREATE TABLE IF NOT EXISTS run on every startup) and is fine for a
|
||||
// learning project with a single table.
|
||||
//
|
||||
// For a real production project you'd want a proper migration tool (e.g.
|
||||
// golang-migrate/migrate) that tracks a version number, supports
|
||||
// incremental "up"/"down" migrations, and can safely evolve a schema that
|
||||
// already has production data in it. This function is a deliberate
|
||||
// shortcut around that complexity for now.
|
||||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL DEFAULT '',
|
||||
google_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate users table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package database owns everything related to talking to MySQL: opening
|
||||
// the connection pool and running startup migrations. Nothing outside this
|
||||
// package (and the repositories in internal/models) should import the
|
||||
// MySQL driver directly - that keeps the database technology an
|
||||
// implementation detail hidden behind a plain *sql.DB.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// Blank import: we never call anything from this package by name.
|
||||
// Importing it purely for its side effect - its init() function
|
||||
// registers "mysql" as a driver name with database/sql, which is what
|
||||
// lets sql.Open("mysql", ...) below work at all.
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||
)
|
||||
|
||||
// NewMySQL opens a connection pool to MySQL, configures reasonable pool
|
||||
// limits, and verifies connectivity with a real ping before returning -
|
||||
// so a bad connection string / unreachable database fails loudly at
|
||||
// startup instead of silently on the first real query.
|
||||
func NewMySQL(ctx context.Context, cfg config.Config) (*sql.DB, error) {
|
||||
// DSN = Data Source Name. Format: user:password@tcp(host:port)/dbname?options
|
||||
// parseTime=true tells the driver to convert MySQL DATETIME/TIMESTAMP
|
||||
// columns into Go time.Time values automatically.
|
||||
dsn := fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%s)/%s?parseTime=true",
|
||||
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName,
|
||||
)
|
||||
|
||||
// sql.Open does NOT connect yet - it just validates the DSN and
|
||||
// returns a *sql.DB, which represents a pool of connections managed
|
||||
// for you, not a single live connection.
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql: %w", err)
|
||||
}
|
||||
|
||||
// Pool tuning:
|
||||
// MaxOpenConns - hard ceiling on simultaneous connections, protects
|
||||
// the database from being overwhelmed by this app.
|
||||
// MaxIdleConns - how many connections to keep warm/ready even when
|
||||
// idle, so we don't pay reconnect cost constantly.
|
||||
// ConnMaxLifetime - force connections to be recycled periodically,
|
||||
// useful behind load balancers or if MySQL itself
|
||||
// closes long-lived idle connections.
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
// Give the ping a hard deadline instead of letting it hang forever if
|
||||
// the database host is unreachable.
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(pingCtx); err != nil {
|
||||
return nil, fmt.Errorf("ping mysql: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package logging builds the single *slog.Logger instance shared across the
|
||||
// whole application. Every log line the app writes goes through this
|
||||
// logger, formatted as JSON to stdout.
|
||||
//
|
||||
// Why JSON to stdout specifically? This is the standard "12-factor app"
|
||||
// approach to logging: the application doesn't know or care where its logs
|
||||
// end up (a file, Loki, Elasticsearch, ...) - it just writes structured
|
||||
// lines to stdout, and an external agent (in your case, Grafana Alloy)
|
||||
// takes care of collecting, parsing, and shipping them. Because every line
|
||||
// is valid JSON with consistent keys, Alloy/Loki can index and query on
|
||||
// fields like "status", "path", or "request_id" without any custom parsing
|
||||
// rules or regexes.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
// New builds and returns the application's structured logger.
|
||||
//
|
||||
// The minimum log level is controlled by the LOG_LEVEL environment
|
||||
// variable: set LOG_LEVEL=debug to see verbose Debug()-level output;
|
||||
// anything else (or unset) defaults to Info level, which hides Debug logs.
|
||||
func New() *slog.Logger {
|
||||
level := slog.LevelInfo
|
||||
if os.Getenv("LOG_LEVEL") == "debug" {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
// slog.NewJSONHandler formats every log record as a single-line JSON
|
||||
// object and writes it to the given io.Writer (here, os.Stdout).
|
||||
// Swapping this for slog.NewTextHandler would switch to human-readable
|
||||
// text output instead - useful for local dev if you ever want it - but
|
||||
// every other part of the app that calls logger.Info/Error/etc would
|
||||
// stay completely unchanged, since they only depend on *slog.Logger,
|
||||
// not on which Handler is behind it.
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})
|
||||
|
||||
return slog.New(handler)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package middleware contains chi-compatible HTTP middleware: functions
|
||||
// matching the shape func(http.Handler) http.Handler, each wrapping the
|
||||
// next handler in the chain to add some cross-cutting behavior (logging,
|
||||
// authentication, ...) before and/or after the real handler runs.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
// Aliased to chimw so it doesn't collide with this package's own name
|
||||
// ("middleware") when referenced from other files/packages.
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// RequestLogger is a middleware FACTORY: a function that takes the
|
||||
// dependencies it needs (here, just a logger) and returns the actual
|
||||
// middleware function chi expects. This extra layer exists because chi's
|
||||
// r.Use() only accepts func(http.Handler) http.Handler - there's no room
|
||||
// to pass in a logger directly, so we wrap it in an outer function that
|
||||
// captures the logger in a closure instead.
|
||||
//
|
||||
// There are three layers of function here, each running at a different
|
||||
// time:
|
||||
//
|
||||
// RequestLogger(logger) -> runs ONCE, when building the router
|
||||
// func(next http.Handler) ... -> runs ONCE, when chi wires up the chain
|
||||
// func(w, r) { ... } -> runs on EVERY request
|
||||
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Capture the start time BEFORE the request is handled, so we
|
||||
// can measure total duration afterward.
|
||||
start := time.Now()
|
||||
|
||||
// A plain http.ResponseWriter only lets you WRITE a status
|
||||
// code/body - it doesn't let you read back what was written
|
||||
// afterward. WrapResponseWriter adds that: once the handler
|
||||
// below has run, ww.Status() and ww.BytesWritten() become
|
||||
// available. We must pass ww (not w) to next.ServeHTTP so the
|
||||
// wrapping actually captures what gets written downstream.
|
||||
ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
// Run the rest of the middleware chain / the final handler.
|
||||
// Everything above this line happens BEFORE the request is
|
||||
// handled; everything below happens AFTER the response has
|
||||
// been written.
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
// Emit one structured JSON log line per request, with typed
|
||||
// fields (slog.String, slog.Int, slog.Duration) so each one
|
||||
// becomes an independently queryable key once these logs land
|
||||
// in Loki via Grafana Alloy.
|
||||
logger.Info("http_request",
|
||||
// GetReqID reads back the request ID that chi's own
|
||||
// RequestID middleware (registered earlier in the chain,
|
||||
// see router.go) attached to the request's context - this
|
||||
// lets you correlate every log line belonging to one
|
||||
// specific request.
|
||||
slog.String("request_id", chimw.GetReqID(r.Context())),
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int("bytes", ww.BytesWritten()),
|
||||
slog.Duration("duration_ms", time.Since(start)),
|
||||
slog.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
|
||||
)
|
||||
|
||||
// contextKey is a private, unexported type used as the type of our context
|
||||
// key below. This is a well-known Go idiom to avoid key collisions: since
|
||||
// context.WithValue keys are compared by BOTH type and value, using our
|
||||
// own named type (instead of a plain string) guarantees userContextKey can
|
||||
// never accidentally collide with a key defined by another package, even
|
||||
// if the underlying text happened to be identical.
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "current_user"
|
||||
|
||||
// RequireAuth is a middleware factory (same three-layer shape as
|
||||
// RequestLogger) that protects a route: it checks the caller's session for
|
||||
// a logged-in user ID, loads the full user from the database, and - only
|
||||
// if that all succeeds - stores the user in the request's context and lets
|
||||
// the request continue. If anything fails, it responds 401 immediately and
|
||||
// the wrapped handler never runs at all.
|
||||
func RequireAuth(sessions *scs.SessionManager, userRepo *models.UserRepository, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// GetInt returns the zero value (0) if the key was never set
|
||||
// in the session - which is exactly what happens for a
|
||||
// visitor who never logged in.
|
||||
userID := sessions.GetInt(r.Context(), session.UserIDKey)
|
||||
if userID == 0 {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userRepo.FindByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
// Covers both "no such user" (e.g. the account was
|
||||
// deleted after this session was created) and genuine
|
||||
// database errors - either way, this request cannot
|
||||
// proceed as authenticated.
|
||||
logger.Error("require auth: find user failed", "error", err, "user_id", userID)
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
// context.WithValue returns a NEW context wrapping the old
|
||||
// one plus our key/value pair - contexts are immutable, you
|
||||
// can't add to an existing one in place. Similarly,
|
||||
// r.WithContext returns a NEW *http.Request carrying that
|
||||
// context; we pass that new request onward so downstream
|
||||
// handlers can read the user back out.
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentUser lets handlers retrieve the authenticated user that
|
||||
// RequireAuth already loaded and stashed in the request's context.
|
||||
// Handlers never need to know about userContextKey directly (it's
|
||||
// unexported - only this file can create or read that specific key) - they
|
||||
// just call this function.
|
||||
func CurrentUser(r *http.Request) *models.User {
|
||||
// Value() returns `any`, so we need a type assertion to get back a
|
||||
// concrete *models.User. The two-value form (`user, ok := ...`) is the
|
||||
// SAFE version: ok is false if the assertion fails (wrong type, or the
|
||||
// key simply isn't present) instead of panicking - always prefer this
|
||||
// form when the value's presence isn't 100% guaranteed.
|
||||
user, ok := r.Context().Value(userContextKey).(*models.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// writeUnauthorized writes a plain {"error":"unauthorized"} 401 response.
|
||||
// Written by hand (instead of reusing handlers.writeError) because
|
||||
// internal/middleware and internal/handlers are separate packages, and
|
||||
// writeError is unexported in the handlers package - a deliberate package
|
||||
// boundary, not an oversight.
|
||||
func writeUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package models contains the application's domain types (plain data
|
||||
// structs like User) and their repositories (the code that knows how to
|
||||
// load/save them from/to the database).
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// User represents one account in our system. It can be authenticated in
|
||||
// two different ways, both represented on the same row:
|
||||
// - Password login: PasswordHash is set, GoogleID is empty.
|
||||
// - Google login: GoogleID is set, PasswordHash is empty.
|
||||
// - Both: a password user later links a Google account (or
|
||||
// vice versa), and both fields end up populated.
|
||||
//
|
||||
// Deliberately NOT included here: a plaintext Password field. This struct
|
||||
// should never hold a plaintext password in memory beyond the brief moment
|
||||
// it's hashed during registration - see handlers.Register.
|
||||
type User struct {
|
||||
ID int
|
||||
Email string
|
||||
PasswordHash string
|
||||
GoogleID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrUserNotFound is a sentinel error returned by any lookup method below
|
||||
// when no matching row exists. Callers use errors.Is(err, ErrUserNotFound)
|
||||
// to distinguish "not found" (an expected, normal outcome - e.g. checking
|
||||
// if an email is already registered) from a real database failure.
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// UserRepository is the ONLY place in the entire application that writes
|
||||
// SQL queries for the users table. Every handler that needs to read or
|
||||
// write user data goes through here instead of touching *sql.DB directly.
|
||||
//
|
||||
// Why bother with this layer instead of just calling db.Query in handlers?
|
||||
// - If you ever swap MySQL for Postgres (or add a cache in front), you
|
||||
// change this one file - no handler code needs to know or care.
|
||||
// - It gives handlers a small, purpose-built vocabulary (Create,
|
||||
// FindByEmail, FindByID, SetGoogleID) instead of raw SQL strings
|
||||
// scattered across the codebase.
|
||||
type UserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewUserRepository is the constructor. Go doesn't have classes/constructors
|
||||
// built into the language - "NewXxx returns a *Xxx" is just a naming
|
||||
// convention the whole ecosystem follows.
|
||||
func NewUserRepository(db *sql.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new user row. It takes a *User (pointer) specifically so
|
||||
// it can write the newly generated auto-increment ID back into the
|
||||
// caller's struct after the insert succeeds - the caller passes in a User
|
||||
// with ID == 0, and walks away with u.ID populated.
|
||||
func (r *UserRepository) Create(ctx context.Context, u *User) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"INSERT INTO users (email, password_hash, google_id, created_at) VALUES (?, ?, ?, ?)",
|
||||
u.Email, u.PasswordHash, u.GoogleID, time.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get last insert id: %w", err)
|
||||
}
|
||||
u.ID = int(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindByEmail looks up a user by their email address. This is what the
|
||||
// login flow uses: the user submits an email, we look up the matching row,
|
||||
// then compare the submitted password against the stored PasswordHash.
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE email = ?", email,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// sql.ErrNoRows is the driver's own sentinel for "query matched
|
||||
// zero rows". We translate it into OUR sentinel (ErrUserNotFound)
|
||||
// so callers never need to know or care that the underlying
|
||||
// storage is SQL at all.
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user by email: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// FindByID looks up a user by their numeric ID. This is used after a
|
||||
// session is validated: the session only stores the user's ID, so we look
|
||||
// the rest of the user up fresh on every authenticated request (see
|
||||
// middleware.RequireAuth).
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
|
||||
var u User
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE id = ?", id,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user by id: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// SetGoogleID links a Google account to an existing user row - used the
|
||||
// first time a user who originally registered with a password logs in via
|
||||
// "Sign in with Google" using the same email address.
|
||||
func (r *UserRepository) SetGoogleID(ctx context.Context, userID int, googleID string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"UPDATE users SET google_id = ? WHERE id = ?", googleID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set google id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package oauth builds provider-specific OAuth2 configurations. Right now
|
||||
// there's only Google, but the package name is deliberately generic (not
|
||||
// "google") so a second provider (GitHub, Microsoft, ...) could live
|
||||
// alongside it later as its own file/function without a rename.
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||
)
|
||||
|
||||
// NewGoogleConfig builds the *oauth2.Config describing how to talk to
|
||||
// Google's OAuth2 system: our app's identity (ClientID/ClientSecret),
|
||||
// where Google should redirect the user back to after login
|
||||
// (RedirectURL - this MUST exactly match what's registered in the Google
|
||||
// Cloud Console), what permission we're requesting (Scopes), and which
|
||||
// provider's specific auth/token URLs to use (Endpoint).
|
||||
func NewGoogleConfig(cfg config.Config) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: cfg.GoogleClientID,
|
||||
ClientSecret: cfg.GoogleClientSecret,
|
||||
RedirectURL: cfg.GoogleRedirectURL,
|
||||
|
||||
// We only ask for the user's email - the minimum needed to
|
||||
// identify/create an account. Requesting more scopes than you
|
||||
// actually need is both a privacy and a security smell.
|
||||
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
|
||||
|
||||
// google.Endpoint is a predefined value from
|
||||
// golang.org/x/oauth2/google pointing at Google's real
|
||||
// authorization and token URLs - we never hardcode those
|
||||
// ourselves.
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package router is where the whole application gets wired together: it
|
||||
// receives already-constructed shared dependencies (logger, db, sessions,
|
||||
// config) from main.go, builds the handlers and middleware that need them,
|
||||
// and registers every route on a chi.Mux.
|
||||
package router
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/httprate"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
|
||||
"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/oauth"
|
||||
)
|
||||
|
||||
// New builds and returns the fully configured chi router for the
|
||||
// application. *chi.Mux implements http.Handler (it has a ServeHTTP
|
||||
// method), so the return value can be passed directly to http.Server as
|
||||
// its Handler - see cmd/api/main.go.
|
||||
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// --- Global middleware stack ---
|
||||
// Order matters here: each middleware wraps everything registered
|
||||
// after it, so requests flow through this list top-to-bottom on the
|
||||
// way in, and bottom-to-top on the way out.
|
||||
|
||||
// Tags every request with a unique ID, retrievable later via
|
||||
// chimw.GetReqID(ctx) - used by our RequestLogger below, and useful
|
||||
// for correlating log lines to one specific request once shipped to
|
||||
// Loki.
|
||||
r.Use(chimw.RequestID)
|
||||
|
||||
// Our own structured JSON request logger (internal/middleware),
|
||||
// replacing chi's built-in plain-text Logger.
|
||||
r.Use(middleware.RequestLogger(logger))
|
||||
|
||||
// Recovers from panics in any handler, returning a 500 instead of
|
||||
// crashing the whole server process for one bad request.
|
||||
r.Use(chimw.Recoverer)
|
||||
|
||||
// Cancels a request's context if it runs longer than this, so a slow
|
||||
// downstream call (e.g. a hung database query) can't hold a
|
||||
// connection open forever.
|
||||
r.Use(chimw.Timeout(60 * time.Second))
|
||||
|
||||
// CORS: controls which browser-based frontends (running on a
|
||||
// different origin than this API) are allowed to call it with
|
||||
// credentials (cookies) attached. This does NOT protect against
|
||||
// non-browser callers (curl, mobile apps, server-to-server) - CORS is
|
||||
// a browser-enforced rule, not a server-side security boundary.
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: cfg.AllowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
AllowCredentials: true, // required for the session cookie to be sent cross-origin
|
||||
}))
|
||||
|
||||
// A generous, global rate limit - mostly a safety net against
|
||||
// runaway scripts/bots hitting the API in general. 100 requests per
|
||||
// IP per minute.
|
||||
r.Use(httprate.LimitByIP(100, time.Minute))
|
||||
|
||||
// scs's own middleware: loads session data (from Redis) into the
|
||||
// request's context before the handler runs, and saves any changes
|
||||
// back (plus sets/refreshes the cookie) after the handler finishes.
|
||||
// Every route below this line can use sessions.Get/Put/Destroy.
|
||||
r.Use(sessions.LoadAndSave)
|
||||
|
||||
// --- Public routes ---
|
||||
|
||||
r.Get("/health", handlers.Health)
|
||||
|
||||
userRepo := models.NewUserRepository(db)
|
||||
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
|
||||
requireAuth := middleware.RequireAuth(sessions, userRepo, logger)
|
||||
|
||||
// Register/Login get a MUCH stricter rate limit than the global one,
|
||||
// since these are exactly the endpoints a credential-stuffing /
|
||||
// brute-force script would target: 5 requests per IP per minute.
|
||||
// r.Group scopes this middleware to only the routes registered inside
|
||||
// the closure - it does not affect any route registered outside it.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httprate.LimitByIP(5, time.Minute))
|
||||
r.Post("/register", authHandler.Register)
|
||||
r.Post("/login", authHandler.Login)
|
||||
})
|
||||
|
||||
// Deliberately OUTSIDE the strict-rate-limit group above - we don't
|
||||
// want to rate-limit a legitimate logged-in user trying to log out.
|
||||
r.Post("/logout", authHandler.Logout)
|
||||
|
||||
// --- Protected routes ---
|
||||
// Every route registered inside this group first passes through
|
||||
// requireAuth; if the caller isn't authenticated, requireAuth responds
|
||||
// 401 and the route handler never runs at all. Add future
|
||||
// authenticated-only routes inside this same group.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requireAuth)
|
||||
r.Get("/me", authHandler.Me)
|
||||
})
|
||||
|
||||
// --- Google OAuth2 routes ---
|
||||
|
||||
googleConfig := oauth.NewGoogleConfig(cfg)
|
||||
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
|
||||
|
||||
r.Get("/auth/google/login", googleHandler.Login)
|
||||
r.Get("/auth/google/callback", googleHandler.Callback)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package session
|
||||
|
||||
// UserIDKey is the string key under which we store the logged-in user's
|
||||
// numeric ID inside the session (via sessions.Put(ctx, UserIDKey, id)).
|
||||
//
|
||||
// This constant exists so the key is spelled exactly once, in one place -
|
||||
// every other file that touches this key (login, logout, the auth
|
||||
// middleware) imports this constant instead of retyping the raw string
|
||||
// "user_id", which would risk a typo silently breaking authentication.
|
||||
const UserIDKey = "user_id"
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package session builds and configures the application's server-side
|
||||
// session manager. We use github.com/alexedwards/scs (SCS) for the session
|
||||
// framework itself, backed by Redis for storage.
|
||||
//
|
||||
// "Server-side session" means: the browser only ever holds a random,
|
||||
// meaningless token in a cookie. All the actual session DATA (which user
|
||||
// is logged in, etc.) lives in Redis, keyed by that token. This is in
|
||||
// contrast to storing data directly inside a signed/encrypted cookie -
|
||||
// server-side sessions can be instantly revoked (just delete the Redis
|
||||
// key), don't grow the cookie size as you store more data, and never
|
||||
// expose their contents to the browser at all.
|
||||
package session
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/redisstore"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/gomodule/redigo/redis"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||
)
|
||||
|
||||
// New builds a *scs.SessionManager wired up to Redis, with cookie settings
|
||||
// appropriate for an authentication system.
|
||||
func New(cfg config.Config) *scs.SessionManager {
|
||||
// redigo's connection pool - conceptually identical to *sql.DB's pool
|
||||
// from the database package, just for Redis instead of MySQL. Dial is
|
||||
// a function the pool calls whenever it needs to open a new
|
||||
// connection.
|
||||
pool := &redis.Pool{
|
||||
MaxIdle: 10,
|
||||
Dial: func() (redis.Conn, error) {
|
||||
return redis.Dial("tcp", cfg.RedisAddr)
|
||||
},
|
||||
}
|
||||
|
||||
manager := scs.New()
|
||||
|
||||
// By default scs stores session data IN MEMORY, which would be lost on
|
||||
// every restart and wouldn't work at all if you ever ran more than one
|
||||
// instance of this app behind a load balancer. Setting .Store swaps
|
||||
// the storage backend to Redis while keeping the exact same
|
||||
// Put/Get/Destroy API - the rest of the app never needs to know Redis
|
||||
// is involved at all.
|
||||
manager.Store = redisstore.New(pool)
|
||||
|
||||
// How long a session stays valid after creation.
|
||||
manager.Lifetime = 24 * time.Hour
|
||||
|
||||
// --- Cookie security settings ---
|
||||
manager.Cookie.Name = "session_id"
|
||||
|
||||
// HttpOnly: JavaScript in the browser cannot read this cookie via
|
||||
// document.cookie. Blocks a large class of XSS-based session theft.
|
||||
manager.Cookie.HttpOnly = true
|
||||
|
||||
// SameSite=Lax: the cookie IS sent on normal top-level navigation
|
||||
// (e.g. clicking a link to this site, or Google redirecting back to
|
||||
// our OAuth callback), but is NOT sent on cross-site requests
|
||||
// triggered by another page (e.g. a malicious auto-submitting <form>
|
||||
// pointed at our /logout). This is our primary CSRF defense for the
|
||||
// cookie-based session.
|
||||
manager.Cookie.SameSite = http.SameSiteLaxMode
|
||||
|
||||
// Secure: when true, the browser will refuse to send this cookie over
|
||||
// plain HTTP, only HTTPS. We only turn this on in production, because
|
||||
// enabling it during local development (over http://localhost) would
|
||||
// silently break the cookie entirely.
|
||||
manager.Cookie.Secure = cfg.Env == "production"
|
||||
|
||||
return manager
|
||||
}
|
||||
Reference in New Issue
Block a user