fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps),
tracked in deploy-k3s/SECURITY.md, plus fixes from two independent
post-remediation reviews.

Auth & sessions:
- SHA-256 hashed auth-token storage (C1); prior-token cache eviction on
  re-login (MEDIUM-1)
- local Google JWKS verification, iss/aud/exp checks (C2/C3)
- constant-time login + generic errors (L1/LIVE-L11/LIVE-L13)
- per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3)
- verified-email gating, login rate limiting (LIVE-L19, H1-H3)

IAP & webhooks:
- Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6)
- migrations 000003-000006 (token hashing, IAP replay, audit_log +
  webhook_event_log table creation, append-only audit log)

Authorization & races:
- file-ownership owner-OR-member fix (C7), atomic share-code join
  (C9/H9), device-token reassignment (C8/LOW-3)

Secrets & deploy:
- secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis
  password out of the ConfigMap (HIGH-1); B2 keys reconciled
- digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics
  lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban +
  unattended-upgrades at provision; secret-rotation runbook

Build, vet, and the full test suite (incl. -race) pass; the goose
migration chain is verified against PostgreSQL 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-16 22:28:33 -05:00
parent 2004f9c5b2
commit c77ff07ce9
59 changed files with 2819 additions and 1245 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
# Admin panel build stage # Admin panel build stage
FROM node:20-alpine AS admin-builder FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin-builder
WORKDIR /app WORKDIR /app
@@ -109,7 +109,7 @@ FROM go-base AS worker
CMD ["/app/worker"] CMD ["/app/worker"]
# Admin panel runtime stage # Admin panel runtime stage
FROM node:20-alpine AS admin FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin
WORKDIR /app WORKDIR /app
@@ -131,7 +131,7 @@ ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["node", "server.js"]
# Default production stage (for Dokku - runs API + Admin) # Default production stage (for Dokku - runs API + Admin)
FROM node:20-alpine AS production FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS production
# Install runtime dependencies # Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata curl RUN apk add --no-cache ca-certificates tzdata curl
+4 -2
View File
@@ -54,11 +54,13 @@ func main() {
// Initialize OpenTelemetry tracing — exports to obs.88oakapps.com // Initialize OpenTelemetry tracing — exports to obs.88oakapps.com
// (Jaeger via OTLP/HTTP) when OBS_TRACES_URL is set; otherwise installs // (Jaeger via OTLP/HTTP) when OBS_TRACES_URL is set; otherwise installs
// a no-op tracer so call sites can use otel.Tracer() unconditionally. // a no-op tracer so call sites can use otel.Tracer() unconditionally.
// config.SecretValue (not os.Getenv) so file-mounted secrets resolve
// after audit F8 removed these from the process environment.
tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{ tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
ServiceName: "honeydue-api", ServiceName: "honeydue-api",
Environment: deploymentEnvironment(cfg.Server.Debug), Environment: deploymentEnvironment(cfg.Server.Debug),
EndpointURL: os.Getenv("OBS_TRACES_URL"), EndpointURL: config.SecretValue("OBS_TRACES_URL"),
BearerToken: os.Getenv("OBS_INGEST_TOKEN"), BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
SampleRatio: tracing.SampleRatioFromEnv(), SampleRatio: tracing.SampleRatioFromEnv(),
}) })
if err != nil { if err != nil {
+15 -2
View File
@@ -47,11 +47,13 @@ func main() {
// Initialize OpenTelemetry tracing for the worker process. Same OTLP // Initialize OpenTelemetry tracing for the worker process. Same OTLP
// destination as the api; service.name distinguishes them in Jaeger. // destination as the api; service.name distinguishes them in Jaeger.
// config.SecretValue (not os.Getenv) so file-mounted secrets resolve
// after audit F8 removed these from the process environment.
tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{ tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
ServiceName: "honeydue-worker", ServiceName: "honeydue-worker",
Environment: workerDeploymentEnv(cfg.Server.Debug), Environment: workerDeploymentEnv(cfg.Server.Debug),
EndpointURL: os.Getenv("OBS_TRACES_URL"), EndpointURL: config.SecretValue("OBS_TRACES_URL"),
BearerToken: os.Getenv("OBS_INGEST_TOKEN"), BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
SampleRatio: tracing.SampleRatioFromEnv(), SampleRatio: tracing.SampleRatioFromEnv(),
}) })
if err != nil { if err != nil {
@@ -106,6 +108,17 @@ func main() {
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to parse Redis URL") log.Fatal().Err(err).Msg("Failed to parse Redis URL")
} }
// Audit HIGH-1: the Redis password is a file-mounted secret (REDIS_PASSWORD),
// not embedded in REDIS_URL — REDIS_URL travels in the honeydue-config
// ConfigMap. Apply the password onto the parsed opt so the Asynq server,
// inspector and monitoring client (all derived from redisOpt below)
// authenticate against a requirepass-protected Redis.
if cfg.Redis.Password != "" {
if clientOpt, ok := redisOpt.(asynq.RedisClientOpt); ok {
clientOpt.Password = cfg.Redis.Password
redisOpt = clientOpt
}
}
// Initialize monitoring service (if Redis is available) // Initialize monitoring service (if Redis is available)
var monitoringService *monitoring.Service var monitoringService *monitoring.Service
+896 -676
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -30,6 +30,7 @@ load_balancer_ip: ""
domains: domains:
api: api.myhoneydue.com api: api.myhoneydue.com
admin: admin.myhoneydue.com admin: admin.myhoneydue.com
app: app.myhoneydue.com # web client host — added to CORS_ALLOWED_ORIGINS
base: myhoneydue.com base: myhoneydue.com
# --- Container Registry (GHCR) --- # --- Container Registry (GHCR) ---
+5 -1
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: admin serviceAccountName: admin
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
imagePullSecrets: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1001 runAsUser: 1001
@@ -35,6 +38,7 @@ spec:
containers: containers:
- name: admin - name: admin
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports: ports:
- containerPort: 3000 - containerPort: 3000
protocol: TCP protocol: TCP
+26 -64
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: api serviceAccountName: api
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
imagePullSecrets: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1000 runAsUser: 1000
@@ -35,6 +38,7 @@ spec:
containers: containers:
- name: api - name: api
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports: ports:
- containerPort: 8000 - containerPort: 8000
protocol: TCP protocol: TCP
@@ -46,65 +50,16 @@ spec:
envFrom: envFrom:
- configMapRef: - configMapRef:
name: honeydue-config name: honeydue-config
env: # Audit CODE-F8: secrets are NOT injected as environment variables.
- name: POSTGRES_PASSWORD # Env vars are readable for the life of the pod via /proc/<pid>/environ
valueFrom: # and leak into crash dumps / child processes. honeydue-secrets is
secretKeyRef: # mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
name: honeydue-secrets # config layer (config.loadFileSecrets) reads each key from its file.
key: POSTGRES_PASSWORD # Non-secret config still arrives via the configMapRef above.
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: SECRET_KEY
- name: EMAIL_HOST_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: EMAIL_HOST_PASSWORD
- name: FCM_SERVER_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: FCM_SERVER_KEY
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: REDIS_PASSWORD
optional: true
# B2 (Backblaze) credentials. With both set, StorageConfig.IsS3()
# returns true and uploads stream to B2 via minio-go. With either
# missing, code falls back to local filesystem — and since
# readOnlyRootFilesystem is true on this container, that fallback
# silently fails. So both must be wired or uploads break.
- name: B2_KEY_ID
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: B2_KEY_ID
- name: B2_APP_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: B2_APP_KEY
# Observability — push traces (and any future OTLP metrics) to
# obs.88oakapps.com. Token gates ingest at nginx; URL is the
# same one vmagent uses for metric remote-write. Both come from
# honeydue-secrets so they aren't world-readable in ConfigMap.
- name: OBS_TRACES_URL
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: OBS_TRACES_URL
optional: true
- name: OBS_INGEST_TOKEN
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: OBS_INGEST_TOKEN
optional: true
volumeMounts: volumeMounts:
- name: app-secrets
mountPath: /etc/honeydue/secrets
readOnly: true
- name: apns-key - name: apns-key
mountPath: /secrets/apns mountPath: /secrets/apns
readOnly: true readOnly: true
@@ -121,11 +76,12 @@ spec:
httpGet: httpGet:
path: /api/health/ path: /api/health/
port: 8000 port: 8000
# MigrateWithLock in cmd/api/main.go runs pg_advisory_lock on # Schema migrations run separately in the honeydue-migrate Job
# every startup. On a cold boot with 3 replicas, the first does # *before* this Deployment rolls — the api itself does not migrate
# AutoMigrate (~90s) and the others wait on the lock, so real # (it only verifies goose_db_version at boot). Cold start still
# startup runs 90240s. 48 × 5s = 240s grace absorbs it without # pays the DB pool warm-up + Redis connect + APNs/FCM client init
# healthcheck killing a still-starting replica. # before /api/health/ goes green. 48 × 5s = 240s grace keeps the
# probe from killing a still-starting replica.
failureThreshold: 48 failureThreshold: 48
periodSeconds: 5 periodSeconds: 5
readinessProbe: readinessProbe:
@@ -143,6 +99,12 @@ spec:
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
volumes: volumes:
# Audit CODE-F8: the whole honeydue-secrets Secret, projected as files.
# defaultMode 0400 → readable only by the container's runAsUser (1000).
- name: app-secrets
secret:
secretName: honeydue-secrets
defaultMode: 0400
- name: apns-key - name: apns-key
secret: secret:
secretName: honeydue-apns-key secretName: honeydue-apns-key
@@ -53,7 +53,12 @@ metadata:
labels: labels:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
annotations: annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd # cloudflare-only + admin-auth wired in (audit F2/F3/CODE-L6). Order
# matters: reject non-Cloudflare IPs, then basic auth, then headers,
# then rate limit. The admin-basic-auth secret is created by
# 02-setup-secrets.sh from config.yaml admin.basic_auth_* — that runs
# before 03-deploy.sh, so the middleware always has its secret.
traefik.ingress.kubernetes.io/router.middlewares: honeydue-cloudflare-only@kubernetescrd,honeydue-admin-auth@kubernetescrd,honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls: tls:
@@ -98,3 +103,98 @@ spec:
name: web name: web
port: port:
number: 3000 number: 3000
---
# Auth-endpoint Ingress (audit F10 / LIVE-L12). A dedicated Ingress for the
# auth paths so Traefik gives their longer path-prefix routers a higher
# priority than honeydue-api's "/" router — these paths then get
# auth-rate-limit (5/min) instead of the general rate-limit (100/min).
# Anything not matched here falls through to honeydue-api unchanged.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: honeydue-api-auth
namespace: honeydue
labels:
app.kubernetes.io/part-of: honeydue
annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-auth-rate-limit@kubernetescrd,honeydue-security-headers@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- api.myhoneydue.com
secretName: cloudflare-origin-cert
rules:
- host: api.myhoneydue.com
http:
paths:
- path: /api/auth/login
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/register
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/forgot-password
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/reset-password
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/residences/join-with-code
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/verify-reset-code
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/apple-sign-in
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/google-sign-in
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/refresh
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/account
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
+31 -2
View File
@@ -21,12 +21,20 @@ spec:
headers: headers:
frameDeny: true frameDeny: true
contentTypeNosniff: true contentTypeNosniff: true
browserXssFilter: true # browserXssFilter removed (audit L7): it emits the deprecated
# X-XSS-Protection header, which can itself introduce XSS in legacy
# browsers. Modern browsers ignore it.
referrerPolicy: "strict-origin-when-cross-origin" referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders: customResponseHeaders:
X-Content-Type-Options: "nosniff" X-Content-Type-Options: "nosniff"
X-Frame-Options: "DENY" X-Frame-Options: "DENY"
Strict-Transport-Security: "max-age=31536000; includeSubDomains" # HSTS: 2-year max-age + preload (audit L5/CODE-L3). After this is
# live on api/admin/app, submit myhoneydue.com to hstspreload.org.
Strict-Transport-Security: "max-age=63072000; includeSubDomains; preload"
# Cross-origin isolation (audit F9). COEP (require-corp) is omitted —
# it commonly breaks third-party embeds; add only after testing.
Cross-Origin-Opener-Policy: "same-origin"
Cross-Origin-Resource-Policy: "same-origin"
# Content-Security-Policy is intentionally NOT set here — the Go API # Content-Security-Policy is intentionally NOT set here — the Go API
# sets a CSP in internal/router/router.go that permits Google Fonts # sets a CSP in internal/router/router.go that permits Google Fonts
# for the landing page. Two CSP headers would intersect and break it. # for the landing page. Two CSP headers would intersect and break it.
@@ -83,3 +91,24 @@ spec:
basicAuth: basicAuth:
secret: admin-basic-auth secret: admin-basic-auth
realm: "honeyDue Admin" realm: "honeyDue Admin"
---
# Strict rate limit for auth endpoints (audit F10 / LIVE-L12).
# Applied via the honeydue-api-auth Ingress to login / register /
# forgot-password / reset-password / join-with-code. depth: 2 makes the
# limiter key on the real client IP rather than the Cloudflare edge IP
# (request path: client -> Cloudflare -> Traefik). This is the edge half;
# the per-account lockout in the Go app is the robust half.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: auth-rate-limit
namespace: honeydue
spec:
rateLimit:
average: 5
burst: 10
period: 1m
sourceCriterion:
ipStrategy:
depth: 2
@@ -0,0 +1,61 @@
# Kyverno image-signature verification policy (audit CODE-L5).
#
# ──────────────────────────────────────────────────────────────────────────
# THIS MANIFEST IS NOT APPLIED BY 03-deploy.sh. It is intentionally outside
# the script's apply set. Applying it before the prerequisites are in place
# would block every honeydue Pod from scheduling. Operator steps:
#
# 1. Install Kyverno in the cluster (it is an admission controller):
# kubectl create -f https://github.com/kyverno/kyverno/releases/latest/download/install.yaml
# 2. Generate a cosign key pair and keep the private key safe:
# cosign generate-key-pair # -> cosign.key (PRIVATE) + cosign.pub
# Set COSIGN_KEY=cosign.key in the deploy environment so 03-deploy.sh
# signs images after pushing them (the signing step is already wired,
# guarded, into 03-deploy.sh).
# 3. Paste the contents of cosign.pub into the publicKeys block below.
# 4. Apply this policy: kubectl apply -f deploy-k3s/manifests/kyverno-verify-images.yaml
# 5. After confirming honeydue Pods still schedule, flip
# validationFailureAction from Audit to Enforce.
#
# Until then it is a documented, ready-to-use template — not active config.
# ──────────────────────────────────────────────────────────────────────────
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-honeydue-images
annotations:
policies.kyverno.io/title: Verify honeyDue image signatures
policies.kyverno.io/description: >-
Requires that honeyDue application images pulled into the honeydue
namespace carry a valid cosign signature made with the operator's key.
spec:
# Audit first — logs violations without blocking. Switch to Enforce once
# signing is confirmed working end to end.
validationFailureAction: Audit
background: false
webhookTimeoutSeconds: 30
rules:
- name: verify-gitea-image-signatures
match:
any:
- resources:
kinds:
- Pod
namespaces:
- honeydue
verifyImages:
# Only the images we build and sign. Public base images
# (redis, vmagent) are pinned by digest instead — see their manifests.
- imageReferences:
- "gitea.treytartt.com/admin/honeydue-api*"
- "gitea.treytartt.com/admin/honeydue-worker*"
- "gitea.treytartt.com/admin/honeydue-admin*"
- "gitea.treytartt.com/admin/honeydue-web*"
attestors:
- count: 1
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
REPLACE_WITH_CONTENTS_OF_cosign.pub
-----END PUBLIC KEY-----
+4 -1
View File
@@ -27,8 +27,10 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
restartPolicy: Never restartPolicy: Never
# The migrate Job never calls the k8s API (audit F11).
automountServiceAccountToken: false
imagePullSecrets: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1000 runAsUser: 1000
@@ -38,6 +40,7 @@ spec:
containers: containers:
- name: goose - name: goose
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh — same as api image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh — same as api
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
command: ["/bin/sh", "-c"] command: ["/bin/sh", "-c"]
# DB_HOST in the ConfigMap points at the -pooler endpoint for runtime. # DB_HOST in the ConfigMap points at the -pooler endpoint for runtime.
# goose's session-scoped advisory lock can't survive PgBouncer # goose's session-scoped advisory lock can't survive PgBouncer
@@ -179,7 +179,17 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: vmagent - name: vmagent
image: victoriametrics/vmagent:v1.106.1 # Pinned by digest (audit K3S-F14).
image: victoriametrics/vmagent:v1.106.1@sha256:90208a667c0baf65f7536b92a84c40b6e35ffe8e88bda7e4447b97b06c6ba6b8
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
# Container-level hardening (audit F7) — matches the other 5
# workloads. vmagent only writes to the /tmp/vmagent emptyDir
# (its remoteWrite buffer), so a read-only root filesystem holds.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
args: args:
- "-promscrape.config=/etc/vmagent/scrape.yaml" - "-promscrape.config=/etc/vmagent/scrape.yaml"
- "-remoteWrite.url=https://obs.88oakapps.com/api/v1/write" - "-remoteWrite.url=https://obs.88oakapps.com/api/v1/write"
+6 -1
View File
@@ -20,6 +20,9 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: redis serviceAccountName: redis
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
nodeSelector: nodeSelector:
honeydue/redis: "true" honeydue/redis: "true"
securityContext: securityContext:
@@ -31,7 +34,9 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: redis - name: redis
image: redis:7-alpine # Pinned by digest (audit K3S-F14) — redis:7-alpine is 7.4.9-alpine.
image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
command: command:
- sh - sh
- -c - -c
+5 -1
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: web serviceAccountName: web
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
imagePullSecrets: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1001 runAsUser: 1001
@@ -43,6 +46,7 @@ spec:
containers: containers:
- name: web - name: web
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh or manual sed image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh or manual sed
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports: ports:
- containerPort: 3000 - containerPort: 3000
protocol: TCP protocol: TCP
+20 -58
View File
@@ -27,8 +27,11 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: worker serviceAccountName: worker
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
imagePullSecrets: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1000 runAsUser: 1000
@@ -39,6 +42,7 @@ spec:
containers: containers:
- name: worker - name: worker
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
@@ -47,64 +51,16 @@ spec:
envFrom: envFrom:
- configMapRef: - configMapRef:
name: honeydue-config name: honeydue-config
env: # Audit CODE-F8: secrets are NOT injected as environment variables.
- name: POSTGRES_PASSWORD # Env vars are readable for the life of the pod via /proc/<pid>/environ
valueFrom: # and leak into crash dumps / child processes. honeydue-secrets is
secretKeyRef: # mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
name: honeydue-secrets # config layer (config.loadFileSecrets) reads each key from its file.
key: POSTGRES_PASSWORD # Non-secret config still arrives via the configMapRef above.
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: SECRET_KEY
- name: EMAIL_HOST_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: EMAIL_HOST_PASSWORD
- name: FCM_SERVER_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: FCM_SERVER_KEY
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: REDIS_PASSWORD
optional: true
# B2 (Backblaze) credentials. The worker needs these to delete
# B2 objects when the pending_uploads cleanup cron reaps
# expired upload sessions. Without them the worker falls back
# to local-disk storage which fails on this pod's read-only
# root filesystem and disables the cleanup cron.
- name: B2_KEY_ID
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: B2_KEY_ID
- name: B2_APP_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: B2_APP_KEY
# Observability — workers emit traces (e.g., asynq job spans) to
# obs.88oakapps.com over OTLP/HTTP. service.name=honeydue-worker
# so api and worker show up as separate services in Jaeger.
- name: OBS_TRACES_URL
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: OBS_TRACES_URL
optional: true
- name: OBS_INGEST_TOKEN
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: OBS_INGEST_TOKEN
optional: true
volumeMounts: volumeMounts:
- name: app-secrets
mountPath: /etc/honeydue/secrets
readOnly: true
- name: apns-key - name: apns-key
mountPath: /secrets/apns mountPath: /secrets/apns
readOnly: true readOnly: true
@@ -124,6 +80,12 @@ spec:
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 5 timeoutSeconds: 5
volumes: volumes:
# Audit CODE-F8: the whole honeydue-secrets Secret, projected as files.
# defaultMode 0400 → readable only by the container's runAsUser (1000).
- name: app-secrets
secret:
secretName: honeydue-secrets
defaultMode: 0400
- name: apns-key - name: apns-key
secret: secret:
secretName: honeydue-apns-key secretName: honeydue-apns-key
+27 -5
View File
@@ -68,6 +68,25 @@ SECRET_ARGS=(
if [[ -n "${REDIS_PASSWORD}" ]]; then if [[ -n "${REDIS_PASSWORD}" ]]; then
log " Including REDIS_PASSWORD in secrets" log " Including REDIS_PASSWORD in secrets"
SECRET_ARGS+=(--from-literal="REDIS_PASSWORD=${REDIS_PASSWORD}") SECRET_ARGS+=(--from-literal="REDIS_PASSWORD=${REDIS_PASSWORD}")
else
# Audit K3S-F1 (CRITICAL) / MEDIUM-4: refuse to deploy with an unauthenticated
# Redis. A previous version only warned here, which let a deploy from an
# unedited config.yaml silently bring Redis up with no password.
die "redis.password is empty in config.yaml — refusing to deploy: Redis would run with NO authentication (audit K3S-F1). Set a strong value, e.g.: openssl rand -base64 32"
fi
# B2 (Backblaze) object-storage credentials. The api/worker manifests
# reference B2_KEY_ID / B2_APP_KEY as required secret keys, so honeydue-secrets
# MUST carry them or those pods fail to start. Sourced from config.yaml so the
# script and the manifests no longer drift (was a latent gap before 2026-05-16).
B2_KEY_ID_VAL="$(cfg storage.b2_key_id 2>/dev/null || true)"
B2_APP_KEY_VAL="$(cfg storage.b2_app_key 2>/dev/null || true)"
if [[ -n "${B2_KEY_ID_VAL}" && -n "${B2_APP_KEY_VAL}" ]]; then
log " Including B2_KEY_ID / B2_APP_KEY in secrets"
SECRET_ARGS+=(--from-literal="B2_KEY_ID=${B2_KEY_ID_VAL}")
SECRET_ARGS+=(--from-literal="B2_APP_KEY=${B2_APP_KEY_VAL}")
else
warn "storage.b2_key_id / b2_app_key not set in config.yaml — B2 uploads will be disabled."
fi fi
# Observability ingest credentials live in deploy/prod.env (gitignored) so # Observability ingest credentials live in deploy/prod.env (gitignored) so
@@ -100,22 +119,24 @@ kubectl create secret generic honeydue-apns-key \
--from-file="apns_auth_key.p8=${SECRETS_DIR}/apns_auth_key.p8" \ --from-file="apns_auth_key.p8=${SECRETS_DIR}/apns_auth_key.p8" \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
# --- Create GHCR registry credentials --- # --- Create container registry credentials ---
# Secret name is gitea-credentials (audit F6): the registry is self-hosted
# Gitea, not GHCR. Every deployment manifest references this same name.
REGISTRY_SERVER="$(cfg registry.server)" REGISTRY_SERVER="$(cfg registry.server)"
REGISTRY_USER="$(cfg registry.username)" REGISTRY_USER="$(cfg registry.username)"
REGISTRY_TOKEN="$(cfg registry.token)" REGISTRY_TOKEN="$(cfg registry.token)"
if [[ -n "${REGISTRY_SERVER}" && -n "${REGISTRY_USER}" && -n "${REGISTRY_TOKEN}" ]]; then if [[ -n "${REGISTRY_SERVER}" && -n "${REGISTRY_USER}" && -n "${REGISTRY_TOKEN}" ]]; then
log "Creating ghcr-credentials..." log "Creating gitea-credentials..."
kubectl create secret docker-registry ghcr-credentials \ kubectl create secret docker-registry gitea-credentials \
--namespace="${NAMESPACE}" \ --namespace="${NAMESPACE}" \
--docker-server="${REGISTRY_SERVER}" \ --docker-server="${REGISTRY_SERVER}" \
--docker-username="${REGISTRY_USER}" \ --docker-username="${REGISTRY_USER}" \
--docker-password="${REGISTRY_TOKEN}" \ --docker-password="${REGISTRY_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
else else
warn "Registry credentials incomplete in config.yaml — skipping ghcr-credentials." warn "Registry credentials incomplete in config.yaml — skipping gitea-credentials."
fi fi
# --- Create Cloudflare origin cert --- # --- Create Cloudflare origin cert ---
@@ -132,7 +153,8 @@ kubectl create secret tls cloudflare-origin-cert \
if [[ -n "${ADMIN_AUTH_USER}" && -n "${ADMIN_AUTH_PASSWORD}" ]]; then if [[ -n "${ADMIN_AUTH_USER}" && -n "${ADMIN_AUTH_PASSWORD}" ]]; then
command -v htpasswd >/dev/null 2>&1 || die "Missing: htpasswd (install apache2-utils)" command -v htpasswd >/dev/null 2>&1 || die "Missing: htpasswd (install apache2-utils)"
log "Creating admin-basic-auth secret..." log "Creating admin-basic-auth secret..."
HTPASSWD="$(htpasswd -nb "${ADMIN_AUTH_USER}" "${ADMIN_AUTH_PASSWORD}")" # -B forces bcrypt (Traefik BasicAuth supports it; avoids weak apr1-MD5).
HTPASSWD="$(htpasswd -nbB "${ADMIN_AUTH_USER}" "${ADMIN_AUTH_PASSWORD}")"
kubectl create secret generic admin-basic-auth \ kubectl create secret generic admin-basic-auth \
--namespace="${NAMESPACE}" \ --namespace="${NAMESPACE}" \
--from-literal=users="${HTPASSWD}" \ --from-literal=users="${HTPASSWD}" \
+55 -5
View File
@@ -128,6 +128,56 @@ else
warn "Skipping build. Using images for tag: ${DEPLOY_TAG}" warn "Skipping build. Using images for tag: ${DEPLOY_TAG}"
fi fi
# --- Resolve immutable image digests (audit F5) ---
# A short-SHA tag is mutable — anyone who can push to the registry can
# overwrite it, and imagePullPolicy then pulls the new bits silently. We
# deploy by @sha256: digest instead, pinning the exact image that was just
# built and pushed. `docker push` populates RepoDigests; with --skip-build
# (no local image) resolve_ref falls back to the tag.
resolve_ref() {
local img="$1" digest
digest="$(docker inspect --format='{{range .RepoDigests}}{{println .}}{{end}}' "${img}" 2>/dev/null | grep -m1 '@sha256:' || true)"
if [[ -n "${digest}" ]]; then
printf '%s' "${digest}"
else
warn "could not resolve a digest for ${img} — deploying by mutable tag"
printf '%s' "${img}"
fi
}
API_REF="$(resolve_ref "${API_IMAGE}")"
WORKER_REF="$(resolve_ref "${WORKER_IMAGE}")"
ADMIN_REF="$(resolve_ref "${ADMIN_IMAGE}")"
WEB_REF="$(resolve_ref "${WEB_IMAGE}")"
log "Deploying by digest:"
log " API: ${API_REF}"
log " Worker: ${WORKER_REF}"
log " Admin: ${ADMIN_REF}"
# --- Image scan + signing (audit CODE-L5) ---
# Both steps are best-effort: the deploy does NOT fail if the tools are
# absent, so an operator who has not set up cosign/trivy yet is not blocked.
# Install trivy + cosign and export COSIGN_KEY to enforce. Cluster-side
# admission verification (Kyverno/Connaisseur) is a separate operator step.
if [[ "${SKIP_BUILD}" == "false" ]]; then
if command -v trivy >/dev/null 2>&1; then
log "Scanning images with Trivy (HIGH,CRITICAL)..."
for img in "${API_IMAGE}" "${WORKER_IMAGE}" "${ADMIN_IMAGE}"; do
trivy image --severity HIGH,CRITICAL --exit-code 0 --quiet "${img}" \
|| warn "Trivy reported findings for ${img}"
done
else
warn "trivy not installed — skipping image vulnerability scan (audit L5)"
fi
if command -v cosign >/dev/null 2>&1 && [[ -n "${COSIGN_KEY:-}" ]]; then
log "Signing images with cosign..."
for ref in "${API_REF}" "${WORKER_REF}" "${ADMIN_REF}"; do
cosign sign --yes --key "${COSIGN_KEY}" "${ref}" || warn "cosign sign failed for ${ref}"
done
else
warn "cosign not configured (need cosign + COSIGN_KEY) — skipping image signing (audit L5)"
fi
fi
# --- Generate and apply ConfigMap from config.yaml --- # --- Generate and apply ConfigMap from config.yaml ---
log "Generating env from config.yaml..." log "Generating env from config.yaml..."
@@ -166,7 +216,7 @@ kubectl apply -f "${MANIFESTS}/ingress/"
# pod sees a stale schema. # pod sees a stale schema.
log "Running database migrations (goose Job)..." log "Running database migrations (goose Job)..."
kubectl delete job honeydue-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null kubectl delete job honeydue-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/migrate/job.yaml" | kubectl apply -f - sed "s|image: IMAGE_PLACEHOLDER|image: ${API_REF}|" "${MANIFESTS}/migrate/job.yaml" | kubectl apply -f -
if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=10m job/honeydue-migrate; then if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=10m job/honeydue-migrate; then
warn "migration Job failed — see logs:" warn "migration Job failed — see logs:"
kubectl logs -n "${NAMESPACE}" job/honeydue-migrate --tail=200 || true kubectl logs -n "${NAMESPACE}" job/honeydue-migrate --tail=200 || true
@@ -175,17 +225,17 @@ fi
log "Migrations applied; proceeding with api/worker rollout" log "Migrations applied; proceeding with api/worker rollout"
# Apply deployments with image substitution # Apply deployments with image substitution
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f - sed "s|image: IMAGE_PLACEHOLDER|image: ${API_REF}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f -
kubectl apply -f "${MANIFESTS}/api/service.yaml" kubectl apply -f "${MANIFESTS}/api/service.yaml"
kubectl apply -f "${MANIFESTS}/api/hpa.yaml" kubectl apply -f "${MANIFESTS}/api/hpa.yaml"
sed "s|image: IMAGE_PLACEHOLDER|image: ${WORKER_IMAGE}|" "${MANIFESTS}/worker/deployment.yaml" | kubectl apply -f - sed "s|image: IMAGE_PLACEHOLDER|image: ${WORKER_REF}|" "${MANIFESTS}/worker/deployment.yaml" | kubectl apply -f -
sed "s|image: IMAGE_PLACEHOLDER|image: ${ADMIN_IMAGE}|" "${MANIFESTS}/admin/deployment.yaml" | kubectl apply -f - sed "s|image: IMAGE_PLACEHOLDER|image: ${ADMIN_REF}|" "${MANIFESTS}/admin/deployment.yaml" | kubectl apply -f -
kubectl apply -f "${MANIFESTS}/admin/service.yaml" kubectl apply -f "${MANIFESTS}/admin/service.yaml"
if [[ -d "${MANIFESTS}/web" ]]; then if [[ -d "${MANIFESTS}/web" ]]; then
sed "s|image: IMAGE_PLACEHOLDER|image: ${WEB_IMAGE}|" "${MANIFESTS}/web/deployment.yaml" | kubectl apply -f - sed "s|image: IMAGE_PLACEHOLDER|image: ${WEB_REF}|" "${MANIFESTS}/web/deployment.yaml" | kubectl apply -f -
kubectl apply -f "${MANIFESTS}/web/service.yaml" kubectl apply -f "${MANIFESTS}/web/service.yaml"
fi fi
+21 -6
View File
@@ -100,7 +100,7 @@ lines = [
# API # API
'DEBUG=false', 'DEBUG=false',
f\"ALLOWED_HOSTS={d['api']},{d['base']}\", f\"ALLOWED_HOSTS={d['api']},{d['base']}\",
f\"CORS_ALLOWED_ORIGINS=https://{d['base']},https://{d['admin']}\", f\"CORS_ALLOWED_ORIGINS=https://{d['base']},https://{d['admin']},https://{d.get('app', 'app.' + d['base'])}\",
'TIMEZONE=UTC', 'TIMEZONE=UTC',
f\"BASE_URL=https://{d['base']}\", f\"BASE_URL=https://{d['base']}\",
'PORT=8000', 'PORT=8000',
@@ -119,9 +119,14 @@ lines = [
f\"DB_MAX_IDLE_CONNS={db['max_idle_conns']}\", f\"DB_MAX_IDLE_CONNS={db['max_idle_conns']}\",
f\"DB_MAX_LIFETIME={db['max_lifetime']}\", f\"DB_MAX_LIFETIME={db['max_lifetime']}\",
f\"DB_MAX_IDLE_TIME={db.get('max_idle_time', '0s')}\", f\"DB_MAX_IDLE_TIME={db.get('max_idle_time', '0s')}\",
# Redis (in-namespace DNS short form — password injected if configured; # Redis in-namespace DNS short form (works because pod /etc/resolv.conf
# short form works because /etc/resolv.conf in pods searches honeydue.svc.cluster.local) # searches honeydue.svc.cluster.local). Audit HIGH-1: the password is
f\"REDIS_URL=redis://{':%s@' % val(rd.get('password')) if rd.get('password') else ''}redis:6379/0\", # intentionally NOT embedded here. This URL is emitted into the
# honeydue-config ConfigMap, which is NOT encrypted at rest and is
# readable by anyone with `get configmap`. The Redis password travels
# only in honeydue-secrets as REDIS_PASSWORD (file-mounted, F8); the API
# applies it in cache_service.go and the worker onto its Asynq opt.
'REDIS_URL=redis://redis:6379/0',
'REDIS_DB=0', 'REDIS_DB=0',
# Email # Email
f\"EMAIL_HOST={em['host']}\", f\"EMAIL_HOST={em['host']}\",
@@ -218,8 +223,18 @@ config = {
'image': 'ubuntu-24.04', 'image': 'ubuntu-24.04',
}, },
'additional_packages': ['open-iscsi'], 'additional_packages': ['open-iscsi'],
'post_create_commands': ['sudo systemctl enable --now iscsid'], # Audit K3S-CG2: harden the node OS at provision time — fail2ban for SSH
'k3s_config_file': 'secrets-encryption: true\n', # brute-force, unattended-upgrades for automatic security patches.
'post_create_commands': [
'sudo systemctl enable --now iscsid',
'sudo apt-get update -qq',
'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq fail2ban unattended-upgrades',
'sudo systemctl enable --now fail2ban',
'sudo dpkg-reconfigure -f noninteractive -plow unattended-upgrades',
],
# Audit K3S-CG1 / K3S-F4: encrypt Secrets at rest in etcd, and write the
# node kubeconfig as mode 0600 (not world-readable).
'k3s_config_file': 'secrets-encryption: true\nwrite-kubeconfig-mode: \"0600\"\n',
} }
print(yaml.dump(config, default_flow_style=False, sort_keys=False)) print(yaml.dump(config, default_flow_style=False, sort_keys=False))
+28 -23
View File
@@ -8,6 +8,13 @@ long-haul components, and dedicated service accounts with dropped
capabilities inside containers. This chapter documents each layer, the capabilities inside containers. This chapter documents each layer, the
rationale, and what's currently missing (and why). rationale, and what's currently missing (and why).
> **Updated 2026-05-15 — security remediation.** The 2026-05 audits
> (`live_scan_5_12.md`, `k3_audit_5_12.md`, `security_scan_5_12.md`) drove a
> full remediation pass. **`deploy-k3s/SECURITY.md` is the authoritative,
> per-finding current-state record.** This chapter is corrected for the
> major items below; where any other detail conflicts with `SECURITY.md`,
> `SECURITY.md` wins.
## Threat model ## Threat model
Who we're defending against, in rough order of likelihood: Who we're defending against, in rough order of likelihood:
@@ -54,8 +61,8 @@ Cloudflare sits in front of every public request.
- **Authorize requests** — that's the app's job - **Authorize requests** — that's the app's job
- **Protect origin if origin IP leaks** — once someone knows a node IP - **Protect origin if origin IP leaks** — once someone knows a node IP
they can bypass CF. Mitigation: keep origin firewall strict (Chapter 4). they can bypass CF. Mitigation: keep origin firewall strict (Chapter 4).
- **Encrypt between CF and origin** — we're on SSL=Flexible, so CF↔origin - **~~Encrypt between CF and origin~~** — done (2026-04-24): SSL mode is
is HTTP. This is in our TODO (Chapter 20, upgrade to Full-strict). Full (strict); CF↔origin is TLS with a Cloudflare Origin CA cert.
### The proxy-IP problem ### The proxy-IP problem
@@ -75,8 +82,8 @@ This means a malicious request that bypasses CF (by hitting the node IP
directly) can't spoof headers — Traefik ignores `X-Forwarded-*` unless directly) can't spoof headers — Traefik ignores `X-Forwarded-*` unless
the source IP is in CF's ranges. the source IP is in CF's ranges.
**TODO** (Chapter 20): Enforce at UFW level — allow 80/tcp only from **Done (2026-04-24):** the node UFW allowlist permits `:443` only from
CF IP ranges. Today any IP can reach the origin on port 80. Cloudflare's IP ranges; the `Anywhere` rules on `:80`/`:443` were removed.
## Layer 2 — Node (OS, SSH, firewall) ## Layer 2 — Node (OS, SSH, firewall)
@@ -297,15 +304,13 @@ The `deploy-k3s/manifests/network-policies.yaml` scaffold defines:
reach api pods on port 8000 reach api pods on port 8000
- **allow-ingress-to-admin** — same, for admin:3000 - **allow-ingress-to-admin** — same, for admin:3000
**These are not currently applied.** Without them, our pods can freely **Applied.** `03-deploy.sh` applies
talk to anything — including, theoretically, malicious destinations if `deploy-k3s/manifests/network-policies.yaml` on every deploy — default-deny
an attacker gets RCE inside a pod. plus the explicit per-app allows below. Traefik runs `hostNetwork`, so its
traffic is matched by node-IP `ipBlock`s plus the pod CIDR `10.42.0.0/16`,
not a `namespaceSelector`.
**TODO** (Chapter 20): Apply network policies. The scaffold is there; we ### What network policies prevent
just need to `kubectl apply -f deploy-k3s/manifests/network-policies.yaml`
and test that nothing breaks.
### What network policies would prevent
| Attack scenario | NetworkPolicy blocks | | Attack scenario | NetworkPolicy blocks |
|---|---| |---|---|
@@ -324,13 +329,10 @@ renewed Let's Encrypt or CF-managed cert for `*.myhoneydue.com`.
### CF ↔ origin ### CF ↔ origin
**Plaintext HTTP** (SSL = Flexible). An attacker with access to the **TLS — SSL = Full (strict)** (since 2026-04-24). A Cloudflare Origin CA
Cloudflare-to-Hetzner path could read traffic. In practice nobody who certificate (`cloudflare-origin-cert` secret) is installed on all three
isn't Cloudflare or Hetzner sits on that path. ingresses; Cloudflare validates it. Both user↔CF and CF↔origin are
encrypted, and a DNS-hijack MitM is defeated by the origin-cert check.
**TODO** (Chapter 20): Upgrade to SSL = Full (strict) with a Cloudflare
Origin CA certificate. This encrypts CF ↔ origin and verifies that
origin's cert is the CF-issued one (prevents MitM if DNS is compromised).
### API ↔ Neon Postgres ### API ↔ Neon Postgres
@@ -454,11 +456,14 @@ Mitigations:
- Gitea itself is behind login; PAT is scoped to read:packages + - Gitea itself is behind login; PAT is scoped to read:packages +
write:packages only write:packages only
- Gitea runs on the operator's infrastructure (same operator account) - Gitea runs on the operator's infrastructure (same operator account)
- Image tags are SHA-pinned (`:237c6b8`) not `:latest` → attacker can't - Workloads deploy by immutable `@sha256:` digest, not by mutable tag
replace an existing tag's image without us noticing the digest change (`03-deploy.sh` resolves the digest after push; the redis/vmagent/node
base images are digest-pinned too) — a swapped tag cannot reach the
cluster.
**TODO** (Chapter 20): Add cosign signing at build time, verify at pull **TODO**: cosign signing is wired into `03-deploy.sh` (guarded — runs when
time. `cosign` + `COSIGN_KEY` are present); cluster-side admission verification
(Kyverno/Connaisseur) is still pending. See `deploy-k3s/SECURITY.md` → L5.
## Operator workstation security ## Operator workstation security
+8
View File
@@ -1,5 +1,13 @@
# 06 — Traefik Ingress # 06 — Traefik Ingress
> **Updated 2026-05-15 (security remediation):** the Traefik middleware set
> changed — `cloudflare-only` + `admin-auth` are now attached to the admin
> ingress, a strict `auth-rate-limit` middleware fronts the auth endpoints
> (via a dedicated `honeydue-api-auth` Ingress), and `security-headers`
> gained COOP/CORP + a 2-year preload HSTS and dropped the deprecated
> `X-XSS-Protection`. `deploy-k3s/SECURITY.md` is the authoritative
> current-state record.
## Summary ## Summary
Traefik is the reverse proxy that routes external HTTP requests to the Traefik is the reverse proxy that routes external HTTP requests to the
+6
View File
@@ -1,5 +1,11 @@
# 07 — Services # 07 — Services
> **Updated 2026-05-15 (security remediation):** Redis now requires a
> password (`config.yaml` `redis.password` → `honeydue-secrets`), all
> workloads deploy by immutable `@sha256:` digest, and the redis/vmagent
> base images are digest-pinned. `deploy-k3s/SECURITY.md` is the
> authoritative current-state record.
## Summary ## Summary
Five workloads run in the `honeydue` namespace: **api** (Go REST API, 3 Five workloads run in the `honeydue` namespace: **api** (Go REST API, 3
+6
View File
@@ -1,5 +1,11 @@
# 10 — Secrets & Config # 10 — Secrets & Config
> **Updated 2026-05-15 (security remediation):** `honeydue-secrets` now
> carries `REDIS_PASSWORD`; an `admin-basic-auth` Secret backs the admin
> ingress; rotation is documented in `docs/runbooks/secret-rotation.md`;
> and the Go config can read file-mounted secrets (`HONEYDUE_SECRETS_DIR`).
> `deploy-k3s/SECURITY.md` is the authoritative current-state record.
## Summary ## Summary
Non-sensitive config (hostnames, ports, feature flags, etc.) lives in Non-sensitive config (hostnames, ports, feature flags, etc.) lives in
+146
View File
@@ -0,0 +1,146 @@
# Runbook — Secret Rotation
Closes audit finding `K3S-F12` (secrets unrotated since cluster bootstrap,
no rotation cadence). See `deploy-k3s/SECURITY.md` Stage 2.
**Cadence:** rotate every secret at least **annually**. Rotate
**immediately** on suspected exposure, on an operator-device loss, or when
anyone who has seen a secret leaves the project.
**Record keeping:** after each rotation, annotate the secret so the age is
visible:
```bash
kubectl -n honeydue annotate secret <name> \
honeydue.dev/last-rotated="$(date -u +%Y-%m-%d)" --overwrite
```
---
## How rotation works
Every secret has a **source of truth** on the operator workstation. The
deploy scripts read those sources and (re)create the Kubernetes Secrets.
Rotation is always: **update the source → re-run `02-setup-secrets.sh`
restart the pods that consume it → revoke the old credential at its
provider.**
`02-setup-secrets.sh` uses `kubectl apply` (via `--dry-run=client -o yaml`),
so re-running it is idempotent and only changes what you changed.
| Kubernetes Secret | Source of truth | Consumed by |
|---|---|---|
| `honeydue-secrets``POSTGRES_PASSWORD` | `deploy-k3s/secrets/postgres_password.txt` | api, worker |
| `honeydue-secrets``SECRET_KEY` | `deploy-k3s/secrets/secret_key.txt` | api, worker |
| `honeydue-secrets``EMAIL_HOST_PASSWORD` | `deploy-k3s/secrets/email_host_password.txt` | api, worker |
| `honeydue-secrets``FCM_SERVER_KEY` | `deploy-k3s/secrets/fcm_server_key.txt` | api, worker |
| `honeydue-secrets``REDIS_PASSWORD` | `config.yaml` key `redis.password` | api, worker, redis |
| `honeydue-secrets``OBS_INGEST_TOKEN` | `deploy/prod.env` | api, worker |
| `honeydue-apns-key``apns_auth_key.p8` | `deploy-k3s/secrets/apns_auth_key.p8` | api, worker |
| `cloudflare-origin-cert` | `deploy-k3s/secrets/cloudflare-origin.{crt,key}` | Traefik ingress |
| `ghcr-credentials` | `config.yaml` block `registry.*` | image pulls (all pods) |
| `admin-basic-auth` | `config.yaml` keys `admin.basic_auth_user` / `..._password` | Traefik `admin-auth` middleware |
The `deploy-k3s/secrets/` directory and `config.yaml` are **gitignored**
never commit them.
---
## Standard rotation procedure
```bash
cd honeyDueAPI-go
export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig"
# 1. Update the source (file under deploy-k3s/secrets/ or a config.yaml key)
# 2. Recreate the Kubernetes Secrets from sources
./deploy-k3s/scripts/02-setup-secrets.sh
# 3. Restart the consumers (see per-secret notes below for which)
kubectl -n honeydue rollout restart deploy/api deploy/worker
# 4. Confirm health
kubectl -n honeydue rollout status deploy/api
kubectl -n honeydue rollout status deploy/worker
# 5. Revoke the OLD credential at its provider (see per-secret notes)
# 6. Annotate the rotated secret with today's date
```
---
## Per-secret notes
### `POSTGRES_PASSWORD`
1. Rotate the role password in the Neon dashboard.
2. Write the new value to `deploy-k3s/secrets/postgres_password.txt`.
3. `02-setup-secrets.sh`, then `rollout restart deploy/api deploy/worker`.
4. Watch logs for connection errors; the old password stops working the
moment Neon applies the change, so do steps 23 promptly.
### `SECRET_KEY` ⚠️ user-visible
This signs auth tokens. **Rotating it logs every user out** — all existing
tokens become invalid and every client must re-authenticate.
1. Generate: `openssl rand -hex 32`.
2. Write to `deploy-k3s/secrets/secret_key.txt` (must be ≥32 chars — the
script enforces this; the app refuses to start in production without it).
3. `02-setup-secrets.sh`, then `rollout restart deploy/api deploy/worker`.
- Only rotate on a schedule or on suspected compromise — not casually.
- A future improvement (overlap window via a key-id header) would let old
tokens validate during the transition; not implemented today.
### `EMAIL_HOST_PASSWORD`
1. Generate a new app password in Fastmail; keep the old one alive briefly.
2. Write to `deploy-k3s/secrets/email_host_password.txt`.
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
4. Delete the old Fastmail app password.
### `FCM_SERVER_KEY`
1. Rotate the key in the Firebase console.
2. Write to `deploy-k3s/secrets/fcm_server_key.txt`.
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
### `REDIS_PASSWORD`
Source is `config.yaml` key `redis.password` (hex only — it is embedded in
the `REDIS_URL`, so non-hex characters would break URL parsing).
1. Generate: `openssl rand -hex 32`.
2. Set `redis.password` in `config.yaml`.
3. `02-setup-secrets.sh`.
4. Restart **redis as well as** api/worker so the new `--requirepass` and
the new `REDIS_URL` land together:
`kubectl -n honeydue rollout restart deploy/redis deploy/api deploy/worker`.
Expect a few seconds where api/worker reconnect.
### `apns_auth_key.p8`
1. Revoke the key in the Apple Developer console, generate a new `.p8`.
2. Replace `deploy-k3s/secrets/apns_auth_key.p8`.
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
4. If the Key ID changed, update `push.apns_key_id` in `config.yaml` too.
### `cloudflare-origin-cert`
1. Generate a new Origin CA certificate in the Cloudflare dashboard.
2. Replace `deploy-k3s/secrets/cloudflare-origin.crt` and `.key`.
3. `02-setup-secrets.sh`. Traefik picks up the new TLS secret; no app
restart needed. Verify the served cert with `openssl s_client`.
### `ghcr-credentials` (Gitea registry)
1. Generate a new PAT in Gitea (scope: `read:packages`).
2. Update the `registry.token` value in `config.yaml`.
3. `02-setup-secrets.sh`. No restart needed unless a pull is pending.
4. Revoke the old PAT in Gitea.
### `admin-basic-auth`
Source is `config.yaml` keys `admin.basic_auth_user` / `basic_auth_password`.
1. Set a new password (e.g. `openssl rand -hex 24`).
2. `02-setup-secrets.sh` regenerates the bcrypt htpasswd secret.
3. No app restart needed — Traefik reloads the `admin-auth` middleware.
4. Distribute the new credential to whoever uses the admin panel.
---
## After any rotation
- Run `./deploy-k3s/scripts/04-verify.sh` and confirm no `✗` lines.
- Annotate the rotated secret (see "Record keeping" above).
- If the rotation was due to a compromise, also follow the relevant
playbook in `deploy-k3s/SECURITY.md` → Appendix (Incident response).
+6 -6
View File
@@ -27,10 +27,10 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.51.0
golang.org/x/oauth2 v0.35.0 golang.org/x/oauth2 v0.35.0
golang.org/x/term v0.41.0 golang.org/x/term v0.43.0
golang.org/x/text v0.35.0 golang.org/x/text v0.37.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
google.golang.org/api v0.257.0 google.golang.org/api v0.257.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@@ -117,9 +117,9 @@ require (
go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
+10 -10
View File
@@ -241,12 +241,12 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -262,16 +262,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+70 -5
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/url" "net/url"
@@ -216,6 +217,11 @@ func Load() (*Config, error) {
// Set defaults // Set defaults
setDefaults() setDefaults()
// Audit F8: overlay file-mounted secrets onto Viper. No-op when the
// directory is absent (local/dev), so this is safe to ship before the
// manifests mount honeydue-secrets as a volume.
loadFileSecrets()
// Parse DATABASE_URL if set (Dokku-style) // Parse DATABASE_URL if set (Dokku-style)
dbConfig := DatabaseConfig{ dbConfig := DatabaseConfig{
Host: viper.GetString("DB_HOST"), Host: viper.GetString("DB_HOST"),
@@ -432,14 +438,67 @@ func isWeakSecretKey(key string) bool {
return knownWeakSecretKeys[strings.ToLower(strings.TrimSpace(key))] return knownWeakSecretKeys[strings.ToLower(strings.TrimSpace(key))]
} }
// loadFileSecrets overlays file-mounted secrets onto Viper (audit F8). When
// the honeydue-secrets Secret is mounted as a volume at /etc/honeydue/secrets
// each key is a file; reading the value here and viper.Set-ing it (highest
// Viper precedence) keeps the secret out of the process environment
// (/proc/<pid>/environ), which plain env-var injection cannot. When the
// directory is absent it is a silent no-op and env vars are used as before.
func loadFileSecrets() {
dir := os.Getenv("HONEYDUE_SECRETS_DIR")
if dir == "" {
dir = "/etc/honeydue/secrets"
}
for _, k := range []string{
"POSTGRES_PASSWORD", "SECRET_KEY", "EMAIL_HOST_PASSWORD", "FCM_SERVER_KEY",
"REDIS_PASSWORD", "B2_KEY_ID", "B2_APP_KEY", "OBS_INGEST_TOKEN", "OBS_TRACES_URL",
} {
b, err := os.ReadFile(dir + "/" + k)
if err != nil {
continue
}
if v := strings.TrimSpace(string(b)); v != "" {
viper.Set(k, v)
}
}
}
// SecretValue resolves a configuration value that is not part of the typed
// Config struct. It reads through Viper, so a value supplied via a file-mounted
// secret (audit F8, loaded by loadFileSecrets) is found just like an env var.
//
// Must be called after Load(). Used by cmd/api and cmd/worker for the
// observability endpoints, which are needed before the full Config is wired
// and would otherwise be read with os.Getenv — which misses file-mounted
// secrets entirely once F8 removes them from the process environment.
func SecretValue(key string) string {
return viper.GetString(key)
}
// randomHexKey returns a cryptographically secure random hex string
// representing n random bytes (2n hex characters).
func randomHexKey(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func validate(cfg *Config) error { func validate(cfg *Config) error {
// S-08: Validate SECRET_KEY against known weak defaults // M8: SECRET_KEY validation — no static fallback secret in the binary.
if cfg.Security.SecretKey == "" { if cfg.Security.SecretKey == "" {
if cfg.Server.Debug { if cfg.Server.Debug {
// In debug mode, use a default key with a warning for local development // Debug only: generate a random key per boot. Tokens signed with
cfg.Security.SecretKey = "change-me-in-production-secret-key-12345" // it do not survive a restart, which is acceptable for local dev
fmt.Println("WARNING: SECRET_KEY not set, using default (debug mode only)") // and far safer than a well-known hardcoded fallback.
fmt.Println("WARNING: *** DO NOT USE THIS DEFAULT KEY IN PRODUCTION ***") randomKey, err := randomHexKey(32)
if err != nil {
return fmt.Errorf("failed to generate ephemeral debug SECRET_KEY: %w", err)
}
cfg.Security.SecretKey = randomKey
fmt.Println("WARNING: SECRET_KEY not set, generated an ephemeral random key (debug mode only)")
fmt.Println("WARNING: tokens will not survive a restart — set SECRET_KEY for stable local sessions")
} else { } else {
// In production, refuse to start without a proper secret key // In production, refuse to start without a proper secret key
return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)") return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)")
@@ -452,6 +511,12 @@ func validate(cfg *Config) error {
} }
} }
// C4: fixed confirmation codes ("123456") must never be enabled outside
// debug — with DEBUG=false they are a full authentication bypass.
if cfg.Server.DebugFixedCodes && !cfg.Server.Debug {
return fmt.Errorf("FATAL: DEBUG_FIXED_CODES is enabled with DEBUG=false — fixed confirmation codes must never run in production")
}
// Database password might come from DATABASE_URL, don't require it separately // Database password might come from DATABASE_URL, don't require it separately
// The actual connection will fail if credentials are wrong // The actual connection will fail if credentials are wrong
+31 -2
View File
@@ -106,8 +106,10 @@ func TestLoad_Validation_MissingSecretKey_DebugMode(t *testing.T) {
c, err := Load() c, err := Load()
require.NoError(t, err) require.NoError(t, err)
// In debug mode, a default key is assigned // Audit M8: in debug mode an ephemeral random key is generated per boot
assert.Equal(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey) // (no static fallback). It must be a non-empty 64-char hex string.
assert.Len(t, c.Security.SecretKey, 64)
assert.NotEqual(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey)
} }
func TestLoad_Validation_WeakSecretKey_Production(t *testing.T) { func TestLoad_Validation_WeakSecretKey_Production(t *testing.T) {
@@ -133,6 +135,33 @@ func TestLoad_Validation_WeakSecretKey_DebugMode(t *testing.T) {
assert.Equal(t, "secret", c.Security.SecretKey) assert.Equal(t, "secret", c.Security.SecretKey)
} }
// Audit C4: DEBUG_FIXED_CODES makes confirmation codes a fixed "123456" — a
// full authentication bypass. With DEBUG=false, validate() must refuse to boot
// rather than ship that bypass to production.
func TestLoad_Validation_DebugFixedCodes_Production(t *testing.T) {
// validate() directly — avoids the sync.Once issue Load() has on failure.
cfg := &Config{
Server: ServerConfig{Debug: false, DebugFixedCodes: true},
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
}
err := validate(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "DEBUG_FIXED_CODES")
}
// With DEBUG=true the fixed codes are an intended local-dev convenience, so
// the same combination must NOT error.
func TestLoad_Validation_DebugFixedCodes_DebugMode(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Debug: true, DebugFixedCodes: true},
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
}
err := validate(cfg)
require.NoError(t, err)
}
func TestLoad_Validation_EncryptionKey_Valid(t *testing.T) { func TestLoad_Validation_EncryptionKey_Valid(t *testing.T) {
resetConfigState() resetConfigState()
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests") t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
+45 -28
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
@@ -55,8 +56,15 @@ func (h *AuthHandler) SetAuditService(auditService *services.AuditService) {
h.auditService = auditService h.auditService = auditService
} }
// noStore marks a response as non-cacheable (audit L2) — auth responses
// carry tokens and user data that must never sit in any cache.
func noStore(c echo.Context) {
c.Response().Header().Set("Cache-Control", "no-store")
}
// Login handles POST /api/auth/login/ // Login handles POST /api/auth/login/
func (h *AuthHandler) Login(c echo.Context) error { func (h *AuthHandler) Login(c echo.Context) error {
noStore(c)
var req requests.LoginRequest var req requests.LoginRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request") return apperrors.BadRequest("error.invalid_request")
@@ -65,9 +73,11 @@ func (h *AuthHandler) Login(c echo.Context) error {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
} }
response, err := h.authService.Login(c.Request().Context(), &req) response, err := h.authService.Login(c.Request().Context(), &req, c.RealIP())
if err != nil { if err != nil {
log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed") log.Debug().Err(err).Str("identifier", req.Username).
Str("ip", c.RealIP()).Str("user_agent", c.Request().UserAgent()).
Msg("Login failed")
if h.auditService != nil { if h.auditService != nil {
h.auditService.LogEvent(c, nil, services.AuditEventLoginFailed, map[string]interface{}{ h.auditService.LogEvent(c, nil, services.AuditEventLoginFailed, map[string]interface{}{
"identifier": req.Username, "identifier": req.Username,
@@ -86,6 +96,7 @@ func (h *AuthHandler) Login(c echo.Context) error {
// Register handles POST /api/auth/register/ // Register handles POST /api/auth/register/
func (h *AuthHandler) Register(c echo.Context) error { func (h *AuthHandler) Register(c echo.Context) error {
noStore(c)
var req requests.RegisterRequest var req requests.RegisterRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request") return apperrors.BadRequest("error.invalid_request")
@@ -157,6 +168,7 @@ func (h *AuthHandler) Logout(c echo.Context) error {
// CurrentUser handles GET /api/auth/me/ // CurrentUser handles GET /api/auth/me/
func (h *AuthHandler) CurrentUser(c echo.Context) error { func (h *AuthHandler) CurrentUser(c echo.Context) error {
noStore(c)
user, err := middleware.MustGetAuthUser(c) user, err := middleware.MustGetAuthUser(c)
if err != nil { if err != nil {
return err return err
@@ -276,31 +288,7 @@ func (h *AuthHandler) ForgotPassword(c echo.Context) error {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
} }
code, user, err := h.authService.ForgotPassword(c.Request().Context(), req.Email) noStore(c)
if err != nil {
var appErr *apperrors.AppError
if errors.As(err, &appErr) && appErr.Code == http.StatusTooManyRequests {
// Only reveal rate limit errors
return err
}
log.Error().Err(err).Str("email", req.Email).Msg("Forgot password failed")
// Don't reveal other errors to prevent email enumeration
}
// Send password reset email (async) - only if user found
if h.emailService != nil && code != "" && user != nil {
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in password reset email goroutine")
}
}()
if err := h.emailService.SendPasswordResetEmail(user.Email, user.FirstName, code); err != nil {
log.Error().Err(err).Str("email", user.Email).Msg("Failed to send password reset email")
}
}()
}
if h.auditService != nil { if h.auditService != nil {
h.auditService.LogEvent(c, nil, services.AuditEventPasswordReset, map[string]interface{}{ h.auditService.LogEvent(c, nil, services.AuditEventPasswordReset, map[string]interface{}{
@@ -308,7 +296,33 @@ func (h *AuthHandler) ForgotPassword(c echo.Context) error {
}) })
} }
// Always return success to prevent email enumeration // Audit LIVE-L13: run the user lookup, code generation, and email send
// entirely in the background, then return the generic response
// immediately. This makes the response time identical whether or not
// the email belongs to a real account, defeating timing-based user
// enumeration. context.Background() is used because the request context
// is cancelled the moment this handler returns. Per-account rate
// limiting still runs inside the service; the edge auth-rate-limit
// middleware covers per-IP abuse.
email := req.Email
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("email", email).Msg("Panic in forgot-password goroutine")
}
}()
code, user, err := h.authService.ForgotPassword(context.Background(), email)
if err != nil || code == "" || user == nil {
return
}
if h.emailService != nil {
if sendErr := h.emailService.SendPasswordResetEmail(user.Email, user.FirstName, code); sendErr != nil {
log.Error().Err(sendErr).Str("email", user.Email).Msg("Failed to send password reset email")
}
}
}()
// Always return success to prevent email enumeration.
return c.JSON(http.StatusOK, responses.ForgotPasswordResponse{ return c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: "Password reset email sent", Message: "Password reset email sent",
}) })
@@ -365,6 +379,7 @@ func (h *AuthHandler) ResetPassword(c echo.Context) error {
// AppleSignIn handles POST /api/auth/apple-sign-in/ // AppleSignIn handles POST /api/auth/apple-sign-in/
func (h *AuthHandler) AppleSignIn(c echo.Context) error { func (h *AuthHandler) AppleSignIn(c echo.Context) error {
noStore(c)
var req requests.AppleSignInRequest var req requests.AppleSignInRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request") return apperrors.BadRequest("error.invalid_request")
@@ -412,6 +427,7 @@ func (h *AuthHandler) AppleSignIn(c echo.Context) error {
// GoogleSignIn handles POST /api/auth/google-sign-in/ // GoogleSignIn handles POST /api/auth/google-sign-in/
func (h *AuthHandler) GoogleSignIn(c echo.Context) error { func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
noStore(c)
var req requests.GoogleSignInRequest var req requests.GoogleSignInRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request") return apperrors.BadRequest("error.invalid_request")
@@ -459,6 +475,7 @@ func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
// RefreshToken handles POST /api/auth/refresh/ // RefreshToken handles POST /api/auth/refresh/
func (h *AuthHandler) RefreshToken(c echo.Context) error { func (h *AuthHandler) RefreshToken(c echo.Context) error {
noStore(c)
user, err := middleware.MustGetAuthUser(c) user, err := middleware.MustGetAuthUser(c)
if err != nil { if err != nil {
return err return err
+2 -2
View File
@@ -650,14 +650,14 @@ func TestAuthHandler_RefreshToken(t *testing.T) {
authGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc { authGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
c.Set("auth_user", user) c.Set("auth_user", user)
c.Set("auth_token", authToken.Key) c.Set("auth_token", authToken.Plaintext) // raw token — repo hashes for lookup (audit C1)
return next(c) return next(c)
} }
}) })
authGroup.POST("/refresh/", handler.RefreshToken) authGroup.POST("/refresh/", handler.RefreshToken)
t.Run("successful refresh", func(t *testing.T) { t.Run("successful refresh", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/auth/refresh/", nil, authToken.Key) w := testutil.MakeRequest(e, "POST", "/api/auth/refresh/", nil, authToken.Plaintext)
testutil.AssertStatusCode(t, w, http.StatusOK) testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{} var response map[string]interface{}
+20 -3
View File
@@ -37,6 +37,23 @@ func NewMediaHandler(
} }
} }
// safeContentDisposition builds an inline Content-Disposition header value
// with a sanitized filename (audit M1). Control characters (including CR/LF),
// double-quote and backslash are stripped so an attacker-controlled upload
// filename cannot inject additional response headers (CWE-113).
func safeContentDisposition(filename string) string {
cleaned := strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' {
return -1
}
return r
}, filename)
if cleaned == "" {
cleaned = "download"
}
return `inline; filename="` + cleaned + `"`
}
// ServeDocument serves a document file with access control // ServeDocument serves a document file with access control
// GET /api/media/document/:id // GET /api/media/document/:id
func (h *MediaHandler) ServeDocument(c echo.Context) error { func (h *MediaHandler) ServeDocument(c echo.Context) error {
@@ -71,7 +88,7 @@ func (h *MediaHandler) ServeDocument(c echo.Context) error {
// Set caching and disposition headers // Set caching and disposition headers
c.Response().Header().Set("Cache-Control", "private, max-age=3600") c.Response().Header().Set("Cache-Control", "private, max-age=3600")
if doc.FileName != "" { if doc.FileName != "" {
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+doc.FileName+"\"") c.Response().Header().Set("Content-Disposition", safeContentDisposition(doc.FileName))
} }
return c.Blob(http.StatusOK, mimeType, data) return c.Blob(http.StatusOK, mimeType, data)
} }
@@ -114,7 +131,7 @@ func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
} }
c.Response().Header().Set("Cache-Control", "private, max-age=3600") c.Response().Header().Set("Cache-Control", "private, max-age=3600")
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"") c.Response().Header().Set("Content-Disposition", safeContentDisposition(filepath.Base(img.ImageURL)))
return c.Blob(http.StatusOK, mimeType, data) return c.Blob(http.StatusOK, mimeType, data)
} }
@@ -162,7 +179,7 @@ func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
} }
c.Response().Header().Set("Cache-Control", "private, max-age=3600") c.Response().Header().Set("Cache-Control", "private, max-age=3600")
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"") c.Response().Header().Set("Content-Disposition", safeContentDisposition(filepath.Base(img.ImageURL)))
return c.Blob(http.StatusOK, mimeType, data) return c.Blob(http.StatusOK, mimeType, data)
} }
@@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"io" "io"
"math/big" "math/big"
@@ -20,6 +21,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/config" "github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/models"
@@ -165,9 +167,13 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
if notification.NotificationUUID != "" { if notification.NotificationUUID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID) alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Apple Webhook: Failed to check dedup") // Audit H6: fail closed. A dedup-check failure must not let a
// Continue processing on dedup check failure (fail-open) // possibly-duplicate event through (duplicate refunds/grants).
} else if alreadyProcessed { // Return 500 so Apple retries once the DB is healthy.
log.Error().Err(err).Msg("Apple Webhook: dedup check failed — returning 500 for retry")
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "dedup check failed"})
}
if alreadyProcessed {
log.Info().Str("uuid", notification.NotificationUUID).Msg("Apple Webhook: Duplicate event, skipping") log.Info().Str("uuid", notification.NotificationUUID).Msg("Apple Webhook: Duplicate event, skipping")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"}) return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
} }
@@ -352,10 +358,24 @@ func (h *SubscriptionWebhookHandler) processAppleNotification(
} }
func (h *SubscriptionWebhookHandler) findUserByAppleTransaction(originalTransactionID string) (*models.User, error) { func (h *SubscriptionWebhookHandler) findUserByAppleTransaction(originalTransactionID string) (*models.User, error) {
// Look up user subscription by stored receipt data // Audit C13: exact match on the indexed apple_original_transaction_id
subscription, err := h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID) // column. Falls back to the legacy escaped-LIKE scan over
// apple_receipt_data only for subscriptions created before that column
// existed (and thus not yet populated).
subscription, err := h.subscriptionRepo.FindByAppleOriginalTransactionID(originalTransactionID)
if err != nil { if err != nil {
return nil, err // Only fall back to the legacy substring scan when the exact-match
// column genuinely had no row (a subscription created before the
// column existed). A real DB error must propagate — masking it as
// "not found" could bind the webhook to the wrong account via the
// LIKE scan, or silently drop a legitimate event.
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
subscription, err = h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID)
if err != nil {
return nil, err
}
} }
user, err := h.userRepo.FindByID(subscription.UserID) user, err := h.userRepo.FindByID(subscription.UserID)
@@ -566,9 +586,12 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
if messageID != "" { if messageID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID) alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Google Webhook: Failed to check dedup") // Audit H6: fail closed — see the Apple handler. Return 500 so
// Continue processing on dedup check failure (fail-open) // Google Pub/Sub redelivers once the DB is healthy.
} else if alreadyProcessed { log.Error().Err(err).Msg("Google Webhook: dedup check failed — returning 500 for retry")
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "dedup check failed"})
}
if alreadyProcessed {
log.Info().Str("message_id", messageID).Msg("Google Webhook: Duplicate event, skipping") log.Info().Str("message_id", messageID).Msg("Google Webhook: Duplicate event, skipping")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"}) return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
} }
+29 -1
View File
@@ -169,6 +169,34 @@ func (m *AuthMiddleware) OptionalTokenAuth() echo.MiddlewareFunc {
} }
} }
// RequireVerified returns middleware that rejects users whose email is not
// verified (audit LIVE-L19). Apply it after TokenAuth to gate sensitive
// actions — e.g. generating residence share codes — behind proof that the
// account actually controls its email address.
func (m *AuthMiddleware) RequireVerified() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user := GetAuthUser(c)
if user == nil {
return apperrors.Unauthorized("error.not_authenticated")
}
var verified bool
err := m.db.WithContext(c.Request().Context()).
Model(&models.UserProfile{}).
Where("user_id = ?", user.ID).
Select("verified").
Scan(&verified).Error
if err != nil {
return apperrors.Internal(err)
}
if !verified {
return apperrors.Forbidden("error.email_not_verified")
}
return next(c)
}
}
}
// extractToken extracts the token from the Authorization header // extractToken extracts the token from the Authorization header
func extractToken(c echo.Context) (string, error) { func extractToken(c echo.Context) (string, error) {
authHeader := c.Request().Header.Get("Authorization") authHeader := c.Request().Header.Get("Authorization")
@@ -297,7 +325,7 @@ func (m *AuthMiddleware) getUserFromDatabaseWithToken(ctx context.Context, token
u.last_login AS u_last_login u.last_login AS u_last_login
`). `).
Joins("INNER JOIN auth_user u ON u.id = t.user_id"). Joins("INNER JOIN auth_user u ON u.id = t.user_id").
Where("t.key = ?", token). Where("t.key = ?", models.HashToken(token)). // audit C1: only the hash is stored
Limit(1). Limit(1).
Scan(&row).Error Scan(&row).Error
if err != nil || row.Key == "" { if err != nil || row.Key == "" {
+3 -3
View File
@@ -65,7 +65,7 @@ func TestTokenAuth_RejectsExpiredToken(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key) req.Header.Set("Authorization", "Token "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
@@ -86,7 +86,7 @@ func TestTokenAuth_AcceptsValidToken(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key) req.Header.Set("Authorization", "Token "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
@@ -112,7 +112,7 @@ func TestTokenAuth_AcceptsTokenAtBoundary(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key) req.Header.Set("Authorization", "Token "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
+7 -7
View File
@@ -21,7 +21,7 @@ func TestTokenAuth_BearerScheme_Accepted(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Bearer "+token.Key) req.Header.Set("Authorization", "Bearer "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
@@ -46,7 +46,7 @@ func TestTokenAuth_InvalidScheme_Rejected(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Basic "+token.Key) req.Header.Set("Authorization", "Basic "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
@@ -110,7 +110,7 @@ func TestTokenAuth_InactiveUser_Rejected(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key) req.Header.Set("Authorization", "Token "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
@@ -156,7 +156,7 @@ func TestOptionalTokenAuth_ValidToken_SetsUser(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key) req.Header.Set("Authorization", "Token "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
@@ -182,7 +182,7 @@ func TestOptionalTokenAuth_ExpiredToken_IgnoresUser(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key) req.Header.Set("Authorization", "Token "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
@@ -242,7 +242,7 @@ func TestNewAuthMiddlewareWithConfig_CustomExpiryDays(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key) req.Header.Set("Authorization", "Token "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
@@ -270,7 +270,7 @@ func TestNewAuthMiddlewareWithConfig_ExpiredWithCustomExpiry(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil) req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key) req.Header.Set("Authorization", "Token "+token.Plaintext)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
+14 -12
View File
@@ -99,21 +99,23 @@ func parseTimezone(tz string) *time.Location {
return loc return loc
} }
// Try parsing as UTC offset (e.g., "-08:00", "+05:30") // Try parsing as a UTC offset (e.g., "-08:00", "+05:30"). Audit H8:
// We parse a reference time with the given offset to extract the offset value // reject absurd offsets — real timezones are within ±14h of UTC — so a
t, err := time.Parse("-07:00", tz) // crafted X-Timezone header cannot shift date math arbitrarily.
if err == nil { const maxOffsetSeconds = 14 * 3600
// time.Parse returns a time, we need to extract the offset if t, err := time.Parse("-07:00", tz); err == nil {
// The parsed time will have the offset embedded if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
_, offset := t.Zone() return time.FixedZone(tz, offset)
return time.FixedZone(tz, offset) }
return time.UTC
} }
// Also try without colon (e.g., "-0800") // Also try without colon (e.g., "-0800")
t, err = time.Parse("-0700", tz) if t, err := time.Parse("-0700", tz); err == nil {
if err == nil { if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
_, offset := t.Zone() return time.FixedZone(tz, offset)
return time.FixedZone(tz, offset) }
return time.UTC
} }
// Default to UTC // Default to UTC
+2 -1
View File
@@ -252,7 +252,8 @@ func TestAuthToken_BeforeCreate_GeneratesKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, token.Key) assert.NotEmpty(t, token.Key)
assert.Len(t, token.Key, 40) // 20 bytes = 40 hex chars assert.Len(t, token.Key, 64) // SHA-256 hex hash (audit C1)
assert.Len(t, token.Plaintext, 40) // raw 20-byte token, returned to the client
assert.False(t, token.Created.IsZero()) assert.False(t, token.Created.IsZero())
} }
+3
View File
@@ -43,6 +43,9 @@ type UserSubscription struct {
// In-App Purchase data (Apple / Google) // In-App Purchase data (Apple / Google)
AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"` AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"`
GooglePurchaseToken *string `gorm:"column:google_purchase_token;type:text" json:"-"` GooglePurchaseToken *string `gorm:"column:google_purchase_token;type:text" json:"-"`
// AppleOriginalTransactionID binds an Apple subscription to one account
// (audit C5/C13). A partial unique index enforces one-account-per-txn.
AppleOriginalTransactionID *string `gorm:"column:apple_original_transaction_id;type:text" json:"-"`
// Stripe data (web subscriptions) // Stripe data (web subscriptions)
StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"` StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"`
+55 -20
View File
@@ -2,7 +2,10 @@ package models
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex" "encoding/hex"
"fmt"
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -37,14 +40,16 @@ func (User) TableName() string {
return "auth_user" return "auth_user"
} }
// BcryptCost is the bcrypt work factor for password and code hashing.
// 12 (audit M2) is stronger than bcrypt.DefaultCost (10).
const BcryptCost = 12
// SetPassword hashes and sets the password // SetPassword hashes and sets the password
func (u *User) SetPassword(password string) error { func (u *User) SetPassword(password string) error {
// Django uses PBKDF2_SHA256 by default, but we'll use bcrypt for Go // Django uses PBKDF2_SHA256 by default, but we use bcrypt for Go.
// Note: This means passwords set by Django won't work with Go's check // Passwords set by Django won't verify with Go's bcrypt check — those
// For migration, you'd need to either: // users must reset their password after migration.
// 1. Force password reset for all users hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
// 2. Implement Django's PBKDF2 hasher in Go
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return err return err
} }
@@ -69,12 +74,22 @@ func (u *User) GetFullName() string {
return u.Username return u.Username
} }
// AuthToken represents the user_authtoken table // AuthToken represents the user_authtoken table.
//
// Audit C1: the Key column stores the SHA-256 hash of the token, never the
// token itself. The raw token is handed to the client exactly once, at
// creation, via the non-persisted Plaintext field — it is never stored or
// logged. A database compromise therefore yields no usable session tokens.
type AuthToken struct { type AuthToken struct {
Key string `gorm:"column:key;primaryKey;size:40" json:"key"` Key string `gorm:"column:key;primaryKey;size:64" json:"-"`
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"` UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
Created time.Time `gorm:"column:created;autoCreateTime" json:"created"` Created time.Time `gorm:"column:created;autoCreateTime" json:"created"`
// Plaintext is the raw token value. It is never persisted (gorm:"-")
// and is only populated on a freshly-created token so the caller can
// return it to the client. On a token loaded from the DB it is "".
Plaintext string `gorm:"-" json:"-"`
// Relations // Relations
User User `gorm:"foreignKey:UserID" json:"-"` User User `gorm:"foreignKey:UserID" json:"-"`
} }
@@ -84,10 +99,13 @@ func (AuthToken) TableName() string {
return "user_authtoken" return "user_authtoken"
} }
// BeforeCreate generates a token key if not provided // BeforeCreate generates a token if one is not already set, storing only
// its hash in Key and the raw value in the non-persisted Plaintext field.
func (t *AuthToken) BeforeCreate(tx *gorm.DB) error { func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
if t.Key == "" { if t.Key == "" {
t.Key = generateToken() raw := generateToken()
t.Plaintext = raw
t.Key = HashToken(raw)
} }
if t.Created.IsZero() { if t.Created.IsZero() {
t.Created = time.Now().UTC() t.Created = time.Now().UTC()
@@ -95,13 +113,23 @@ func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
return nil return nil
} }
// generateToken creates a random 40-character hex token // generateToken creates a random 40-character hex token (the raw value).
func generateToken() string { func generateToken() string {
b := make([]byte, 20) b := make([]byte, 20)
rand.Read(b) rand.Read(b)
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// HashToken returns the at-rest representation of an auth token: the
// hex-encoded SHA-256 hash. Auth tokens are 160-bit random values, so a
// fast deterministic hash is appropriate — there is nothing to brute-force,
// and determinism preserves the single indexed-lookup query in the auth
// middleware. The raw token is never stored.
func HashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// GetOrCreate gets an existing token or creates a new one for the user // GetOrCreate gets an existing token or creates a new one for the user
func GetOrCreateToken(tx *gorm.DB, userID uint) (*AuthToken, error) { func GetOrCreateToken(tx *gorm.DB, userID uint) (*AuthToken, error) {
var token AuthToken var token AuthToken
@@ -160,15 +188,22 @@ func (c *ConfirmationCode) IsValid() bool {
return !c.IsUsed && time.Now().UTC().Before(c.ExpiresAt) return !c.IsUsed && time.Now().UTC().Before(c.ExpiresAt)
} }
// GenerateCode creates a random 6-digit code // GenerateConfirmationCode creates a uniformly-random 6-digit code using
// rejection sampling on crypto/rand (audit H4 — removes the modulo bias of
// the previous implementation).
func GenerateConfirmationCode() string { func GenerateConfirmationCode() string {
b := make([]byte, 3) for {
rand.Read(b) var b [4]byte
// Convert to 6-digit number if _, err := rand.Read(b[:]); err != nil {
num := int(b[0])<<16 | int(b[1])<<8 | int(b[2]) continue
return string(rune('0'+num%10)) + string(rune('0'+(num/10)%10)) + }
string(rune('0'+(num/100)%10)) + string(rune('0'+(num/1000)%10)) + // 4294000000 is the largest multiple of 1e6 <= MaxUint32; rejecting
string(rune('0'+(num/10000)%10)) + string(rune('0'+(num/100000)%10)) // the tail above it makes n % 1000000 perfectly uniform.
n := binary.BigEndian.Uint32(b[:])
if n < 4294000000 {
return fmt.Sprintf("%06d", n%1000000)
}
}
} }
// PasswordResetCode represents the user_passwordresetcode table // PasswordResetCode represents the user_passwordresetcode table
@@ -193,7 +228,7 @@ func (PasswordResetCode) TableName() string {
// SetCode hashes and stores the reset code // SetCode hashes and stores the reset code
func (p *PasswordResetCode) SetCode(code string) error { func (p *PasswordResetCode) SetCode(code string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(code), BcryptCost)
if err != nil { if err != nil {
return err return err
} }
+55
View File
@@ -9,6 +9,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/models"
) )
@@ -194,6 +195,60 @@ func (r *ResidenceRepository) HasAccess(residenceID, userID uint) (bool, error)
return count > 0, nil return count > 0, nil
} }
// JoinWithShareCode atomically redeems a one-time share code (audit C9/H9):
// it locks the share-code row, re-checks validity under the lock, adds the
// user to the residence, and deactivates the code — all in one transaction.
// Concurrent redemptions of the same code serialize on the row lock; the
// loser sees is_active=false and is rejected. A failure to deactivate aborts
// the whole join. Returns gorm.ErrRecordNotFound for an unknown, inactive, or
// expired code so the caller can map every case to one generic error.
func (r *ResidenceRepository) JoinWithShareCode(code string, userID uint) (residenceID uint, alreadyMember bool, err error) {
err = r.db.Transaction(func(tx *gorm.DB) error {
var sc models.ResidenceShareCode
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("code = ?", code).First(&sc).Error; e != nil {
return e
}
if !sc.IsActive || (sc.ExpiresAt != nil && time.Now().UTC().After(*sc.ExpiresAt)) {
return gorm.ErrRecordNotFound
}
residenceID = sc.ResidenceID
// Already a member (owner or shared user)?
var accessCount int64
if e := tx.Raw(`
SELECT COUNT(*) FROM (
SELECT 1 FROM residence_residence
WHERE id = ? AND owner_id = ? AND is_active = true
UNION
SELECT 1 FROM residence_residence_users
WHERE residence_id = ? AND user_id = ?
) ac
`, sc.ResidenceID, userID, sc.ResidenceID, userID).Scan(&accessCount).Error; e != nil {
return e
}
if accessCount > 0 {
alreadyMember = true
return nil
}
if e := tx.Exec(
"INSERT INTO residence_residence_users (residence_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
sc.ResidenceID, userID,
).Error; e != nil {
return e
}
// One-time use: deactivate the code. A failure here aborts the join.
if e := tx.Model(&models.ResidenceShareCode{}).
Where("id = ?", sc.ID).Update("is_active", false).Error; e != nil {
return e
}
return nil
})
return residenceID, alreadyMember, err
}
// IsOwner checks if a user is the owner of a residence // IsOwner checks if a user is the owner of a residence
func (r *ResidenceRepository) IsOwner(residenceID, userID uint) (bool, error) { func (r *ResidenceRepository) IsOwner(residenceID, userID uint) (bool, error) {
var count int64 var count int64
@@ -151,6 +151,28 @@ func (r *SubscriptionRepository) FindByAppleReceiptContains(transactionID string
return &sub, nil return &sub, nil
} }
// FindByAppleOriginalTransactionID finds a subscription by the Apple original
// transaction ID (audit C5/C13). Exact match on an indexed column — replaces
// the LIKE scan in FindByAppleReceiptContains for both replay detection and
// webhook user lookup.
func (r *SubscriptionRepository) FindByAppleOriginalTransactionID(originalTransactionID string) (*models.UserSubscription, error) {
var sub models.UserSubscription
err := r.db.Where("apple_original_transaction_id = ?", originalTransactionID).First(&sub).Error
if err != nil {
return nil, err
}
return &sub, nil
}
// UpdateAppleOriginalTransactionID binds an Apple original transaction ID to a
// user's subscription. A partial unique index enforces one account per
// transaction (audit C5) — a second account claiming the same ID fails here.
func (r *SubscriptionRepository) UpdateAppleOriginalTransactionID(userID uint, originalTransactionID string) error {
return r.db.Model(&models.UserSubscription{}).
Where("user_id = ?", userID).
Update("apple_original_transaction_id", originalTransactionID).Error
}
// FindByGoogleToken finds a subscription by Google purchase token // FindByGoogleToken finds a subscription by Google purchase token
// Used by webhooks to find the user associated with a purchase // Used by webhooks to find the user associated with a purchase
func (r *SubscriptionRepository) FindByGoogleToken(purchaseToken string) (*models.UserSubscription, error) { func (r *SubscriptionRepository) FindByGoogleToken(purchaseToken string) (*models.UserSubscription, error) {
@@ -226,3 +226,48 @@ func TestUpdateExpiresAt(t *testing.T) {
require.NotNil(t, updated.ExpiresAt) require.NotNil(t, updated.ExpiresAt)
assert.WithinDuration(t, newExpiry, *updated.ExpiresAt, time.Second, "expires_at should be updated") assert.WithinDuration(t, newExpiry, *updated.ExpiresAt, time.Second, "expires_at should be updated")
} }
// TestSubscriptionRepo_IAPTransactionReplayRejected is the regression test for
// audit C5/C6: an in-app-purchase transaction (an Apple original transaction
// ID or a Google purchase token) may be bound to exactly one account. Without
// that guarantee a valid receipt could be replayed against a second account
// to grant Pro for free. The guarantee is the pair of partial unique indexes
// added by migration 000004; AutoMigrate does not create them, so this test
// recreates them verbatim to exercise the same DB-level enforcement.
func TestSubscriptionRepo_IAPTransactionReplayRejected(t *testing.T) {
db := testutil.SetupTestDB(t)
require.NoError(t, db.Exec(`CREATE UNIQUE INDEX uq_subscription_apple_original_txn `+
`ON subscription_usersubscription (apple_original_transaction_id) `+
`WHERE apple_original_transaction_id IS NOT NULL AND apple_original_transaction_id <> ''`).Error)
require.NoError(t, db.Exec(`CREATE UNIQUE INDEX uq_subscription_google_purchase_token `+
`ON subscription_usersubscription (google_purchase_token) `+
`WHERE google_purchase_token IS NOT NULL AND google_purchase_token <> ''`).Error)
repo := NewSubscriptionRepository(db)
userA := testutil.CreateTestUser(t, db, "iapusera", "iapa@test.com", "password")
userB := testutil.CreateTestUser(t, db, "iapuserb", "iapb@test.com", "password")
require.NoError(t, db.Create(&models.UserSubscription{UserID: userA.ID, Tier: models.TierFree}).Error)
require.NoError(t, db.Create(&models.UserSubscription{UserID: userB.ID, Tier: models.TierFree}).Error)
t.Run("apple transaction cannot be claimed by a second account", func(t *testing.T) {
require.NoError(t, repo.UpdateAppleOriginalTransactionID(userA.ID, "apple-original-txn-1"),
"the first account binding the transaction must succeed")
err := repo.UpdateAppleOriginalTransactionID(userB.ID, "apple-original-txn-1")
require.Error(t, err,
"replaying account A's Apple transaction onto account B must be rejected (C5)")
})
t.Run("google purchase token cannot be claimed by a second account", func(t *testing.T) {
require.NoError(t, repo.UpdatePurchaseToken(userA.ID, "google-purchase-token-1"),
"the first account binding the token must succeed")
err := repo.UpdatePurchaseToken(userB.ID, "google-purchase-token-1")
require.Error(t, err,
"replaying account A's Google purchase token onto account B must be rejected (C6)")
})
t.Run("re-binding the same transaction to the same account is allowed", func(t *testing.T) {
// A renewal re-submitting the same transaction for its owner must not
// be rejected — the partial unique index excludes the row's own value.
require.NoError(t, repo.UpdateAppleOriginalTransactionID(userA.ID, "apple-original-txn-1"))
})
}
+44 -5
View File
@@ -174,10 +174,12 @@ func (r *UserRepository) GetOrCreateToken(userID uint) (*models.AuthToken, error
return &token, nil return &token, nil
} }
// FindTokenByKey looks up an auth token by its key value. // FindTokenByKey looks up an auth token by its raw key value. The raw token
func (r *UserRepository) FindTokenByKey(key string) (*models.AuthToken, error) { // is hashed (audit C1) before the indexed lookup, since only the hash is
// stored.
func (r *UserRepository) FindTokenByKey(rawKey string) (*models.AuthToken, error) {
var token models.AuthToken var token models.AuthToken
if err := r.db.Where("key = ?", key).First(&token).Error; err != nil { if err := r.db.Where("key = ?", models.HashToken(rawKey)).First(&token).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTokenNotFound return nil, ErrTokenNotFound
} }
@@ -195,9 +197,46 @@ func (r *UserRepository) CreateToken(userID uint) (*models.AuthToken, error) {
return &token, nil return &token, nil
} }
// DeleteToken deletes an auth token // CreateFreshToken issues a new auth token for the user, replacing any
// existing one. Because tokens are stored hashed (audit C1) the server
// cannot re-issue a previously-minted token's plaintext, so every login
// mints a fresh token. The returned token's Plaintext field carries the
// raw value to hand to the client; it is never persisted.
//
// It also returns the stored hashes of the token rows it deleted, so the
// caller can evict those entries from the Redis token cache (audit MEDIUM-1).
// Without that, a prior (e.g. stolen) token keeps authenticating via a cache
// hit for up to the cache TTL even though its DB row is gone.
func (r *UserRepository) CreateFreshToken(userID uint) (*models.AuthToken, []string, error) {
var token models.AuthToken
var oldHashes []string
err := r.db.Transaction(func(tx *gorm.DB) error {
var old []models.AuthToken
if err := tx.Where("user_id = ?", userID).Find(&old).Error; err != nil {
return err
}
oldHashes = make([]string, 0, len(old))
for i := range old {
if old[i].Key != "" {
oldHashes = append(oldHashes, old[i].Key)
}
}
if err := tx.Where("user_id = ?", userID).Delete(&models.AuthToken{}).Error; err != nil {
return err
}
token = models.AuthToken{UserID: userID}
return tx.Create(&token).Error
})
if err != nil {
return nil, nil, err
}
return &token, oldHashes, nil
}
// DeleteToken deletes an auth token by its raw key value. The raw token is
// hashed (audit C1) before the lookup, since only the hash is stored.
func (r *UserRepository) DeleteToken(token string) error { func (r *UserRepository) DeleteToken(token string) error {
result := r.db.Where("key = ?", token).Delete(&models.AuthToken{}) result := r.db.Where("key = ?", models.HashToken(token)).Delete(&models.AuthToken{})
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
@@ -104,7 +104,7 @@ func TestUserRepository_FindTokenByKey(t *testing.T) {
token, err := repo.GetOrCreateToken(user.ID) token, err := repo.GetOrCreateToken(user.ID)
require.NoError(t, err) require.NoError(t, err)
found, err := repo.FindTokenByKey(token.Key) found, err := repo.FindTokenByKey(token.Plaintext)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, token.Key, found.Key) assert.Equal(t, token.Key, found.Key)
assert.Equal(t, user.ID, found.UserID) assert.Equal(t, user.ID, found.UserID)
@@ -128,10 +128,10 @@ func TestUserRepository_DeleteToken(t *testing.T) {
token, err := repo.GetOrCreateToken(user.ID) token, err := repo.GetOrCreateToken(user.ID)
require.NoError(t, err) require.NoError(t, err)
err = repo.DeleteToken(token.Key) err = repo.DeleteToken(token.Plaintext)
require.NoError(t, err) require.NoError(t, err)
_, err = repo.FindTokenByKey(token.Key) _, err = repo.FindTokenByKey(token.Plaintext)
assert.ErrorIs(t, err, ErrTokenNotFound) assert.ErrorIs(t, err, ErrTokenNotFound)
} }
+29 -9
View File
@@ -75,10 +75,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// responses are unaffected — they don't load any assets, so any CSP is fine. // responses are unaffected — they don't load any assets, so any CSP is fine.
// frame-ancestors stays 'none' to block clickjacking. // frame-ancestors stays 'none' to block clickjacking.
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
XSSProtection: "1; mode=block", // XSSProtection deliberately empty (audit L7): the X-XSS-Protection
// header is deprecated and has itself caused XSS in legacy browsers.
XSSProtection: "",
ContentTypeNosniff: "nosniff", ContentTypeNosniff: "nosniff",
XFrameOptions: "SAMEORIGIN", XFrameOptions: "SAMEORIGIN",
HSTSMaxAge: 31536000, HSTSMaxAge: 63072000, // 2 years — preload-eligible (audit L5/CODE-L3)
HSTSPreloadEnabled: true,
ReferrerPolicy: "strict-origin-when-cross-origin", ReferrerPolicy: "strict-origin-when-cross-origin",
ContentSecurityPolicy: "default-src 'self'; " + ContentSecurityPolicy: "default-src 'self'; " +
"style-src 'self' https://fonts.googleapis.com; " + "style-src 'self' https://fonts.googleapis.com; " +
@@ -86,6 +89,8 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
"img-src 'self' data:; " + "img-src 'self' data:; " +
"script-src 'self'; " + "script-src 'self'; " +
"connect-src 'self'; " + "connect-src 'self'; " +
"object-src 'none'; " + // audit L8 — disable plugins/embeds
"base-uri 'self'; " + // audit L8 — block <base> hijacking
"frame-ancestors 'none'", "frame-ancestors 'none'",
})) }))
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{ e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
@@ -136,9 +141,20 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// labeled by route pattern, method, and status code. // labeled by route pattern, method, and status code.
e.Use(prom.HTTPMiddleware()) e.Use(prom.HTTPMiddleware())
// /metrics endpoint exposed for vmagent scrape. No auth — bound to // /metrics endpoint for the in-cluster vmagent scrape (audit LIVE-L1).
// the cluster network only; not exposed via Cloudflare. // vmagent scrapes api pods directly (pod-to-pod), so its requests carry
e.GET("/metrics", prom.Handler()) // no X-Forwarded-For. Any request that DOES carry one reached us through
// Traefik/Cloudflare — i.e. the public internet — and is refused with a
// 404. The api pod port is not exposed outside the cluster, so a request
// cannot reach /metrics without going through Traefik, and Traefik always
// appends X-Forwarded-For — the check cannot be bypassed.
metricsHandler := prom.Handler()
e.GET("/metrics", func(c echo.Context) error {
if c.Request().Header.Get("X-Forwarded-For") != "" {
return echo.NewHTTPError(http.StatusNotFound)
}
return metricsHandler(c)
})
// Serve landing page static files (if static directory is configured) // Serve landing page static files (if static directory is configured)
staticDir := cfg.Server.StaticDir staticDir := cfg.Server.StaticDir
@@ -204,6 +220,7 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// Wire Redis cache for residence-ID lookups across the four services that // Wire Redis cache for residence-ID lookups across the four services that
// read it on the request hot path. Cache is best-effort; nil cache is OK. // read it on the request hot path. Cache is best-effort; nil cache is OK.
if deps.Cache != nil { if deps.Cache != nil {
authService.SetCacheService(deps.Cache) // per-account login lockout (audit M5)
residenceService.SetCacheService(deps.Cache) residenceService.SetCacheService(deps.Cache)
taskService.SetCacheService(deps.Cache) taskService.SetCacheService(deps.Cache)
contractorService.SetCacheService(deps.Cache) contractorService.SetCacheService(deps.Cache)
@@ -316,7 +333,7 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
protected.Use(custommiddleware.TimezoneMiddleware()) protected.Use(custommiddleware.TimezoneMiddleware())
{ {
setupProtectedAuthRoutes(protected, authHandler) setupProtectedAuthRoutes(protected, authHandler)
setupResidenceRoutes(protected, residenceHandler) setupResidenceRoutes(protected, residenceHandler, authMiddleware.RequireVerified())
setupTaskRoutes(protected, taskHandler) setupTaskRoutes(protected, taskHandler)
setupSuggestionRoutes(protected, suggestionHandler) setupSuggestionRoutes(protected, suggestionHandler)
setupContractorRoutes(protected, contractorHandler) setupContractorRoutes(protected, contractorHandler)
@@ -583,7 +600,7 @@ func setupPublicDataRoutes(api *echo.Group, residenceHandler *handlers.Residence
} }
// setupResidenceRoutes configures residence routes // setupResidenceRoutes configures residence routes
func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler) { func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler, requireVerified echo.MiddlewareFunc) {
residences := api.Group("/residences") residences := api.Group("/residences")
{ {
residences.GET("/", residenceHandler.ListResidences) residences.GET("/", residenceHandler.ListResidences)
@@ -598,8 +615,11 @@ func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceH
residences.DELETE("/:id/", residenceHandler.DeleteResidence) residences.DELETE("/:id/", residenceHandler.DeleteResidence)
residences.GET("/:id/share-code/", residenceHandler.GetShareCode) residences.GET("/:id/share-code/", residenceHandler.GetShareCode)
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode) // Audit LIVE-L19: generating a residence share code requires a
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage) // verified email — it blocks bad-faith unverified signups from
// minting share codes.
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode, requireVerified)
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage, requireVerified)
residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport) residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport)
residences.GET("/:id/users/", residenceHandler.GetResidenceUsers) residences.GET("/:id/users/", residenceHandler.GetResidenceUsers)
residences.DELETE("/:id/users/:user_id/", residenceHandler.RemoveResidenceUser) residences.DELETE("/:id/users/:user_id/", residenceHandler.RemoveResidenceUser)
+14 -12
View File
@@ -75,9 +75,9 @@ func TestRefreshToken_FreshToken_ReturnsExisting(t *testing.T) {
svc := newTestAuthService(db) svc := newTestAuthService(db)
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID) resp, err := svc.RefreshToken(context.Background(), token.Plaintext, user.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, token.Key, resp.Token, "fresh token should return the same token") assert.Equal(t, token.Plaintext, resp.Token, "fresh token should return the same token")
assert.Contains(t, resp.Message, "still valid") assert.Contains(t, resp.Message, "still valid")
} }
@@ -88,23 +88,25 @@ func TestRefreshToken_InRenewalWindow_ReturnsNewToken(t *testing.T) {
svc := newTestAuthService(db) svc := newTestAuthService(db)
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID) resp, err := svc.RefreshToken(context.Background(), token.Plaintext, user.ID)
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, token.Key, resp.Token, "should return a new token") assert.NotEqual(t, token.Plaintext, resp.Token, "should return a new token")
assert.Contains(t, resp.Message, "refreshed") assert.Contains(t, resp.Message, "refreshed")
// Verify old token was deleted // Verify old token was deleted
var count int64 var count int64
// The DB stores the SHA-256 hash, so query by token.Key (the hash).
db.Model(&models.AuthToken{}).Where("key = ?", token.Key).Count(&count) db.Model(&models.AuthToken{}).Where("key = ?", token.Key).Count(&count)
assert.Equal(t, int64(0), count, "old token should be deleted") assert.Equal(t, int64(0), count, "old token should be deleted")
// Verify new token exists in DB // Verify new token exists in DB
db.Model(&models.AuthToken{}).Where("key = ?", resp.Token).Count(&count) // resp.Token is the raw token; the DB stores its hash.
db.Model(&models.AuthToken{}).Where("key = ?", models.HashToken(resp.Token)).Count(&count)
assert.Equal(t, int64(1), count, "new token should exist in DB") assert.Equal(t, int64(1), count, "new token should exist in DB")
// Verify new token belongs to the same user // Verify new token belongs to the same user
var newToken models.AuthToken var newToken models.AuthToken
require.NoError(t, db.Where("key = ?", resp.Token).First(&newToken).Error) require.NoError(t, db.Where("key = ?", models.HashToken(resp.Token)).First(&newToken).Error)
assert.Equal(t, user.ID, newToken.UserID) assert.Equal(t, user.ID, newToken.UserID)
} }
@@ -115,7 +117,7 @@ func TestRefreshToken_ExpiredToken_Returns401(t *testing.T) {
svc := newTestAuthService(db) svc := newTestAuthService(db)
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID) resp, err := svc.RefreshToken(context.Background(), token.Plaintext, user.ID)
require.Error(t, err) require.Error(t, err)
assert.Nil(t, resp) assert.Nil(t, resp)
assert.Contains(t, err.Error(), "error.token_expired") assert.Contains(t, err.Error(), "error.token_expired")
@@ -130,9 +132,9 @@ func TestRefreshToken_AtExactBoundary60Days(t *testing.T) {
svc := newTestAuthService(db) svc := newTestAuthService(db)
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID) resp, err := svc.RefreshToken(context.Background(), token.Plaintext, user.ID)
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, token.Key, resp.Token, "token at 61 days should be refreshed") assert.NotEqual(t, token.Plaintext, resp.Token, "token at 61 days should be refreshed")
} }
func TestRefreshToken_InvalidToken_Returns401(t *testing.T) { func TestRefreshToken_InvalidToken_Returns401(t *testing.T) {
@@ -155,7 +157,7 @@ func TestRefreshToken_WrongUser_Returns401(t *testing.T) {
svc := newTestAuthService(db) svc := newTestAuthService(db)
// Try to refresh with a different user ID // Try to refresh with a different user ID
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID+999) resp, err := svc.RefreshToken(context.Background(), token.Plaintext, user.ID+999)
require.Error(t, err) require.Error(t, err)
assert.Nil(t, resp) assert.Nil(t, resp)
assert.Contains(t, err.Error(), "error.invalid_token") assert.Contains(t, err.Error(), "error.invalid_token")
@@ -168,7 +170,7 @@ func TestRefreshToken_FreshTokenAt59Days_ReturnsExisting(t *testing.T) {
svc := newTestAuthService(db) svc := newTestAuthService(db)
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID) resp, err := svc.RefreshToken(context.Background(), token.Plaintext, user.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, token.Key, resp.Token, "token at 59 days should NOT be refreshed") assert.Equal(t, token.Plaintext, resp.Token, "token at 59 days should NOT be refreshed")
} }
+132 -52
View File
@@ -3,6 +3,7 @@ package services
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/binary"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -36,13 +37,32 @@ var (
ErrGoogleSignInFailed = errors.New("Google Sign In failed") ErrGoogleSignInFailed = errors.New("Google Sign In failed")
) )
// Per-account login lockout (audit M5, hardened per MEDIUM-3).
const (
// maxLoginFailureIPs is how many DISTINCT source IPs may fail to log in to
// one account within the window before that account is locked. Counting
// distinct IPs (not raw attempts) means a single attacker who knows a
// victim's email cannot lock the victim out by spamming failures — only a
// genuinely distributed credential-stuffing attack reaches this threshold.
maxLoginFailureIPs = 5
// loginLockWindow is how long the failed-IP set persists; it is refreshed
// on each failure so an active attack keeps the window open.
loginLockWindow = 15 * time.Minute
)
// AuthService handles authentication business logic // AuthService handles authentication business logic
type AuthService struct { type AuthService struct {
userRepo *repositories.UserRepository userRepo *repositories.UserRepository
notificationRepo *repositories.NotificationRepository notificationRepo *repositories.NotificationRepository
cache *CacheService
cfg *config.Config cfg *config.Config
} }
// SetCacheService wires Redis for per-account login-failure tracking (M5).
func (s *AuthService) SetCacheService(cache *CacheService) {
s.cache = cache
}
// NewAuthService creates a new auth service // NewAuthService creates a new auth service
func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService { func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService {
return &AuthService{ return &AuthService{
@@ -56,34 +76,89 @@ func (s *AuthService) SetNotificationRepository(notificationRepo *repositories.N
s.notificationRepo = notificationRepo s.notificationRepo = notificationRepo
} }
// Login authenticates a user and returns a token // dummyPasswordHash is a valid bcrypt hash used to keep login response time
func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest) (*responses.LoginResponse, error) { // constant when the account does not exist (audit LIVE-L11). It is computed
// once at startup; the plaintext it hashes is irrelevant and never used.
var dummyPasswordHash = func() string {
h, err := bcrypt.GenerateFromPassword([]byte("honeydue-login-timing-equalizer"), models.BcryptCost)
if err != nil {
return "" // CompareHashAndPassword against "" always fails — safe
}
return string(h)
}()
// freshToken mints a new auth token for the user and evicts any prior token's
// Redis cache entry (audit MEDIUM-1). Without the eviction a re-login would
// not actually kill a previously-issued token until the cache TTL lapsed — a
// stolen token would keep working for up to 5 minutes after the victim
// re-authenticates. A cache-eviction failure is logged, not fatal: the token
// row is already gone, so the stale entry simply ages out on its own.
func (s *AuthService) freshToken(ctx context.Context, userID uint) (*models.AuthToken, error) {
token, oldHashes, err := s.userRepo.WithContext(ctx).CreateFreshToken(userID)
if err != nil {
return nil, err
}
if s.cache != nil && len(oldHashes) > 0 {
if cErr := s.cache.InvalidateAuthTokenHashes(ctx, oldHashes...); cErr != nil {
log.Warn().Err(cErr).Uint("user_id", userID).
Msg("failed to evict prior auth-token cache entries on re-login")
}
}
return token, nil
}
// Login authenticates a user and returns a token. clientIP is the request's
// source IP (echo c.RealIP()), used for the distributed-attack lockout.
func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest, clientIP string) (*responses.LoginResponse, error) {
// Find user by username or email // Find user by username or email
identifier := req.Username identifier := req.Username
if identifier == "" { if identifier == "" {
identifier = req.Email identifier = req.Email
} }
lockKey := strings.ToLower(strings.TrimSpace(identifier))
// Audit M5 (hardened per MEDIUM-3): per-account lockout keyed on the set
// of distinct source IPs that have failed. Once enough distinct IPs have
// failed for one account within the window, reject — this still catches
// distributed credential stuffing, without letting a single attacker lock
// a victim out by spamming failed logins from one IP.
if s.cache != nil && lockKey != "" {
if n, cErr := s.cache.LoginFailureIPCount(ctx, lockKey); cErr == nil && n >= maxLoginFailureIPs {
return nil, apperrors.TooManyRequests("error.too_many_login_attempts")
}
}
user, err := s.userRepo.WithContext(ctx).FindByUsernameOrEmail(identifier) user, err := s.userRepo.WithContext(ctx).FindByUsernameOrEmail(identifier)
if err != nil { if err != nil && !errors.Is(err, repositories.ErrUserNotFound) {
if errors.Is(err, repositories.ErrUserNotFound) {
return nil, apperrors.Unauthorized("error.invalid_credentials")
}
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
// Check if user is active // Constant-time login (audit LIVE-L11): always run a bcrypt comparison,
if !user.IsActive { // even when the account does not exist or is inactive, so response
return nil, apperrors.Unauthorized("error.account_inactive") // timing never reveals which emails are real accounts. Compare against
// the user's hash when available, otherwise a fixed dummy hash.
passwordHash := dummyPasswordHash
if user != nil {
passwordHash = user.Password
} }
passwordOK := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) == nil
// Verify password // One generic error for not-found, inactive, and wrong-password
if !user.CheckPassword(req.Password) { // (audit L1) — none of them disclose which condition failed.
if user == nil || !user.IsActive || !passwordOK {
if s.cache != nil && lockKey != "" {
_, _ = s.cache.RegisterLoginFailure(ctx, lockKey, clientIP, loginLockWindow)
}
return nil, apperrors.Unauthorized("error.invalid_credentials") return nil, apperrors.Unauthorized("error.invalid_credentials")
} }
// Successful authentication — clear the failure counter (audit M5).
if s.cache != nil && lockKey != "" {
_ = s.cache.ClearLoginFailures(ctx, lockKey)
}
// Get or create auth token // Get or create auth token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID) token, err := s.freshToken(ctx, user.ID)
if err != nil { if err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
@@ -95,7 +170,7 @@ func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest) (*r
} }
return &responses.LoginResponse{ return &responses.LoginResponse{
Token: token.Key, Token: token.Plaintext,
User: responses.NewUserResponse(user), User: responses.NewUserResponse(user),
}, nil }, nil
} }
@@ -176,13 +251,13 @@ func (s *AuthService) Register(ctx context.Context, req *requests.RegisterReques
} }
// Create auth token (outside transaction since token generation is idempotent) // Create auth token (outside transaction since token generation is idempotent)
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID) token, err := s.freshToken(ctx, user.ID)
if err != nil { if err != nil {
return nil, "", apperrors.Internal(err) return nil, "", apperrors.Internal(err)
} }
return &responses.RegisterResponse{ return &responses.RegisterResponse{
Token: token.Key, Token: token.Plaintext,
User: responses.NewUserResponse(user), User: responses.NewUserResponse(user),
Message: "Registration successful. Please check your email to verify your account.", Message: "Registration successful. Please check your email to verify your account.",
}, code, nil }, code, nil
@@ -243,7 +318,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, tokenKey string, userID
} }
return &responses.RefreshTokenResponse{ return &responses.RefreshTokenResponse{
Token: newToken.Key, Token: newToken.Plaintext,
Message: "Token refreshed successfully.", Message: "Token refreshed successfully.",
}, nil }, nil
} }
@@ -390,26 +465,26 @@ func (s *AuthService) VerifyEmail(ctx context.Context, userID uint, code string)
return nil return nil
} }
// Find and validate confirmation code // Audit M4: validate the code, consume it, and flip the verified flag in
confirmCode, err := s.userRepo.WithContext(ctx).FindConfirmationCode(userID, code) // one transaction so the three writes commit or roll back together.
if err != nil { txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
if errors.Is(err, repositories.ErrCodeNotFound) { confirmCode, err := txRepo.FindConfirmationCode(userID, code)
if err != nil {
return err
}
if err := txRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
return err
}
return txRepo.SetProfileVerified(userID, true)
})
if txErr != nil {
if errors.Is(txErr, repositories.ErrCodeNotFound) {
return apperrors.BadRequest("error.invalid_verification_code") return apperrors.BadRequest("error.invalid_verification_code")
} }
if errors.Is(err, repositories.ErrCodeExpired) { if errors.Is(txErr, repositories.ErrCodeExpired) {
return apperrors.BadRequest("error.verification_code_expired") return apperrors.BadRequest("error.verification_code_expired")
} }
return apperrors.Internal(err) return apperrors.Internal(txErr)
}
// Mark code as used
if err := s.userRepo.WithContext(ctx).MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
return apperrors.Internal(err)
}
// Set profile as verified
if err := s.userRepo.WithContext(ctx).SetProfileVerified(userID, true); err != nil {
return apperrors.Internal(err)
} }
return nil return nil
@@ -476,7 +551,7 @@ func (s *AuthService) ForgotPassword(ctx context.Context, email string) (string,
expiresAt := time.Now().UTC().Add(s.cfg.Security.PasswordResetExpiry) expiresAt := time.Now().UTC().Add(s.cfg.Security.PasswordResetExpiry)
// Hash the code before storing // Hash the code before storing
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost) codeHash, err := bcrypt.GenerateFromPassword([]byte(code), models.BcryptCost)
if err != nil { if err != nil {
return "", nil, apperrors.Internal(err) return "", nil, apperrors.Internal(err)
} }
@@ -596,7 +671,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
} }
// Get or create token // Get or create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID) token, err := s.freshToken(ctx, user.ID)
if err != nil { if err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
@@ -605,7 +680,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID) _ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID)
return &responses.AppleSignInResponse{ return &responses.AppleSignInResponse{
Token: token.Key, Token: token.Plaintext,
User: responses.NewUserResponse(user), User: responses.NewUserResponse(user),
IsNewUser: false, IsNewUser: false,
}, nil }, nil
@@ -638,7 +713,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
_ = s.userRepo.WithContext(ctx).SetProfileVerified(existingUser.ID, true) _ = s.userRepo.WithContext(ctx).SetProfileVerified(existingUser.ID, true)
// Get or create token // Get or create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(existingUser.ID) token, err := s.freshToken(ctx, existingUser.ID)
if err != nil { if err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
@@ -653,7 +728,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
} }
return &responses.AppleSignInResponse{ return &responses.AppleSignInResponse{
Token: token.Key, Token: token.Plaintext,
User: responses.NewUserResponse(existingUser), User: responses.NewUserResponse(existingUser),
IsNewUser: false, IsNewUser: false,
}, nil }, nil
@@ -704,7 +779,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
} }
// Create token // Create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID) token, err := s.freshToken(ctx, user.ID)
if err != nil { if err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
@@ -716,7 +791,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
} }
return &responses.AppleSignInResponse{ return &responses.AppleSignInResponse{
Token: token.Key, Token: token.Plaintext,
User: responses.NewUserResponse(user), User: responses.NewUserResponse(user),
IsNewUser: true, IsNewUser: true,
}, nil }, nil
@@ -749,7 +824,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
} }
// Get or create token // Get or create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID) token, err := s.freshToken(ctx, user.ID)
if err != nil { if err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
@@ -758,7 +833,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID) _ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID)
return &responses.GoogleSignInResponse{ return &responses.GoogleSignInResponse{
Token: token.Key, Token: token.Plaintext,
User: responses.NewUserResponse(user), User: responses.NewUserResponse(user),
IsNewUser: false, IsNewUser: false,
}, nil }, nil
@@ -794,7 +869,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
} }
// Get or create token // Get or create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(existingUser.ID) token, err := s.freshToken(ctx, existingUser.ID)
if err != nil { if err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
@@ -809,7 +884,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
} }
return &responses.GoogleSignInResponse{ return &responses.GoogleSignInResponse{
Token: token.Key, Token: token.Plaintext,
User: responses.NewUserResponse(existingUser), User: responses.NewUserResponse(existingUser),
IsNewUser: false, IsNewUser: false,
}, nil }, nil
@@ -861,7 +936,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
} }
// Create token // Create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID) token, err := s.freshToken(ctx, user.ID)
if err != nil { if err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
@@ -873,7 +948,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
} }
return &responses.GoogleSignInResponse{ return &responses.GoogleSignInResponse{
Token: token.Key, Token: token.Plaintext,
User: responses.NewUserResponse(user), User: responses.NewUserResponse(user),
IsNewUser: true, IsNewUser: true,
}, nil }, nil
@@ -882,14 +957,19 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
// Helper functions // Helper functions
func generateSixDigitCode() string { func generateSixDigitCode() string {
b := make([]byte, 4) // Uniform 000000999999 via rejection sampling on crypto/rand,
rand.Read(b) // removing the modulo bias of `n % 1000000` (audit H4).
num := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) for {
if num < 0 { var b [4]byte
num = -num if _, err := rand.Read(b[:]); err != nil {
continue
}
// 4294000000 is the largest multiple of 1e6 <= MaxUint32.
n := binary.BigEndian.Uint32(b[:])
if n < 4294000000 {
return fmt.Sprintf("%06d", n%1000000)
}
} }
code := num % 1000000
return fmt.Sprintf("%06d", code)
} }
func generateResetToken() string { func generateResetToken() string {
+11 -9
View File
@@ -54,7 +54,7 @@ func TestAuthService_Login(t *testing.T) {
Password: "Password123", Password: "Password123",
} }
resp, err := service.Login(context.Background(), req) resp, err := service.Login(context.Background(), req, "")
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, resp.Token) assert.NotEmpty(t, resp.Token)
assert.Equal(t, "testuser", resp.User.Username) assert.Equal(t, "testuser", resp.User.Username)
@@ -75,7 +75,7 @@ func TestAuthService_Login_ByEmail(t *testing.T) {
Password: "Password123", Password: "Password123",
} }
resp, err := service.Login(context.Background(), req) resp, err := service.Login(context.Background(), req, "")
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, resp.Token) assert.NotEmpty(t, resp.Token)
} }
@@ -95,7 +95,7 @@ func TestAuthService_Login_InvalidCredentials(t *testing.T) {
Password: "WrongPassword1", Password: "WrongPassword1",
} }
_, err := service.Login(context.Background(), req) _, err := service.Login(context.Background(), req, "")
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials") testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
} }
@@ -112,7 +112,7 @@ func TestAuthService_Login_UserNotFound(t *testing.T) {
Password: "Password123", Password: "Password123",
} }
_, err := service.Login(context.Background(), req) _, err := service.Login(context.Background(), req, "")
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials") testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
} }
@@ -134,8 +134,10 @@ func TestAuthService_Login_InactiveUser(t *testing.T) {
Password: "Password123", Password: "Password123",
} }
_, err := service.Login(context.Background(), req) _, err := service.Login(context.Background(), req, "")
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.account_inactive") // Audit L1: inactive accounts return the same generic error as bad
// credentials so login does not disclose which accounts exist.
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
} }
// === Register === // === Register ===
@@ -443,7 +445,7 @@ func TestAuthService_ResetPassword(t *testing.T) {
Username: "testuser", Username: "testuser",
Password: "NewPassword123", Password: "NewPassword123",
} }
loginResp, err := service.Login(context.Background(), loginReq) loginResp, err := service.Login(context.Background(), loginReq, "")
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, loginResp.Token) assert.NotEmpty(t, loginResp.Token)
} }
@@ -472,7 +474,7 @@ func TestAuthService_Logout(t *testing.T) {
Username: "testuser", Username: "testuser",
Password: "Password123", Password: "Password123",
} }
loginResp, err := service.Login(context.Background(), loginReq) loginResp, err := service.Login(context.Background(), loginReq, "")
require.NoError(t, err) require.NoError(t, err)
// Logout // Logout
@@ -659,7 +661,7 @@ func TestAuthService_Login_EmptyPassword(t *testing.T) {
Password: "", Password: "",
} }
_, err := service.Login(context.Background(), req) _, err := service.Login(context.Background(), req, "")
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials") testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
} }
+67 -10
View File
@@ -12,6 +12,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/config" "github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
) )
// CacheService provides Redis caching functionality // CacheService provides Redis caching functionality
@@ -139,22 +140,25 @@ const (
TokenCacheTTL = 5 * time.Minute TokenCacheTTL = 5 * time.Minute
) )
// authTokenCacheKey returns the Redis key for an auth token. The raw token
// is hashed (audit C1) so the plaintext token never appears in a Redis key.
func authTokenCacheKey(token string) string {
return AuthTokenPrefix + models.HashToken(token)
}
// CacheAuthToken caches a user ID for a token // CacheAuthToken caches a user ID for a token
func (c *CacheService) CacheAuthToken(ctx context.Context, token string, userID uint) error { func (c *CacheService) CacheAuthToken(ctx context.Context, token string, userID uint) error {
key := AuthTokenPrefix + token return c.SetString(ctx, authTokenCacheKey(token), fmt.Sprintf("%d", userID), TokenCacheTTL)
return c.SetString(ctx, key, fmt.Sprintf("%d", userID), TokenCacheTTL)
} }
// CacheAuthTokenWithCreated caches a user ID and token creation time for a token // CacheAuthTokenWithCreated caches a user ID and token creation time for a token
func (c *CacheService) CacheAuthTokenWithCreated(ctx context.Context, token string, userID uint, createdUnix int64) error { func (c *CacheService) CacheAuthTokenWithCreated(ctx context.Context, token string, userID uint, createdUnix int64) error {
key := AuthTokenPrefix + token return c.SetString(ctx, authTokenCacheKey(token), fmt.Sprintf("%d|%d", userID, createdUnix), TokenCacheTTL)
return c.SetString(ctx, key, fmt.Sprintf("%d|%d", userID, createdUnix), TokenCacheTTL)
} }
// GetCachedAuthToken gets a cached user ID for a token // GetCachedAuthToken gets a cached user ID for a token
func (c *CacheService) GetCachedAuthToken(ctx context.Context, token string) (uint, error) { func (c *CacheService) GetCachedAuthToken(ctx context.Context, token string) (uint, error) {
key := AuthTokenPrefix + token val, err := c.GetString(ctx, authTokenCacheKey(token))
val, err := c.GetString(ctx, key)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -167,8 +171,7 @@ func (c *CacheService) GetCachedAuthToken(ctx context.Context, token string) (ui
// GetCachedAuthTokenWithCreated gets a cached user ID and token creation time. // GetCachedAuthTokenWithCreated gets a cached user ID and token creation time.
// Returns userID, createdUnix, error. createdUnix is 0 if not stored (legacy format). // Returns userID, createdUnix, error. createdUnix is 0 if not stored (legacy format).
func (c *CacheService) GetCachedAuthTokenWithCreated(ctx context.Context, token string) (uint, int64, error) { func (c *CacheService) GetCachedAuthTokenWithCreated(ctx context.Context, token string) (uint, int64, error) {
key := AuthTokenPrefix + token val, err := c.GetString(ctx, authTokenCacheKey(token))
val, err := c.GetString(ctx, key)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
@@ -184,8 +187,62 @@ func (c *CacheService) GetCachedAuthTokenWithCreated(ctx context.Context, token
// InvalidateAuthToken removes a cached token // InvalidateAuthToken removes a cached token
func (c *CacheService) InvalidateAuthToken(ctx context.Context, token string) error { func (c *CacheService) InvalidateAuthToken(ctx context.Context, token string) error {
key := AuthTokenPrefix + token return c.Delete(ctx, authTokenCacheKey(token))
return c.Delete(ctx, key) }
// InvalidateAuthTokenHashes removes cached entries for already-hashed token
// keys. Unlike InvalidateAuthToken (which hashes a plaintext), this takes the
// stored hash directly — used to evict a user's prior token on re-login
// (audit MEDIUM-1), where the server no longer has the plaintext.
func (c *CacheService) InvalidateAuthTokenHashes(ctx context.Context, hashes ...string) error {
keys := make([]string, 0, len(hashes))
for _, h := range hashes {
if h != "" {
keys = append(keys, AuthTokenPrefix+h)
}
}
if len(keys) == 0 {
return nil
}
return c.Delete(ctx, keys...)
}
// --- Per-account login-failure tracking (audit M5) ---
const loginFailPrefix = "login_fail:"
// RegisterLoginFailure records a failed login for an account from a given
// source IP, and returns the number of DISTINCT source IPs that have failed
// for this account within the window. Tracking distinct IPs as a set rather
// than a raw counter (audit MEDIUM-3) means one attacker, from one IP, cannot
// run the count up and lock a victim out by knowing only their email — a
// single IP is bounded by the per-IP edge/app rate limiters instead. A
// genuinely distributed credential-stuffing attack still trips the lockout.
func (c *CacheService) RegisterLoginFailure(ctx context.Context, identifier, ip string, window time.Duration) (int64, error) {
key := loginFailPrefix + identifier
member := ip
if member == "" {
member = "unknown"
}
if err := c.client.SAdd(ctx, key, member).Err(); err != nil {
return 0, err
}
// Refresh the TTL on each failure: an active attack keeps the window
// open, while a quiet account ages out `window` after its last failure.
_ = c.client.Expire(ctx, key, window).Err()
return c.client.SCard(ctx, key).Result()
}
// LoginFailureIPCount returns how many distinct source IPs have failed to log
// in to this account within the window (audit MEDIUM-3). SCard on a missing
// key returns 0.
func (c *CacheService) LoginFailureIPCount(ctx context.Context, identifier string) (int64, error) {
return c.client.SCard(ctx, loginFailPrefix+identifier).Result()
}
// ClearLoginFailures resets the failed-login IP set after a successful login.
func (c *CacheService) ClearLoginFailures(ctx context.Context, identifier string) error {
return c.client.Del(ctx, loginFailPrefix+identifier).Err()
} }
// Static data cache helpers // Static data cache helpers
+6 -1
View File
@@ -296,9 +296,14 @@ func (s *ContractorService) ToggleFavorite(ctx context.Context, contractorID, us
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
// Re-fetch the contractor to get the updated state with all relations // Re-fetch to get the updated state with all relations. Audit M12: if the
// contractor was deleted concurrently between the toggle and this read,
// surface a clean 404 instead of a 500.
contractor, err = s.contractorRepo.WithContext(ctx).FindByID(contractorID) contractor, err = s.contractorRepo.WithContext(ctx).FindByID(contractorID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
+29 -14
View File
@@ -5,9 +5,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// FileOwnershipService checks whether a user owns a file referenced by URL. // FileOwnershipService checks whether a user has access to a file referenced
// It queries task completion images, document files, and document images // by URL. It queries task completion images, document files, and document
// to determine ownership through residence access. // images, resolving access through residence ownership or membership.
type FileOwnershipService struct { type FileOwnershipService struct {
db *gorm.DB db *gorm.DB
} }
@@ -17,16 +17,31 @@ func NewFileOwnershipService(db *gorm.DB) *FileOwnershipService {
return &FileOwnershipService{db: db} return &FileOwnershipService{db: db}
} }
// IsFileOwnedByUser checks if the given file URL belongs to a record // accessibleResidenceIDs returns a subquery of residence IDs the user can
// that the user has access to (via residence membership). // access: residences they own (residence_residence.owner_id) UNION residences
// they are a member of (residence_residence_users).
//
// Audit C7: the previous queries joined residence_residence_users only, so a
// residence owner who was not also a member of the join table could not pass
// the ownership check for files in their own property.
func (s *FileOwnershipService) accessibleResidenceIDs(userID uint) *gorm.DB {
return s.db.Raw(`
SELECT id FROM residence_residence WHERE owner_id = ?
UNION
SELECT residence_id FROM residence_residence_users WHERE user_id = ?
`, userID, userID)
}
// IsFileOwnedByUser checks if the given file URL belongs to a record in a
// residence the user owns or is a member of.
func (s *FileOwnershipService) IsFileOwnedByUser(fileURL string, userID uint) (bool, error) { func (s *FileOwnershipService) IsFileOwnedByUser(fileURL string, userID uint) (bool, error) {
// Check task completion images: image_url -> completion -> task -> residence -> user access // Task completion images: image_url -> completion -> task -> residence.
var completionImageCount int64 var completionImageCount int64
err := s.db.Model(&models.TaskCompletionImage{}). err := s.db.Model(&models.TaskCompletionImage{}).
Joins("JOIN task_taskcompletion ON task_taskcompletion.id = task_taskcompletionimage.completion_id"). Joins("JOIN task_taskcompletion ON task_taskcompletion.id = task_taskcompletionimage.completion_id").
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id"). Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
Joins("JOIN residence_residence_users ON residence_residence_users.residence_id = task_task.residence_id"). Where("task_taskcompletionimage.image_url = ?", fileURL).
Where("task_taskcompletionimage.image_url = ? AND residence_residence_users.user_id = ?", fileURL, userID). Where("task_task.residence_id IN (?)", s.accessibleResidenceIDs(userID)).
Count(&completionImageCount).Error Count(&completionImageCount).Error
if err != nil { if err != nil {
return false, err return false, err
@@ -35,11 +50,11 @@ func (s *FileOwnershipService) IsFileOwnedByUser(fileURL string, userID uint) (b
return true, nil return true, nil
} }
// Check document files: file_url -> document -> residence -> user access // Document files: file_url -> document -> residence.
var documentCount int64 var documentCount int64
err = s.db.Model(&models.Document{}). err = s.db.Model(&models.Document{}).
Joins("JOIN residence_residence_users ON residence_residence_users.residence_id = task_document.residence_id"). Where("task_document.file_url = ?", fileURL).
Where("task_document.file_url = ? AND residence_residence_users.user_id = ?", fileURL, userID). Where("task_document.residence_id IN (?)", s.accessibleResidenceIDs(userID)).
Count(&documentCount).Error Count(&documentCount).Error
if err != nil { if err != nil {
return false, err return false, err
@@ -48,12 +63,12 @@ func (s *FileOwnershipService) IsFileOwnedByUser(fileURL string, userID uint) (b
return true, nil return true, nil
} }
// Check document images: image_url -> document_image -> document -> residence -> user access // Document images: image_url -> document_image -> document -> residence.
var documentImageCount int64 var documentImageCount int64
err = s.db.Model(&models.DocumentImage{}). err = s.db.Model(&models.DocumentImage{}).
Joins("JOIN task_document ON task_document.id = task_documentimage.document_id"). Joins("JOIN task_document ON task_document.id = task_documentimage.document_id").
Joins("JOIN residence_residence_users ON residence_residence_users.residence_id = task_document.residence_id"). Where("task_documentimage.image_url = ?", fileURL).
Where("task_documentimage.image_url = ? AND residence_residence_users.user_id = ?", fileURL, userID). Where("task_document.residence_id IN (?)", s.accessibleResidenceIDs(userID)).
Count(&documentImageCount).Error Count(&documentImageCount).Error
if err != nil { if err != nil {
return false, err return false, err
+245 -71
View File
@@ -2,132 +2,306 @@ package services
import ( import (
"context" "context"
"crypto/rsa"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/big"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5"
"github.com/treytartt/honeydue-api/internal/config" "github.com/treytartt/honeydue-api/internal/config"
) )
const ( const (
googleTokenInfoURL = "https://oauth2.googleapis.com/tokeninfo" // googleKeysURL is Google's JWKS endpoint for ID-token signature verification.
googleKeysURL = "https://www.googleapis.com/oauth2/v3/certs"
googleKeysCacheTTL = 24 * time.Hour
googleKeysCacheKey = "google:public_keys"
) )
// googleIssuers is the set of valid `iss` claim values for a Google ID token.
var googleIssuers = map[string]bool{
"accounts.google.com": true,
"https://accounts.google.com": true,
}
var ( var (
ErrInvalidGoogleToken = errors.New("invalid Google ID token") ErrInvalidGoogleToken = errors.New("invalid Google ID token")
ErrGoogleTokenExpired = errors.New("Google ID token has expired") ErrGoogleTokenExpired = errors.New("Google ID token has expired")
ErrInvalidGoogleAudience = errors.New("invalid Google token audience") ErrInvalidGoogleAudience = errors.New("invalid Google token audience")
ErrInvalidGoogleIssuer = errors.New("invalid Google token issuer")
ErrGoogleKeyNotFound = errors.New("Google public key not found")
) )
// GoogleTokenInfo represents the response from Google's token info endpoint // GoogleJWKS represents Google's JSON Web Key Set.
type GoogleTokenInfo struct { type GoogleJWKS struct {
Sub string `json:"sub"` // Unique Google user ID Keys []GoogleJWK `json:"keys"`
Email string `json:"email"` // User's email
EmailVerified string `json:"email_verified"` // "true" or "false"
Name string `json:"name"` // Full name
GivenName string `json:"given_name"` // First name
FamilyName string `json:"family_name"` // Last name
Picture string `json:"picture"` // Profile picture URL
Aud string `json:"aud"` // Audience (client ID)
Azp string `json:"azp"` // Authorized party
Exp string `json:"exp"` // Expiration time
Iss string `json:"iss"` // Issuer
} }
// IsEmailVerified returns whether the email is verified // GoogleJWK represents a single JSON Web Key from Google.
type GoogleJWK struct {
Kty string `json:"kty"` // Key type (RSA)
Kid string `json:"kid"` // Key ID
Use string `json:"use"` // Key use (sig)
Alg string `json:"alg"` // Algorithm (RS256)
N string `json:"n"` // RSA modulus
E string `json:"e"` // RSA exponent
}
// GoogleTokenClaims represents the claims in a Google ID token JWT.
type GoogleTokenClaims struct {
jwt.RegisteredClaims
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Name string `json:"name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
Picture string `json:"picture,omitempty"`
Azp string `json:"azp,omitempty"` // Authorized party
}
// GoogleTokenInfo is the verified, caller-facing view of a Google ID token.
type GoogleTokenInfo struct {
Sub string // Unique Google user ID
Email string
EmailVerified string // "true" or "false" — string for caller compatibility
Name string
GivenName string
FamilyName string
Picture string
Aud string
Azp string
Iss string
}
// IsEmailVerified returns whether the email is verified.
func (t *GoogleTokenInfo) IsEmailVerified() bool { func (t *GoogleTokenInfo) IsEmailVerified() bool {
return t.EmailVerified == "true" return t.EmailVerified == "true"
} }
// GoogleAuthService handles Google Sign In token verification // GoogleAuthService handles Google Sign In token verification.
type GoogleAuthService struct { type GoogleAuthService struct {
cache *CacheService cache *CacheService
config *config.Config config *config.Config
client *http.Client client *http.Client
} }
// NewGoogleAuthService creates a new Google auth service // NewGoogleAuthService creates a new Google auth service.
func NewGoogleAuthService(cache *CacheService, cfg *config.Config) *GoogleAuthService { func NewGoogleAuthService(cache *CacheService, cfg *config.Config) *GoogleAuthService {
return &GoogleAuthService{ return &GoogleAuthService{
cache: cache, cache: cache,
config: cfg, config: cfg,
client: &http.Client{ client: &http.Client{Timeout: 10 * time.Second},
Timeout: 10 * time.Second,
},
} }
} }
// VerifyIDToken verifies a Google ID token and returns the token info // VerifyIDToken verifies a Google ID token locally (audit C2/C3): it checks
// the RS256 signature against Google's published JWKS and the iss, aud, and
// exp claims. It never sends the token to a third-party endpoint, so it no
// longer depends on the deprecated tokeninfo service and never leaks the
// token in a request URL.
func (s *GoogleAuthService) VerifyIDToken(ctx context.Context, idToken string) (*GoogleTokenInfo, error) { func (s *GoogleAuthService) VerifyIDToken(ctx context.Context, idToken string) (*GoogleTokenInfo, error) {
// Call Google's tokeninfo endpoint to verify the token // Parse the token header to get the key ID.
url := fmt.Sprintf("%s?id_token=%s", googleTokenInfoURL, idToken) parts := strings.Split(idToken, ".")
if len(parts) != 3 {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to verify token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, ErrInvalidGoogleToken return nil, ErrInvalidGoogleToken
} }
var tokenInfo GoogleTokenInfo headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil { if err != nil {
return nil, fmt.Errorf("failed to decode token info: %w", err) return nil, fmt.Errorf("failed to decode token header: %w", err)
}
var header struct {
Kid string `json:"kid"`
Alg string `json:"alg"`
}
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, fmt.Errorf("failed to parse token header: %w", err)
} }
// Verify the audience matches our client ID(s) publicKey, err := s.getPublicKey(ctx, header.Kid)
if !s.verifyAudience(tokenInfo.Aud, tokenInfo.Azp) { if err != nil {
return nil, err
}
// Parse and verify the signature. jwt v5 validates exp/iat/nbf automatically.
token, err := jwt.ParseWithClaims(idToken, &GoogleTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return publicKey, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrGoogleTokenExpired
}
return nil, fmt.Errorf("failed to parse token: %w", err)
}
claims, ok := token.Claims.(*GoogleTokenClaims)
if !ok || !token.Valid {
return nil, ErrInvalidGoogleToken
}
// Verify the issuer (audit C3).
if !googleIssuers[claims.Issuer] {
return nil, ErrInvalidGoogleIssuer
}
// Verify the audience matches one of our configured client IDs.
if !s.verifyAudience(claims.Audience, claims.Azp) {
return nil, ErrInvalidGoogleAudience return nil, ErrInvalidGoogleAudience
} }
// Verify the token is not expired (tokeninfo endpoint already checks this, if claims.Subject == "" {
// but we double-check for security)
if tokenInfo.Sub == "" {
return nil, ErrInvalidGoogleToken return nil, ErrInvalidGoogleToken
} }
return &tokenInfo, nil emailVerified := "false"
if claims.EmailVerified {
emailVerified = "true"
}
aud := ""
if len(claims.Audience) > 0 {
aud = claims.Audience[0]
}
return &GoogleTokenInfo{
Sub: claims.Subject,
Email: claims.Email,
EmailVerified: emailVerified,
Name: claims.Name,
GivenName: claims.GivenName,
FamilyName: claims.FamilyName,
Picture: claims.Picture,
Aud: aud,
Azp: claims.Azp,
Iss: claims.Issuer,
}, nil
} }
// verifyAudience checks if the token audience matches our client ID(s). // verifyAudience checks the token audience against our configured client IDs.
// In production (non-debug), an empty clientID causes verification to fail // In production (non-debug) an empty client ID fails verification rather than
// rather than silently bypassing the check. // silently bypassing the check.
func (s *GoogleAuthService) verifyAudience(aud, azp string) bool { func (s *GoogleAuthService) verifyAudience(audience jwt.ClaimStrings, azp string) bool {
clientID := s.config.GoogleAuth.ClientID clientID := s.config.GoogleAuth.ClientID
if clientID == "" { if clientID == "" {
if s.config.Server.Debug { // In debug mode only, skip audience verification for local development.
// In debug mode only, skip audience verification for local development return s.config.Server.Debug
}
candidates := []string{clientID}
if id := s.config.GoogleAuth.AndroidClientID; id != "" {
candidates = append(candidates, id)
}
if id := s.config.GoogleAuth.IOSClientID; id != "" {
candidates = append(candidates, id)
}
for _, want := range candidates {
if azp == want {
return true return true
} }
// In production, missing client ID means we cannot verify the audience for _, aud := range audience {
return false if aud == want {
return true
}
}
} }
// Check both aud and azp (Android vs iOS may use different values)
if aud == clientID || azp == clientID {
return true
}
// Also check Android client ID if configured
androidClientID := s.config.GoogleAuth.AndroidClientID
if androidClientID != "" && (aud == androidClientID || azp == androidClientID) {
return true
}
// Also check iOS client ID if configured
iosClientID := s.config.GoogleAuth.IOSClientID
if iosClientID != "" && (aud == iosClientID || azp == iosClientID) {
return true
}
return false return false
} }
// getPublicKey returns the RSA public key for the given key ID, using a
// Redis-cached copy of Google's JWKS and re-fetching once on a cache miss
// (Google rotates signing keys roughly daily).
func (s *GoogleAuthService) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
keys, err := s.getCachedKeys(ctx)
if err != nil || keys == nil {
keys, err = s.fetchGooglePublicKeys(ctx)
if err != nil {
return nil, err
}
}
if pubKey, ok := keys[kid]; ok {
return pubKey, nil
}
// Cache miss for this kid — keys may have rotated; fetch fresh.
keys, err = s.fetchGooglePublicKeys(ctx)
if err != nil {
return nil, err
}
if pubKey, ok := keys[kid]; ok {
return pubKey, nil
}
return nil, ErrGoogleKeyNotFound
}
// getCachedKeys retrieves cached Google public keys from Redis.
func (s *GoogleAuthService) getCachedKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
if s.cache == nil {
return nil, nil
}
data, err := s.cache.GetString(ctx, googleKeysCacheKey)
if err != nil || data == "" {
return nil, nil
}
var jwks GoogleJWKS
if err := json.Unmarshal([]byte(data), &jwks); err != nil {
return nil, nil
}
return s.parseJWKS(&jwks), nil
}
// fetchGooglePublicKeys fetches Google's JWKS and caches it.
func (s *GoogleAuthService) fetchGooglePublicKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleKeysURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch Google keys: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Google keys endpoint returned status %d", resp.StatusCode)
}
var jwks GoogleJWKS
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return nil, fmt.Errorf("failed to decode Google keys: %w", err)
}
if s.cache != nil {
keysJSON, _ := json.Marshal(jwks)
_ = s.cache.SetString(ctx, googleKeysCacheKey, string(keysJSON), googleKeysCacheTTL)
}
return s.parseJWKS(&jwks), nil
}
// parseJWKS converts Google's JWKS into a map of RSA public keys by key ID.
func (s *GoogleAuthService) parseJWKS(jwks *GoogleJWKS) map[string]*rsa.PublicKey {
keys := make(map[string]*rsa.PublicKey)
for _, key := range jwks.Keys {
if key.Kty != "RSA" {
continue
}
nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
if err != nil {
continue
}
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
if err != nil {
continue
}
e := 0
for _, b := range eBytes {
e = e<<8 + int(b)
}
keys[key.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}
}
return keys
}
+49 -29
View File
@@ -68,13 +68,14 @@ type AppleTransactionInfo struct {
// AppleValidationResult contains the result of Apple receipt validation // AppleValidationResult contains the result of Apple receipt validation
type AppleValidationResult struct { type AppleValidationResult struct {
Valid bool Valid bool
TransactionID string TransactionID string
ProductID string OriginalTransactionID string // stable across renewals — the replay key
ExpiresAt time.Time ProductID string
IsTrialPeriod bool ExpiresAt time.Time
AutoRenewEnabled bool IsTrialPeriod bool
Environment string AutoRenewEnabled bool
Environment string
} }
// GoogleValidationResult contains the result of Google token validation // GoogleValidationResult contains the result of Google token validation
@@ -95,6 +96,21 @@ func NewAppleIAPClient(cfg config.AppleIAPConfig) (*AppleIAPClient, error) {
return nil, ErrIAPNotConfigured return nil, ErrIAPNotConfigured
} }
// Audit H5 (relaxed per MEDIUM-2): refuse to load the IAP signing key from
// a world-accessible file — a leaked .p8 lets an attacker forge App Store
// Server API requests. The original "0600 or stricter" check is
// incompatible with a Kubernetes Secret volume: the kubelet widens secret
// files to 0440 once fsGroup is set, so 0600 is unattainable for a
// non-root container. Group access is scoped to the pod's fsGroup; the
// real exposure is the "other" bits, so reject only those.
info, err := os.Stat(cfg.KeyPath)
if err != nil {
return nil, fmt.Errorf("failed to stat Apple IAP key: %w", err)
}
if perm := info.Mode().Perm(); perm&0o007 != 0 {
return nil, fmt.Errorf("Apple IAP key %s is world-accessible (permissions %#o); remove other-rwx bits", cfg.KeyPath, perm)
}
// Read the private key // Read the private key
keyData, err := os.ReadFile(cfg.KeyPath) keyData, err := os.ReadFile(cfg.KeyPath)
if err != nil { if err != nil {
@@ -215,11 +231,12 @@ func (c *AppleIAPClient) ValidateTransaction(ctx context.Context, transactionID
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
return &AppleValidationResult{ return &AppleValidationResult{
Valid: true, Valid: true,
TransactionID: transactionInfo.TransactionID, TransactionID: transactionInfo.TransactionID,
ProductID: transactionInfo.ProductID, OriginalTransactionID: transactionInfo.OriginalTransactionID,
ExpiresAt: expiresAt, ProductID: transactionInfo.ProductID,
Environment: transactionInfo.Environment, ExpiresAt: expiresAt,
Environment: transactionInfo.Environment,
}, nil }, nil
} }
@@ -243,11 +260,12 @@ func (c *AppleIAPClient) ValidateReceipt(ctx context.Context, receiptData string
if err == nil { if err == nil {
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
return &AppleValidationResult{ return &AppleValidationResult{
Valid: true, Valid: true,
TransactionID: transactionInfo.TransactionID, TransactionID: transactionInfo.TransactionID,
ProductID: transactionInfo.ProductID, OriginalTransactionID: transactionInfo.OriginalTransactionID,
ExpiresAt: expiresAt, ProductID: transactionInfo.ProductID,
Environment: transactionInfo.Environment, ExpiresAt: expiresAt,
Environment: transactionInfo.Environment,
}, nil }, nil
} }
} }
@@ -317,11 +335,12 @@ func (c *AppleIAPClient) ValidateReceipt(ctx context.Context, receiptData string
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
return &AppleValidationResult{ return &AppleValidationResult{
Valid: true, Valid: true,
TransactionID: transactionInfo.TransactionID, TransactionID: transactionInfo.TransactionID,
ProductID: transactionInfo.ProductID, OriginalTransactionID: transactionInfo.OriginalTransactionID,
ExpiresAt: expiresAt, ProductID: transactionInfo.ProductID,
Environment: transactionInfo.Environment, ExpiresAt: expiresAt,
Environment: transactionInfo.Environment,
}, nil }, nil
} }
@@ -418,13 +437,14 @@ func (c *AppleIAPClient) validateLegacyReceiptWithSandbox(ctx context.Context, r
} }
return &AppleValidationResult{ return &AppleValidationResult{
Valid: true, Valid: true,
TransactionID: latestReceipt.TransactionID, TransactionID: latestReceipt.TransactionID,
ProductID: latestReceipt.ProductID, OriginalTransactionID: latestReceipt.OriginalTransactionID,
ExpiresAt: expiresAt, ProductID: latestReceipt.ProductID,
IsTrialPeriod: latestReceipt.IsTrialPeriod == "true", ExpiresAt: expiresAt,
AutoRenewEnabled: autoRenew, IsTrialPeriod: latestReceipt.IsTrialPeriod == "true",
Environment: legacyResponse.Environment, AutoRenewEnabled: autoRenew,
Environment: legacyResponse.Environment,
}, nil }, nil
} }
+24 -2
View File
@@ -308,7 +308,18 @@ func (s *NotificationService) registerAPNSDevice(ctx context.Context, userID uin
// Check if device exists // Check if device exists
existing, err := s.notificationRepo.WithContext(ctx).FindAPNSDeviceByToken(req.RegistrationID) existing, err := s.notificationRepo.WithContext(ctx).FindAPNSDeviceByToken(req.RegistrationID)
if err == nil { if err == nil {
// Update existing device // Audit C8 / LOW-3: APNs device tokens are recycled across devices,
// app reinstalls and OS reassignments, so a token already bound to a
// different account is a stale binding — not a hijack. Reassign it to
// the current (authenticated) registrant rather than reject: a 409
// here would lock the legitimate new owner of a recycled token out of
// push entirely. The reassignment is logged as a security-relevant
// event so a genuine token-takeover attempt is still traceable.
if existing.UserID != nil && *existing.UserID != userID {
log.Warn().Uint("user_id", userID).Uint("previous_owner_id", *existing.UserID).
Msg("APNS device token reassigned to a new account")
}
// Update existing device — reassign to the current user
existing.UserID = &userID existing.UserID = &userID
existing.Active = true existing.Active = true
existing.Name = req.Name existing.Name = req.Name
@@ -337,7 +348,18 @@ func (s *NotificationService) registerGCMDevice(ctx context.Context, userID uint
// Check if device exists // Check if device exists
existing, err := s.notificationRepo.WithContext(ctx).FindGCMDeviceByToken(req.RegistrationID) existing, err := s.notificationRepo.WithContext(ctx).FindGCMDeviceByToken(req.RegistrationID)
if err == nil { if err == nil {
// Update existing device // Audit C8 / LOW-3: FCM device tokens are recycled across devices,
// app reinstalls and OS reassignments, so a token already bound to a
// different account is a stale binding — not a hijack. Reassign it to
// the current (authenticated) registrant rather than reject: a 409
// here would lock the legitimate new owner of a recycled token out of
// push entirely. The reassignment is logged as a security-relevant
// event so a genuine token-takeover attempt is still traceable.
if existing.UserID != nil && *existing.UserID != userID {
log.Warn().Uint("user_id", userID).Uint("previous_owner_id", *existing.UserID).
Msg("GCM device token reassigned to a new account")
}
// Update existing device — reassign to the current user
existing.UserID = &userID existing.UserID = &userID
existing.Active = true existing.Active = true
existing.Name = req.Name existing.Name = req.Name
+7 -22
View File
@@ -559,30 +559,22 @@ func (s *ResidenceService) GenerateSharePackage(ctx context.Context, residenceID
}, nil }, nil
} }
// JoinWithCode allows a user to join a residence using a share code // JoinWithCode allows a user to join a residence using a share code.
// Audit C9/H9: the code lookup, membership add, and one-time-code
// deactivation run as a single locked transaction in the repository, so a
// code can never be redeemed twice and a deactivation failure aborts the join.
func (s *ResidenceService) JoinWithCode(ctx context.Context, code string, userID uint) (*responses.JoinResidenceResponse, error) { func (s *ResidenceService) JoinWithCode(ctx context.Context, code string, userID uint) (*responses.JoinResidenceResponse, error) {
// Find the share code residenceID, alreadyMember, err := s.residenceRepo.WithContext(ctx).JoinWithShareCode(code, userID)
shareCode, err := s.residenceRepo.WithContext(ctx).FindShareCodeByCode(code)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.share_code_invalid") return nil, apperrors.NotFound("error.share_code_invalid")
} }
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
if alreadyMember {
// Check if already a member
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(shareCode.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if hasAccess {
return nil, apperrors.Conflict("error.user_already_member") return nil, apperrors.Conflict("error.user_already_member")
} }
// Add user to residence
if err := s.residenceRepo.WithContext(ctx).AddUser(shareCode.ResidenceID, userID); err != nil {
return nil, apperrors.Internal(err)
}
if s.cache != nil { if s.cache != nil {
// The joining user's residence-IDs cache is now stale, and their // The joining user's residence-IDs cache is now stale, and their
// subscription status now reflects an extra residence with all of its // subscription status now reflects an extra residence with all of its
@@ -591,15 +583,8 @@ func (s *ResidenceService) JoinWithCode(ctx context.Context, code string, userID
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID) _ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
} }
// Mark share code as used (one-time use)
if err := s.residenceRepo.WithContext(ctx).DeactivateShareCode(shareCode.ID); err != nil {
// Log the error but don't fail the join - the user has already been added
// The code will just be usable by others until it expires
log.Error().Err(err).Uint("code_id", shareCode.ID).Msg("Failed to deactivate share code after join")
}
// Get the residence with full details // Get the residence with full details
residence, err := s.residenceRepo.WithContext(ctx).FindByID(shareCode.ResidenceID) residence, err := s.residenceRepo.WithContext(ctx).FindByID(residenceID)
if err != nil { if err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
+80 -45
View File
@@ -399,99 +399,135 @@ func (s *SubscriptionService) GetActivePromotions(ctx context.Context, userID ui
return result, nil return result, nil
} }
// ProcessApplePurchase processes an Apple IAP purchase // ProcessApplePurchase processes an Apple IAP purchase.
// Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID) // Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID).
func (s *SubscriptionService) ProcessApplePurchase(ctx context.Context, userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) { func (s *SubscriptionService) ProcessApplePurchase(ctx context.Context, userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) {
// Store receipt/transaction data // Apple IAP client must be configured — without server-side validation
dataToStore := receiptData // we cannot trust client-provided receipts.
if dataToStore == "" {
dataToStore = transactionID
}
if err := s.subscriptionRepo.WithContext(ctx).UpdateReceiptData(userID, dataToStore); err != nil {
return nil, apperrors.Internal(err)
}
// Apple IAP client must be configured to validate purchases.
// Without server-side validation, we cannot trust client-provided receipts.
if s.appleClient == nil { if s.appleClient == nil {
log.Error().Uint("user_id", userID).Msg("Apple IAP validation not configured, rejecting purchase") log.Error().Uint("user_id", userID).Msg("Apple IAP validation not configured, rejecting purchase")
return nil, apperrors.BadRequest("error.iap_validation_not_configured") return nil, apperrors.BadRequest("error.iap_validation_not_configured")
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Validation is a network call to Apple; detach from the request context
// so a client disconnect cannot abort an in-flight grant.
vctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
var result *AppleValidationResult var result *AppleValidationResult
var err error var err error
// Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1).
// Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1)
if transactionID != "" { if transactionID != "" {
result, err = s.appleClient.ValidateTransaction(ctx, transactionID) result, err = s.appleClient.ValidateTransaction(vctx, transactionID)
} else if receiptData != "" { } else if receiptData != "" {
result, err = s.appleClient.ValidateReceipt(ctx, receiptData) result, err = s.appleClient.ValidateReceipt(vctx, receiptData)
} }
if err != nil { if err != nil {
// Validation failed -- do NOT fall through to grant Pro. // Validation failed -- do NOT fall through to grant Pro.
log.Error().Err(err).Uint("user_id", userID).Msg("Apple validation failed") log.Error().Err(err).Uint("user_id", userID).Msg("Apple validation failed")
return nil, err return nil, err
} }
if result == nil { if result == nil {
return nil, apperrors.BadRequest("error.no_receipt_or_transaction") return nil, apperrors.BadRequest("error.no_receipt_or_transaction")
} }
// Audit C5/C10: replay protection. A validated transaction may only ever
// be bound to one account — re-submitting a valid receipt against a
// second account must not grant Pro for free. The partial unique index
// on apple_original_transaction_id is the backstop for the check/store
// race below.
if result.OriginalTransactionID != "" {
existing, lookupErr := s.subscriptionRepo.WithContext(vctx).FindByAppleOriginalTransactionID(result.OriginalTransactionID)
switch {
case lookupErr == nil && existing != nil && existing.UserID != userID:
log.Warn().Uint("user_id", userID).Uint("bound_user_id", existing.UserID).
Msg("Apple purchase rejected — transaction already claimed by another account")
return nil, apperrors.Forbidden("error.iap_transaction_already_claimed")
case lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound):
return nil, apperrors.Internal(lookupErr)
}
}
// Persist the receipt blob and the replay key.
dataToStore := receiptData
if dataToStore == "" {
dataToStore = transactionID
}
if err := s.subscriptionRepo.WithContext(vctx).UpdateReceiptData(userID, dataToStore); err != nil {
return nil, apperrors.Internal(err)
}
if result.OriginalTransactionID != "" {
if err := s.subscriptionRepo.WithContext(vctx).UpdateAppleOriginalTransactionID(userID, result.OriginalTransactionID); err != nil {
// The unique index rejected the bind — a concurrent request
// claimed the same transaction first.
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to bind Apple transaction ID")
return nil, apperrors.Internal(err)
}
}
expiresAt := result.ExpiresAt expiresAt := result.ExpiresAt
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Str("env", result.Environment).Msg("Apple purchase validated") log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Str("env", result.Environment).Msg("Apple purchase validated")
// Upgrade to Pro with the validated expiration if err := s.subscriptionRepo.WithContext(vctx).UpgradeToPro(userID, expiresAt, "ios"); err != nil {
if err := s.subscriptionRepo.WithContext(ctx).UpgradeToPro(userID, expiresAt, "ios"); err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
// Tier flipped — drop cached SubscriptionStatusResponse so the next call // Tier flipped — drop cached SubscriptionStatusResponse so the next call
// returns Pro immediately instead of stale Free. // returns Pro immediately instead of stale Free.
if s.cache != nil { if s.cache != nil {
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID) _ = s.cache.InvalidateSubscriptionStatusForUsers(vctx, userID)
} }
return s.GetSubscription(ctx, userID) return s.GetSubscription(vctx, userID)
} }
// ProcessGooglePurchase processes a Google Play purchase // ProcessGooglePurchase processes a Google Play purchase
// productID is optional but helps validate the specific subscription // productID is optional but helps validate the specific subscription
func (s *SubscriptionService) ProcessGooglePurchase(ctx context.Context, userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) { func (s *SubscriptionService) ProcessGooglePurchase(ctx context.Context, userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
// Store purchase token first // Google IAP client must be configured — without server-side validation
if err := s.subscriptionRepo.WithContext(ctx).UpdatePurchaseToken(userID, purchaseToken); err != nil { // we cannot trust client-provided tokens.
return nil, apperrors.Internal(err)
}
// Google IAP client must be configured to validate purchases.
// Without server-side validation, we cannot trust client-provided tokens.
if s.googleClient == nil { if s.googleClient == nil {
log.Error().Uint("user_id", userID).Msg("Google IAP validation not configured, rejecting purchase") log.Error().Uint("user_id", userID).Msg("Google IAP validation not configured, rejecting purchase")
return nil, apperrors.BadRequest("error.iap_validation_not_configured") return nil, apperrors.BadRequest("error.iap_validation_not_configured")
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Audit C6/C10: replay protection — a purchase token may only ever be
// bound to one account. The partial unique index on google_purchase_token
// is the backstop for the check/store race.
if purchaseToken != "" {
existing, lookupErr := s.subscriptionRepo.WithContext(ctx).FindByGoogleToken(purchaseToken)
switch {
case lookupErr == nil && existing != nil && existing.UserID != userID:
log.Warn().Uint("user_id", userID).Uint("bound_user_id", existing.UserID).
Msg("Google purchase rejected — token already claimed by another account")
return nil, apperrors.Forbidden("error.iap_transaction_already_claimed")
case lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound):
return nil, apperrors.Internal(lookupErr)
}
}
// Store the purchase token (the replay key).
if err := s.subscriptionRepo.WithContext(ctx).UpdatePurchaseToken(userID, purchaseToken); err != nil {
return nil, apperrors.Internal(err)
}
// Validation is a network call; detach from the request context.
vctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
var result *GoogleValidationResult var result *GoogleValidationResult
var err error var err error
// If productID is provided, use it directly; otherwise try known IDs.
// If productID is provided, use it directly; otherwise try known IDs
if productID != "" { if productID != "" {
result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken) result, err = s.googleClient.ValidateSubscription(vctx, productID, purchaseToken)
} else { } else {
result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs) result, err = s.googleClient.ValidatePurchaseToken(vctx, purchaseToken, KnownSubscriptionIDs)
} }
if err != nil { if err != nil {
// Validation failed -- do NOT fall through to grant Pro. // Validation failed -- do NOT fall through to grant Pro.
log.Error().Err(err).Uint("user_id", userID).Msg("Google purchase validation failed") log.Error().Err(err).Uint("user_id", userID).Msg("Google purchase validation failed")
return nil, err return nil, err
} }
if result == nil { if result == nil {
return nil, apperrors.BadRequest("error.no_purchase_token") return nil, apperrors.BadRequest("error.no_purchase_token")
} }
@@ -499,24 +535,23 @@ func (s *SubscriptionService) ProcessGooglePurchase(ctx context.Context, userID
expiresAt := result.ExpiresAt expiresAt := result.ExpiresAt
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Bool("auto_renew", result.AutoRenewing).Msg("Google purchase validated") log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Bool("auto_renew", result.AutoRenewing).Msg("Google purchase validated")
// Acknowledge the subscription if not already acknowledged // Acknowledge the subscription if not already acknowledged.
if !result.AcknowledgedState { if !result.AcknowledgedState {
if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil { if err := s.googleClient.AcknowledgeSubscription(vctx, result.ProductID, purchaseToken); err != nil {
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to acknowledge Google subscription") log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to acknowledge Google subscription")
// Don't fail the purchase, just log the warning // Don't fail the purchase, just log the warning.
} }
} }
// Upgrade to Pro with the validated expiration if err := s.subscriptionRepo.WithContext(vctx).UpgradeToPro(userID, expiresAt, "android"); err != nil {
if err := s.subscriptionRepo.WithContext(ctx).UpgradeToPro(userID, expiresAt, "android"); err != nil {
return nil, apperrors.Internal(err) return nil, apperrors.Internal(err)
} }
if s.cache != nil { if s.cache != nil {
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID) _ = s.cache.InvalidateSubscriptionStatusForUsers(vctx, userID)
} }
return s.GetSubscription(ctx, userID) return s.GetSubscription(vctx, userID)
} }
// CancelSubscription cancels a subscription (downgrades to free at end of period) // CancelSubscription cancels a subscription (downgrades to free at end of period)
+13
View File
@@ -0,0 +1,13 @@
-- +goose Up
-- Audit C1: auth tokens are stored as SHA-256 hashes (hex, 64 chars), never
-- as plaintext, so a database compromise no longer yields usable session
-- tokens. Widen the key column from 40 to 64 chars. Existing plaintext rows
-- cannot be rehashed in place, so they are dropped — every user logs in
-- once after this deploy. This is expected and one-time.
ALTER TABLE user_authtoken ALTER COLUMN key TYPE varchar(64);
DELETE FROM user_authtoken;
-- +goose Down
-- Tokens cannot be un-hashed; clearing the table is the only safe rollback.
DELETE FROM user_authtoken;
ALTER TABLE user_authtoken ALTER COLUMN key TYPE varchar(40);
@@ -0,0 +1,47 @@
-- +goose Up
-- Audit C5/C6/C10/C13: bind each in-app-purchase transaction to exactly one
-- account so a valid receipt cannot be replayed against a second account to
-- grant Pro for free.
--
-- apple_original_transaction_id is a dedicated, indexed column — it replaces
-- the LIKE '%...%' scan over apple_receipt_data that the Apple webhook used
-- to find users (C13). google_purchase_token already exists; we just add the
-- uniqueness guarantee.
ALTER TABLE subscription_usersubscription
ADD COLUMN IF NOT EXISTS apple_original_transaction_id text;
-- Partial unique indexes: one account per transaction. NULL/empty rows are
-- excluded so accounts without an IAP are unaffected.
CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_apple_original_txn
ON subscription_usersubscription (apple_original_transaction_id)
WHERE apple_original_transaction_id IS NOT NULL
AND apple_original_transaction_id <> '';
-- Pre-flight dedup for the Google index below. apple_original_transaction_id
-- is brand-new (added above), so it is all-NULL and cannot collide. But
-- google_purchase_token is a pre-existing column, and the C6 replay bug being
-- fixed here is exactly "the same token bound to multiple accounts" — so
-- duplicate rows may exist and would make the UNIQUE index below fail to
-- build, aborting the migrate Job. Keep the earliest subscription row for
-- each token and clear the token on the rest; those rows lose a binding that
-- was disputed anyway, while the original (earliest) owner keeps it.
UPDATE subscription_usersubscription s
SET google_purchase_token = NULL
WHERE google_purchase_token IS NOT NULL
AND google_purchase_token <> ''
AND id <> (
SELECT MIN(s2.id)
FROM subscription_usersubscription s2
WHERE s2.google_purchase_token = s.google_purchase_token
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_google_purchase_token
ON subscription_usersubscription (google_purchase_token)
WHERE google_purchase_token IS NOT NULL
AND google_purchase_token <> '';
-- +goose Down
DROP INDEX IF EXISTS uq_subscription_google_purchase_token;
DROP INDEX IF EXISTS uq_subscription_apple_original_txn;
ALTER TABLE subscription_usersubscription
DROP COLUMN IF EXISTS apple_original_transaction_id;
@@ -0,0 +1,52 @@
-- +goose Up
-- Audit M7: make audit_log append-only. A BEFORE trigger rejects UPDATE and
-- DELETE so the security-event history cannot be altered or erased after the
-- fact — even by a database account with broad table privileges. The
-- application only ever INSERTs into this table.
-- The audit_log table itself was never created by a goose migration — it is
-- only built by GORM AutoMigrate in the test harness, and production never
-- runs AutoMigrate. CREATE TABLE IF NOT EXISTS brings it under migration
-- control without disturbing an existing table: a no-op on a DB that already
-- has it, and a correct build (matching models.AuditLog) on a from-scratch
-- redeploy — so the trigger below has a table to attach to and a clean
-- redeploy comes up with a working, append-only audit log.
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
event_type VARCHAR(50) NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log (created_at);
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION audit_log_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log is append-only: % is not permitted', TG_OP;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
-- DROP ... IF EXISTS before CREATE keeps this idempotent (CREATE TRIGGER has
-- no OR REPLACE on older PostgreSQL).
DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;
CREATE TRIGGER audit_log_no_update
BEFORE UPDATE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;
CREATE TRIGGER audit_log_no_delete
BEFORE DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
-- +goose Down
-- Reverses only the append-only guard, which is this migration's purpose.
-- The audit_log table is intentionally NOT dropped — it may hold security
-- history that predates this migration.
DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;
DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;
DROP FUNCTION IF EXISTS audit_log_append_only();
+30
View File
@@ -0,0 +1,30 @@
-- +goose Up
-- Audit H6 follow-up. The Apple/Google webhook handler now fails CLOSED on a
-- deduplication-store error: if it cannot consult webhook_event_log it returns
-- 500 rather than risk processing a replayed event. That makes the presence of
-- the webhook_event_log table mandatory.
--
-- Like audit_log, this table was never created by a goose migration — only by
-- GORM AutoMigrate in tests — so a from-scratch redeploy would come up without
-- it and 500 every subscription webhook. CREATE TABLE IF NOT EXISTS brings it
-- under migration control: a no-op where the table already exists, and a
-- correct build (matching repositories.WebhookEvent) on a fresh database.
CREATE TABLE IF NOT EXISTS webhook_event_log (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(255) NOT NULL,
provider VARCHAR(20) NOT NULL,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload_hash VARCHAR(64)
);
-- (provider, event_id) is the dedup key — matches the
-- uniqueIndex:idx_provider_event_id GORM tags on repositories.WebhookEvent.
CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_event_id
ON webhook_event_log (provider, event_id);
-- +goose Down
-- The table is intentionally NOT dropped — it may hold deduplication history
-- that predates this migration, and dropping it would let already-processed
-- webhook events be replayed. Down is a documented no-op.
SELECT 1;