first commit

This commit is contained in:
2026-07-16 10:13:46 +03:30
commit 423c528b2f
42 changed files with 7298 additions and 0 deletions
+87
View File
@@ -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
}
+32
View File
@@ -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
}
+66
View File
@@ -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
}
+207
View File
@@ -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,
})
}
+16
View File
@@ -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"})
}
+209
View File
@@ -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
}
+28
View File
@@ -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})
}
+43
View File
@@ -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)
}
+71
View File
@@ -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),
)
})
}
}
+92
View File
@@ -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"}`))
}
+24
View File
@@ -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
}
+111
View File
@@ -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
}
+37
View File
@@ -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,
}
}
+121
View File
@@ -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
}
+10
View File
@@ -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"
+74
View File
@@ -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
}