Techadamia Backend Documentation

Techadamia Backend - Security Implementation Guide

This document describes the security controls actually implemented in the Techadamia backend, verified against the source. It is meant to be an accurate reference, not an aspirational one: features that do not exist are listed under "Known Limitations / Not Yet Implemented" rather than described as if present.

Overview

The API uses server-side session authentication (no JWT), a double-submit cookie CSRF defense, per-endpoint rate limiting, request body size limits, hardened media upload validation, security response headers, an origin CORS allowlist, and a role/status-based authorization model. It is production-ready for single-instance deployments; multi-instance deployments should configure Redis so rate-limit state is shared.

Note

At a glance: server-side sessions (no JWT), double-submit CSRF, bcrypt hashing, per-IP rate limiting, a strict CORS allowlist, hardened media uploads, and security headers — all validated at startup. Known gaps are listed honestly in §17.

Relevant source files:

  • internal/middleware/middleware.go - CORS, rate limiting (in-memory), body size cap, security headers, CSRF, auth/role middleware.
  • internal/middleware/ratelimit_redis.go - Redis-backed rate limiter.
  • internal/service/auth.go - login, bootstrap, sessions, password hashing.
  • internal/utils/security.go - CSRF token generation and cookie/header names.
  • internal/api/handlers.go - cookies, CSRF token issuance, media validation, bootstrap handler, authorization guards.
  • cmd/main.go - configuration validation, HTTP server timeouts, session cleanup, trusted-proxy validation.

1. Authentication (Server-Side Sessions)

Location: internal/service/auth.go, internal/api/handlers.go, internal/middleware/middleware.go

There are no JWTs and no refresh tokens. A session is a random UUID v4 (github.com/google/uuid) persisted in the sessions table with a 7-day TTL (sessionTTL = 7 * 24 * time.Hour).

Login flow (Service.Login)

  1. Look up the user by email.
  2. Verify the password with bcrypt (see section 2).
  3. Require user_status = active; otherwise return ErrUserNotActive (the handler responds 403 account not active).
  4. In a single transaction:
    • DeleteAllUserSessions(user_id) - enforces a single active session per user (logging in elsewhere invalidates prior sessions).
    • AddSession(...) - inserts the new session with expires_at.
    • UpdateLastLogin(...) - updates last_login_at.
  5. On success the handler sets:
    • session_id cookie - HttpOnly, path /, Max-Age matching the TTL.
    • csrf_token cookie - readable by JS (not HttpOnly), see section 4.

Session transport

Each authenticated request resolves the session id via extractSessionID:

  1. The session_id cookie, if present; otherwise
  2. An Authorization: Bearer <session_id> header.

RequireAuthMiddleware parses the id, loads the session + user (GetSessionAndUser), and requires user_status = active (otherwise 403 account not active). A missing/invalid/unknown session yields 401.

Logout (Logout handler)

Deletes the session row (DeleteSession) and clears both the session_id and csrf_token cookies (Max-Age = -1).

Session cleanup

Expired sessions are purged on startup and on an interval governed by SESSION_CLEANUP_INTERVAL (default 1h, cmd/main.go startSessionCleanup). DeleteExpiredSessions removes rows where expires_at < NOW().

sessionCookieConfig() reads:

  • SESSION_COOKIE_SECURE - required (validated at startup); must be true in production (APP_ENV=production).
  • SESSION_COOKIE_SAMESITE - lax (default behavior), strict, or none. none requires SESSION_COOKIE_SECURE=true.
  • SESSION_COOKIE_DOMAIN - optional cookie domain.

2. Password Hashing & Login Timing Mitigation

Location: internal/service/auth.go

  • Passwords are hashed with bcrypt at bcrypt.DefaultCost on register and bootstrap, and verified with bcrypt.CompareHashAndPassword on login.
  • Anti-enumeration timing mitigation: when the email is unknown (pgx.ErrNoRows), login runs a bcrypt.CompareHashAndPassword against a pre-computed dummy hash before returning ErrInvalidCredentials, so the response time of "user not found" matches "user found, wrong password".
  • The handler returns the same generic 401 invalid credentials for both unknown email and wrong password.

Password / input rules (internal/api/validator.go)

  • Password: minimum length 8, maximum 72 (bcrypt's input limit). There are no complexity, breach, or reuse rules.
  • Username: 3-50 characters, restricted to [a-zA-Z0-9._-] (URL-safe — usernames appear in public profile paths), starting and ending alphanumeric.
  • Optional phone number: must be exactly 10 digits when provided.

Password reset & email verification tokens (internal/service/tokens.go)

Both flows use the same single-use token model:

  • Tokens are 32 random bytes (hex); the database stores only the SHA-256 hash (password_reset_tokens, email_verification_tokens), so a database leak does not yield usable links.
  • TTLs: reset 1 hour, verification 24 hours. Expired rows are purged by the background cleanup sweep alongside expired sessions.
  • One outstanding token per account — requesting again deletes the previous token. Redeeming burns every token for that account.
  • Enumeration-safe endpoints: POST /password-reset/request and POST /verify-email/resend return byte-identical responses whether or not the email matches an account (and, for resend, whether or not it is already verified).
  • Redeeming a reset token (POST /password-reset/confirm) sets the new bcrypt hash and revokes every session for the account.
  • PUT /me/password (authenticated password change) verifies the current password, then atomically updates the hash, deletes outstanding reset tokens, revokes all sessions, and issues one fresh session to the caller.
  • Email delivery is via SMTP (internal/mail). When SMTP_HOST is unset the mailer is disabled: links are written to the server log instead, and reader registrations activate without verification (development mode — do not run production this way).

3. Admin Bootstrap

Location: internal/api/handlers.go (BootstrapAdmin), internal/service/auth.go (Service.BootstrapAdmin)

POST /admin/bootstrap creates the very first admin and is designed to be safe to leave registered:

  1. If BOOTSTRAP_TOKEN is unset, the endpoint is disabled (403).
  2. The X-Bootstrap-Token header is compared to BOOTSTRAP_TOKEN with crypto/subtle.ConstantTimeCompare; mismatch returns 401.
  3. In a transaction, CountAdmins is checked: if an admin already exists it returns 409 (ErrAdminAlreadyExists). Otherwise the new admin is created and immediately set to active (admins skip the pending flow).

Because it refuses once any admin exists, it is effectively one-time.


Location: internal/middleware/middleware.go (CSRFMiddleware), internal/utils/security.go, internal/api/handlers.go

  • On login the server generates a token via utils.GenerateCSRFToken - 32 random bytes (crypto/rand) encoded as base64url (base64.RawURLEncoding).
  • The token is delivered in the csrf_token cookie (JS-readable, not HttpOnly, so the client can echo it back).
  • For state-changing methods the client must send the same value in the X-CSRF-Token header.

Enforcement rules

CSRF is checked only when all of the following hold:

  • Method is POST, PUT, PATCH, or DELETE (GET/HEAD/OPTIONS skip).
  • The request is not Bearer-authenticated (Bearer requests are exempt - they are not subject to ambient cookie auth).
  • A session_id cookie is present (cookie-authenticated requests).

When enforced:

  • Missing/empty csrf_token cookie -> 403 csrf token missing.
  • Missing header or value mismatch -> 403 csrf token invalid. The comparison uses crypto/subtle.ConstantTimeCompare.

5. Authorization Model

Location: internal/middleware/middleware.go, internal/api/handlers.go, internal/service/*

Roles and statuses

  • Roles (user_role): admin, author, reader. Self-registration creates an author (admin-approved) by default, or a reader with role=reader (activated by email verification, or immediately when SMTP is not configured). Readers get the full social surface (comments, likes, follows, messages, media upload for avatars) but cannot author posts. The first admin is created via bootstrap.
  • Statuses (user_status): pending, active, suspended, deleted.

Middleware gates

  • RequireAuthMiddleware - valid session and status = active.
  • OptionalAuthMiddleware - populates the same context when a valid session is present but never rejects; used on public endpoints that personalize for logged-in viewers (liked_by_me, following). It grants no access.
  • RequireAuthorMiddleware - role author or admin.
  • RequireAdminMiddleware - role admin only.

Post ownership

For post update/delete, the handler passes requireOwnership = role != admin. Non-admins must own the post (verified in the service via VerifyPostOwnership); admins bypass the ownership check. Failing the check returns 403.

Comment ownership & moderation

Editing a comment requires being its author (403 otherwise). Deleting requires author or admin. PUT /comments/:id/hide is admin-only (moderation: hidden comments leave public lists but remain recoverable, unlike the soft delete). Notification reads are ownership-scoped in SQL — marking another user's notification is a no-op surfaced as 404.

Private-data exposure rules

  • Public profiles (GET /users/:username) never expose email, phone number, or account status, and resolve active accounts only.
  • Message threads and notification inboxes are always scoped to the authenticated user in the query's WHERE clause, never by client-supplied IDs.

Admin self-protection guards

  • Self-target guard: DeleteUser and UpdateUserStatus return 400 if the admin targets their own account ("cannot delete/change your own ...").
  • Last-active-admin guard: removing or deactivating the final active admin returns 409 (ErrLastAdmin), enforced via guardLastAdmin + CountAdmins.
  • Setting a user's status to deleted performs a soft delete; any non-active status change also wipes that user's sessions.

6. Rate Limiting

Location: internal/middleware/middleware.go, internal/middleware/ratelimit_redis.go, internal/api/routes.go

A fixed-window limiter keyed by client IP (c.ClientIP()).

  • In-memory (default): a mutex-guarded map with periodic eviction of expired entries. Not shared across instances.
  • Redis (when RATE_LIMIT_REDIS_URL is set): a Lua script doing INCR + PEXPIRE (and PTTL) for atomic per-window counting across instances.

Per-endpoint specs (env / default)

Env Default Applies to
RATE_LIMIT_LOGIN 10/1m POST /login, POST /admin/bootstrap, password-reset request/confirm, verify-email (+resend), PUT /me/password
RATE_LIMIT_REGISTER 10/1m POST /register
RATE_LIMIT_MEDIA 30/1m POST /media, GET /media/:key
RATE_LIMIT_ADMIN 60/1m admin routes
RATE_LIMIT_WRITE 60/1m author post routes + social writes (comments, likes, follows, messages, PUT /me)

Note: the shipped .env.example overrides some of these (e.g. RATE_LIMIT_LOGIN=5/5m); the values above are the in-code defaults used when the env var is unset.

Response headers

Every rate-limited response carries:

  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset (seconds until window reset)

When the limit is exceeded the response is 429 Too Many Requests with a Retry-After header (seconds). If the limiter backend errors, the request fails closed with 503.


7. Request Body Size Limits

Location: internal/middleware/middleware.go (MaxBodyBytesMiddleware), internal/api/routes.go

  • A global middleware wraps request bodies for POST/PUT/PATCH/DELETE with http.MaxBytesReader, capped at MAX_REQUEST_BODY_BYTES (default 1 MiB / 1048576).
  • The /media route is exempt from the global cap and instead enforces its own larger limit, MEDIA_MAX_BYTES (default 10000000, ~10 MB), via http.MaxBytesReader inside the upload handler.

8. Media Upload Validation

Location: internal/api/handlers.go (UploadMedia, loadMediaConfig)

POST /media (author/admin only) validates uploads in layers:

  1. Size - bounded by MEDIA_MAX_BYTES via http.MaxBytesReader and an explicit file.Size check; over-limit -> 413.
  2. Content sniffing - the first 512 bytes are inspected with http.DetectContentType. The detected MIME must be in MEDIA_ALLOWED_MIME (default image/jpeg,image/png,image/webp); otherwise 415.
  3. Declared type - the multipart part's Content-Type (when present) is parsed and must also be in the allowlist; otherwise 415. Both the sniffed and declared types are checked.
  4. Storage - the file is saved under MEDIA_STORAGE_DIR with a random uuid filename plus a safe extension derived from the detected MIME. The database stores the relative storage_key (the filename), not an absolute path and not the user-supplied filename. If the DB insert fails, the saved file is removed to avoid orphans for that request.
  5. Orphan cleanup - a background sweep (startMediaGC, every MEDIA_GC_INTERVAL, default 1h) deletes media not linked to any live post once it is older than MEDIA_ORPHAN_GRACE (default 24h), removing both the row and the backing file. The grace period protects freshly uploaded media that is not yet attached to a post.

9. Security Headers

Location: internal/middleware/middleware.go (SecurityHeadersMiddleware)

Set on every response:

Header Value Notes
X-Content-Type-Options nosniff always
X-Frame-Options DENY always
Referrer-Policy strict-origin-when-cross-origin (default) REFERRER_POLICY
Permissions-Policy geolocation=(), microphone=(), camera=() (default) PERMISSIONS_POLICY
Strict-Transport-Security built from HSTS_* only if HSTS_MAX_AGE is set
Content-Security-Policy from CONTENT_SECURITY_POLICY only if set

HSTS is opt-in: when HSTS_MAX_AGE is provided, includeSubDomains and preload are appended based on HSTS_INCLUDE_SUBDOMAINS / HSTS_PRELOAD.


10. CORS (Cross-Origin Resource Sharing)

Location: internal/middleware/middleware.go (LoadCORSConfigFromEnv, CORSMiddleware)

  • Allowlist only. CORS_ALLOWED_ORIGINS is required at startup. Requests whose Origin is not on the list get no CORS headers; an unknown-origin preflight OPTIONS is rejected with 403.
  • CORS_ALLOWED_METHODS (default GET,POST,PUT,DELETE,OPTIONS) and CORS_ALLOWED_HEADERS (default Content-Type,Authorization,X-CSRF-Token).
  • CORS_ALLOW_CREDENTIALS cannot be true together with a wildcard (*) origin - startup config loading rejects that combination.

11. HTTP Server Timeouts

Location: cmd/main.go

The http.Server is configured with:

  • ReadHeaderTimeout: 10s
  • ReadTimeout: 30s
  • WriteTimeout: 30s
  • IdleTimeout: 120s
  • MaxHeaderBytes: 1 MiB (1 << 20)

These bound slow-client / slowloris-style exposure.


12. Trusted Proxies & Client IP

Location: cmd/main.go (validateTrustedProxies), internal/api/routes.go

TRUSTED_PROXIES (comma-separated IPs/CIDRs; validated at startup) is passed to Gin's SetTrustedProxies. This determines whether X-Forwarded-For is trusted for c.ClientIP(), which feeds rate limiting and access logs. Default: none trusted (SetTrustedProxies(nil)), so behind a proxy this must be set for correct per-client rate limiting.


13. Soft Deletes & Data Model

Location: internal/sql/schema.sql, migrations, service layer

  • Users and posts use status enums plus a deleted_at timestamp; deletion is a soft delete (status set to deleted), not a row removal.
  • Sessions and join rows (post/category, post/media) cascade with their parents.
  • Database migrations run on startup under a pg_advisory_lock (internal/migrate/migrate.go) so concurrent replicas cannot apply them simultaneously.

14. Request ID Tracking & Access Logging

Location: internal/utils/request_id.go, internal/middleware/middleware.go

  • RequestIDMiddleware accepts or generates an X-Request-Id (UUID v4), stores it in context, and echoes it on the response.
  • A custom slog handler stamps the request id onto log records.
  • AccessLogMiddleware emits structured logs (method, path, status, latency, client IP) instead of Gin's default logger.

15. Configuration Validation (Fail Fast)

Location: cmd/main.go (loadConfig)

Startup validates configuration and exits non-zero on any problem, including:

  • Required: DATABASE_URL, APP_PORT, MEDIA_STORAGE_DIR (must be writable), SESSION_COOKIE_SECURE.
  • APP_ENV=production requires SESSION_COOKIE_SECURE=true.
  • SESSION_COOKIE_SAMESITE=none requires SESSION_COOKIE_SECURE=true.
  • CORS allowlist present and consistent with credentials.
  • Rate-limit specs parse; RATE_LIMIT_REDIS_URL (if set) is a valid URL.
  • MEDIA_MAX_BYTES positive; MEDIA_ALLOWED_MIME entries are valid MIME types.
  • TRUSTED_PROXIES entries are valid IPs/CIDRs.
  • HSTS_* values well-formed; SESSION_CLEANUP_INTERVAL a valid duration.

16. Production Checklist

  • APP_ENV=production
  • SESSION_COOKIE_SECURE=true (required in production)
  • SESSION_COOKIE_SAMESITE chosen deliberately (none requires Secure)
  • CORS_ALLOWED_ORIGINS set to real frontend origins (no wildcard with credentials)
  • TRUSTED_PROXIES set to your reverse proxy IPs/CIDRs
  • Rate-limit specs reviewed for expected traffic
  • RATE_LIMIT_REDIS_URL configured if running multiple instances
  • MEDIA_MAX_BYTES and MEDIA_ALLOWED_MIME set appropriately
  • HSTS_MAX_AGE set if served over HTTPS
  • BOOTSTRAP_TOKEN set for first deploy, then unset after the admin exists
  • SMTP_* configured — without it, password-reset links only land in server logs and readers activate without email verification
  • APP_PUBLIC_URL set to the real frontend origin (emailed links point here)
  • Database and media backups arranged externally
  • Graceful shutdown verified (SIGTERM drains in-flight requests)

17. Known Limitations / Not Yet Implemented

The following are genuinely absent from the current codebase. They are listed so this document does not overstate the security posture:

  • No CI pipeline (no automated build/test/lint on push).
  • No metrics, tracing, or observability beyond structured logs (no Prometheus /metrics, no OpenTelemetry).
  • No audit log of security-relevant actions (status changes, deletions, logins).
  • No password complexity, breach, or reuse checks beyond the minimum length of 8.
  • No account lockout / progressive backoff on repeated failed logins (only IP-based rate limiting).
  • No 2FA / MFA.
  • No JWT or refresh tokens. Authentication is server-side sessions only.
  • No per-user cap on SSE connections (GET /events); each connection is cheap (one buffered channel), but a hostile authenticated user could open many.
  • No user-level blocking for messaging/comments — moderation is admin-side (hide/delete/suspend).

In-memory rate limiting across instances

Without Redis, each instance keeps its own counters, so the effective limit scales with the number of instances. Configure RATE_LIMIT_REDIS_URL for a shared limit.


Document Version: 2.0 Last Updated: 2026-07-01

Generated from SECURITY.md on 2026-08-17 23:37 CEST. Edit the Markdown source and re-run task docs to update.