fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
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:
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Admin panel build stage
|
||||
FROM node:20-alpine AS admin-builder
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -109,7 +109,7 @@ FROM go-base AS worker
|
||||
CMD ["/app/worker"]
|
||||
|
||||
# Admin panel runtime stage
|
||||
FROM node:20-alpine AS admin
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -131,7 +131,7 @@ ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
# 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
|
||||
RUN apk add --no-cache ca-certificates tzdata curl
|
||||
|
||||
+4
-2
@@ -54,11 +54,13 @@ func main() {
|
||||
// Initialize OpenTelemetry tracing — exports to obs.88oakapps.com
|
||||
// (Jaeger via OTLP/HTTP) when OBS_TRACES_URL is set; otherwise installs
|
||||
// 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{
|
||||
ServiceName: "honeydue-api",
|
||||
Environment: deploymentEnvironment(cfg.Server.Debug),
|
||||
EndpointURL: os.Getenv("OBS_TRACES_URL"),
|
||||
BearerToken: os.Getenv("OBS_INGEST_TOKEN"),
|
||||
EndpointURL: config.SecretValue("OBS_TRACES_URL"),
|
||||
BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
|
||||
SampleRatio: tracing.SampleRatioFromEnv(),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
+15
-2
@@ -47,11 +47,13 @@ func main() {
|
||||
|
||||
// Initialize OpenTelemetry tracing for the worker process. Same OTLP
|
||||
// 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{
|
||||
ServiceName: "honeydue-worker",
|
||||
Environment: workerDeploymentEnv(cfg.Server.Debug),
|
||||
EndpointURL: os.Getenv("OBS_TRACES_URL"),
|
||||
BearerToken: os.Getenv("OBS_INGEST_TOKEN"),
|
||||
EndpointURL: config.SecretValue("OBS_TRACES_URL"),
|
||||
BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
|
||||
SampleRatio: tracing.SampleRatioFromEnv(),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -106,6 +108,17 @@ func main() {
|
||||
if err != nil {
|
||||
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)
|
||||
var monitoringService *monitoring.Service
|
||||
|
||||
+896
-676
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ load_balancer_ip: ""
|
||||
domains:
|
||||
api: api.myhoneydue.com
|
||||
admin: admin.myhoneydue.com
|
||||
app: app.myhoneydue.com # web client host — added to CORS_ALLOWED_ORIGINS
|
||||
base: myhoneydue.com
|
||||
|
||||
# --- Container Registry (GHCR) ---
|
||||
|
||||
@@ -23,8 +23,11 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
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:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
@@ -35,6 +38,7 @@ spec:
|
||||
containers:
|
||||
- name: admin
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
|
||||
@@ -23,8 +23,11 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
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:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
@@ -35,6 +38,7 @@ spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
protocol: TCP
|
||||
@@ -46,65 +50,16 @@ spec:
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: honeydue-config
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: POSTGRES_PASSWORD
|
||||
- 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
|
||||
# Audit CODE-F8: secrets are NOT injected as environment variables.
|
||||
# Env vars are readable for the life of the pod via /proc/<pid>/environ
|
||||
# and leak into crash dumps / child processes. honeydue-secrets is
|
||||
# mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
|
||||
# config layer (config.loadFileSecrets) reads each key from its file.
|
||||
# Non-secret config still arrives via the configMapRef above.
|
||||
volumeMounts:
|
||||
- name: app-secrets
|
||||
mountPath: /etc/honeydue/secrets
|
||||
readOnly: true
|
||||
- name: apns-key
|
||||
mountPath: /secrets/apns
|
||||
readOnly: true
|
||||
@@ -121,11 +76,12 @@ spec:
|
||||
httpGet:
|
||||
path: /api/health/
|
||||
port: 8000
|
||||
# MigrateWithLock in cmd/api/main.go runs pg_advisory_lock on
|
||||
# every startup. On a cold boot with 3 replicas, the first does
|
||||
# AutoMigrate (~90s) and the others wait on the lock, so real
|
||||
# startup runs 90–240s. 48 × 5s = 240s grace absorbs it without
|
||||
# healthcheck killing a still-starting replica.
|
||||
# Schema migrations run separately in the honeydue-migrate Job
|
||||
# *before* this Deployment rolls — the api itself does not migrate
|
||||
# (it only verifies goose_db_version at boot). Cold start still
|
||||
# pays the DB pool warm-up + Redis connect + APNs/FCM client init
|
||||
# before /api/health/ goes green. 48 × 5s = 240s grace keeps the
|
||||
# probe from killing a still-starting replica.
|
||||
failureThreshold: 48
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
@@ -143,6 +99,12 @@ spec:
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
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
|
||||
secret:
|
||||
secretName: honeydue-apns-key
|
||||
|
||||
@@ -53,7 +53,12 @@ metadata:
|
||||
labels:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
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:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
@@ -98,3 +103,98 @@ spec:
|
||||
name: web
|
||||
port:
|
||||
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
|
||||
|
||||
@@ -21,12 +21,20 @@ spec:
|
||||
headers:
|
||||
frameDeny: 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"
|
||||
customResponseHeaders:
|
||||
X-Content-Type-Options: "nosniff"
|
||||
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
|
||||
# sets a CSP in internal/router/router.go that permits Google Fonts
|
||||
# for the landing page. Two CSP headers would intersect and break it.
|
||||
@@ -83,3 +91,24 @@ spec:
|
||||
basicAuth:
|
||||
secret: admin-basic-auth
|
||||
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-----
|
||||
@@ -27,8 +27,10 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
# The migrate Job never calls the k8s API (audit F11).
|
||||
automountServiceAccountToken: false
|
||||
imagePullSecrets:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
@@ -38,6 +40,7 @@ spec:
|
||||
containers:
|
||||
- name: goose
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh — same as api
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
|
||||
command: ["/bin/sh", "-c"]
|
||||
# DB_HOST in the ConfigMap points at the -pooler endpoint for runtime.
|
||||
# goose's session-scoped advisory lock can't survive PgBouncer
|
||||
|
||||
@@ -179,7 +179,17 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- 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:
|
||||
- "-promscrape.config=/etc/vmagent/scrape.yaml"
|
||||
- "-remoteWrite.url=https://obs.88oakapps.com/api/v1/write"
|
||||
|
||||
@@ -20,6 +20,9 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
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:
|
||||
honeydue/redis: "true"
|
||||
securityContext:
|
||||
@@ -31,7 +34,9 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- 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:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -23,8 +23,11 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
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:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
@@ -43,6 +46,7 @@ spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh or manual sed
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
|
||||
@@ -27,8 +27,11 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
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:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
@@ -39,6 +42,7 @@ spec:
|
||||
containers:
|
||||
- name: worker
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
@@ -47,64 +51,16 @@ spec:
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: honeydue-config
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: POSTGRES_PASSWORD
|
||||
- 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
|
||||
# Audit CODE-F8: secrets are NOT injected as environment variables.
|
||||
# Env vars are readable for the life of the pod via /proc/<pid>/environ
|
||||
# and leak into crash dumps / child processes. honeydue-secrets is
|
||||
# mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
|
||||
# config layer (config.loadFileSecrets) reads each key from its file.
|
||||
# Non-secret config still arrives via the configMapRef above.
|
||||
volumeMounts:
|
||||
- name: app-secrets
|
||||
mountPath: /etc/honeydue/secrets
|
||||
readOnly: true
|
||||
- name: apns-key
|
||||
mountPath: /secrets/apns
|
||||
readOnly: true
|
||||
@@ -124,6 +80,12 @@ spec:
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
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
|
||||
secret:
|
||||
secretName: honeydue-apns-key
|
||||
|
||||
@@ -68,6 +68,25 @@ SECRET_ARGS=(
|
||||
if [[ -n "${REDIS_PASSWORD}" ]]; then
|
||||
log " Including REDIS_PASSWORD in secrets"
|
||||
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
|
||||
|
||||
# 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" \
|
||||
--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_USER="$(cfg registry.username)"
|
||||
REGISTRY_TOKEN="$(cfg registry.token)"
|
||||
|
||||
if [[ -n "${REGISTRY_SERVER}" && -n "${REGISTRY_USER}" && -n "${REGISTRY_TOKEN}" ]]; then
|
||||
log "Creating ghcr-credentials..."
|
||||
kubectl create secret docker-registry ghcr-credentials \
|
||||
log "Creating gitea-credentials..."
|
||||
kubectl create secret docker-registry gitea-credentials \
|
||||
--namespace="${NAMESPACE}" \
|
||||
--docker-server="${REGISTRY_SERVER}" \
|
||||
--docker-username="${REGISTRY_USER}" \
|
||||
--docker-password="${REGISTRY_TOKEN}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
else
|
||||
warn "Registry credentials incomplete in config.yaml — skipping ghcr-credentials."
|
||||
warn "Registry credentials incomplete in config.yaml — skipping gitea-credentials."
|
||||
fi
|
||||
|
||||
# --- 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
|
||||
command -v htpasswd >/dev/null 2>&1 || die "Missing: htpasswd (install apache2-utils)"
|
||||
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 \
|
||||
--namespace="${NAMESPACE}" \
|
||||
--from-literal=users="${HTPASSWD}" \
|
||||
|
||||
@@ -128,6 +128,56 @@ else
|
||||
warn "Skipping build. Using images for tag: ${DEPLOY_TAG}"
|
||||
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 ---
|
||||
|
||||
log "Generating env from config.yaml..."
|
||||
@@ -166,7 +216,7 @@ kubectl apply -f "${MANIFESTS}/ingress/"
|
||||
# pod sees a stale schema.
|
||||
log "Running database migrations (goose Job)..."
|
||||
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
|
||||
warn "migration Job failed — see logs:"
|
||||
kubectl logs -n "${NAMESPACE}" job/honeydue-migrate --tail=200 || true
|
||||
@@ -175,17 +225,17 @@ fi
|
||||
log "Migrations applied; proceeding with api/worker rollout"
|
||||
|
||||
# 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/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"
|
||||
|
||||
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"
|
||||
fi
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ lines = [
|
||||
# API
|
||||
'DEBUG=false',
|
||||
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',
|
||||
f\"BASE_URL=https://{d['base']}\",
|
||||
'PORT=8000',
|
||||
@@ -119,9 +119,14 @@ lines = [
|
||||
f\"DB_MAX_IDLE_CONNS={db['max_idle_conns']}\",
|
||||
f\"DB_MAX_LIFETIME={db['max_lifetime']}\",
|
||||
f\"DB_MAX_IDLE_TIME={db.get('max_idle_time', '0s')}\",
|
||||
# Redis (in-namespace DNS short form — password injected if configured;
|
||||
# short form works because /etc/resolv.conf in pods searches honeydue.svc.cluster.local)
|
||||
f\"REDIS_URL=redis://{':%s@' % val(rd.get('password')) if rd.get('password') else ''}redis:6379/0\",
|
||||
# Redis — in-namespace DNS short form (works because pod /etc/resolv.conf
|
||||
# searches honeydue.svc.cluster.local). Audit HIGH-1: the password is
|
||||
# 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',
|
||||
# Email
|
||||
f\"EMAIL_HOST={em['host']}\",
|
||||
@@ -218,8 +223,18 @@ config = {
|
||||
'image': 'ubuntu-24.04',
|
||||
},
|
||||
'additional_packages': ['open-iscsi'],
|
||||
'post_create_commands': ['sudo systemctl enable --now iscsid'],
|
||||
'k3s_config_file': 'secrets-encryption: true\n',
|
||||
# Audit K3S-CG2: harden the node OS at provision time — fail2ban for SSH
|
||||
# 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))
|
||||
|
||||
@@ -8,6 +8,13 @@ long-haul components, and dedicated service accounts with dropped
|
||||
capabilities inside containers. This chapter documents each layer, the
|
||||
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
|
||||
|
||||
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
|
||||
- **Protect origin if origin IP leaks** — once someone knows a node IP
|
||||
they can bypass CF. Mitigation: keep origin firewall strict (Chapter 4).
|
||||
- **Encrypt between CF and origin** — we're on SSL=Flexible, so CF↔origin
|
||||
is HTTP. This is in our TODO (Chapter 20, upgrade to Full-strict).
|
||||
- **~~Encrypt between CF and origin~~** — done (2026-04-24): SSL mode is
|
||||
Full (strict); CF↔origin is TLS with a Cloudflare Origin CA cert.
|
||||
|
||||
### 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
|
||||
the source IP is in CF's ranges.
|
||||
|
||||
**TODO** (Chapter 20): Enforce at UFW level — allow 80/tcp only from
|
||||
CF IP ranges. Today any IP can reach the origin on port 80.
|
||||
**Done (2026-04-24):** the node UFW allowlist permits `:443` only from
|
||||
Cloudflare's IP ranges; the `Anywhere` rules on `:80`/`:443` were removed.
|
||||
|
||||
## 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
|
||||
- **allow-ingress-to-admin** — same, for admin:3000
|
||||
|
||||
**These are not currently applied.** Without them, our pods can freely
|
||||
talk to anything — including, theoretically, malicious destinations if
|
||||
an attacker gets RCE inside a pod.
|
||||
**Applied.** `03-deploy.sh` applies
|
||||
`deploy-k3s/manifests/network-policies.yaml` on every deploy — default-deny
|
||||
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
|
||||
just need to `kubectl apply -f deploy-k3s/manifests/network-policies.yaml`
|
||||
and test that nothing breaks.
|
||||
|
||||
### What network policies would prevent
|
||||
### What network policies prevent
|
||||
|
||||
| Attack scenario | NetworkPolicy blocks |
|
||||
|---|---|
|
||||
@@ -324,13 +329,10 @@ renewed Let's Encrypt or CF-managed cert for `*.myhoneydue.com`.
|
||||
|
||||
### CF ↔ origin
|
||||
|
||||
**Plaintext HTTP** (SSL = Flexible). An attacker with access to the
|
||||
Cloudflare-to-Hetzner path could read traffic. In practice nobody who
|
||||
isn't Cloudflare or Hetzner sits on that path.
|
||||
|
||||
**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).
|
||||
**TLS — SSL = Full (strict)** (since 2026-04-24). A Cloudflare Origin CA
|
||||
certificate (`cloudflare-origin-cert` secret) is installed on all three
|
||||
ingresses; Cloudflare validates it. Both user↔CF and CF↔origin are
|
||||
encrypted, and a DNS-hijack MitM is defeated by the origin-cert check.
|
||||
|
||||
### API ↔ Neon Postgres
|
||||
|
||||
@@ -454,11 +456,14 @@ Mitigations:
|
||||
- Gitea itself is behind login; PAT is scoped to read:packages +
|
||||
write:packages only
|
||||
- Gitea runs on the operator's infrastructure (same operator account)
|
||||
- Image tags are SHA-pinned (`:237c6b8`) not `:latest` → attacker can't
|
||||
replace an existing tag's image without us noticing the digest change
|
||||
- Workloads deploy by immutable `@sha256:` digest, not by mutable tag
|
||||
(`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
|
||||
time.
|
||||
**TODO**: cosign signing is wired into `03-deploy.sh` (guarded — runs when
|
||||
`cosign` + `COSIGN_KEY` are present); cluster-side admission verification
|
||||
(Kyverno/Connaisseur) is still pending. See `deploy-k3s/SECURITY.md` → L5.
|
||||
|
||||
## Operator workstation security
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 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
|
||||
|
||||
Traefik is the reverse proxy that routes external HTTP requests to the
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
Five workloads run in the `honeydue` namespace: **api** (Go REST API, 3
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
Non-sensitive config (hostnames, ports, feature flags, etc.) lives in
|
||||
|
||||
@@ -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 2–3 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).
|
||||
@@ -27,10 +27,10 @@ require (
|
||||
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/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/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.257.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -117,9 +117,9 @@ require (
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
|
||||
@@ -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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
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.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
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-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
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/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
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.11.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.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
@@ -216,6 +217,11 @@ func Load() (*Config, error) {
|
||||
// Set defaults
|
||||
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)
|
||||
dbConfig := DatabaseConfig{
|
||||
Host: viper.GetString("DB_HOST"),
|
||||
@@ -432,14 +438,67 @@ func isWeakSecretKey(key string) bool {
|
||||
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 {
|
||||
// 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.Server.Debug {
|
||||
// In debug mode, use a default key with a warning for local development
|
||||
cfg.Security.SecretKey = "change-me-in-production-secret-key-12345"
|
||||
fmt.Println("WARNING: SECRET_KEY not set, using default (debug mode only)")
|
||||
fmt.Println("WARNING: *** DO NOT USE THIS DEFAULT KEY IN PRODUCTION ***")
|
||||
// Debug only: generate a random key per boot. Tokens signed with
|
||||
// it do not survive a restart, which is acceptable for local dev
|
||||
// and far safer than a well-known hardcoded fallback.
|
||||
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 {
|
||||
// In production, refuse to start without a proper secret key
|
||||
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
|
||||
// The actual connection will fail if credentials are wrong
|
||||
|
||||
|
||||
@@ -106,8 +106,10 @@ func TestLoad_Validation_MissingSecretKey_DebugMode(t *testing.T) {
|
||||
|
||||
c, err := Load()
|
||||
require.NoError(t, err)
|
||||
// In debug mode, a default key is assigned
|
||||
assert.Equal(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey)
|
||||
// Audit M8: in debug mode an ephemeral random key is generated per boot
|
||||
// (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) {
|
||||
@@ -133,6 +135,33 @@ func TestLoad_Validation_WeakSecretKey_DebugMode(t *testing.T) {
|
||||
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) {
|
||||
resetConfigState()
|
||||
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -55,8 +56,15 @@ func (h *AuthHandler) SetAuditService(auditService *services.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/
|
||||
func (h *AuthHandler) Login(c echo.Context) error {
|
||||
noStore(c)
|
||||
var req requests.LoginRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
response, err := h.authService.Login(c.Request().Context(), &req)
|
||||
response, err := h.authService.Login(c.Request().Context(), &req, c.RealIP())
|
||||
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 {
|
||||
h.auditService.LogEvent(c, nil, services.AuditEventLoginFailed, map[string]interface{}{
|
||||
"identifier": req.Username,
|
||||
@@ -86,6 +96,7 @@ func (h *AuthHandler) Login(c echo.Context) error {
|
||||
|
||||
// Register handles POST /api/auth/register/
|
||||
func (h *AuthHandler) Register(c echo.Context) error {
|
||||
noStore(c)
|
||||
var req requests.RegisterRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
@@ -157,6 +168,7 @@ func (h *AuthHandler) Logout(c echo.Context) error {
|
||||
|
||||
// CurrentUser handles GET /api/auth/me/
|
||||
func (h *AuthHandler) CurrentUser(c echo.Context) error {
|
||||
noStore(c)
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -276,31 +288,7 @@ func (h *AuthHandler) ForgotPassword(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
}
|
||||
|
||||
code, user, err := h.authService.ForgotPassword(c.Request().Context(), req.Email)
|
||||
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")
|
||||
}
|
||||
}()
|
||||
}
|
||||
noStore(c)
|
||||
|
||||
if h.auditService != nil {
|
||||
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{
|
||||
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/
|
||||
func (h *AuthHandler) AppleSignIn(c echo.Context) error {
|
||||
noStore(c)
|
||||
var req requests.AppleSignInRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
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/
|
||||
func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
|
||||
noStore(c)
|
||||
var req requests.GoogleSignInRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
@@ -459,6 +475,7 @@ func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
|
||||
|
||||
// RefreshToken handles POST /api/auth/refresh/
|
||||
func (h *AuthHandler) RefreshToken(c echo.Context) error {
|
||||
noStore(c)
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -650,14 +650,14 @@ func TestAuthHandler_RefreshToken(t *testing.T) {
|
||||
authGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
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)
|
||||
}
|
||||
})
|
||||
authGroup.POST("/refresh/", handler.RefreshToken)
|
||||
|
||||
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)
|
||||
|
||||
var response map[string]interface{}
|
||||
|
||||
@@ -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
|
||||
// GET /api/media/document/:id
|
||||
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
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
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)
|
||||
}
|
||||
@@ -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("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)
|
||||
}
|
||||
|
||||
@@ -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("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)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
@@ -165,9 +167,13 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
|
||||
if notification.NotificationUUID != "" {
|
||||
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Apple Webhook: Failed to check dedup")
|
||||
// Continue processing on dedup check failure (fail-open)
|
||||
} else if alreadyProcessed {
|
||||
// Audit H6: fail closed. A dedup-check failure must not let a
|
||||
// possibly-duplicate event through (duplicate refunds/grants).
|
||||
// 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")
|
||||
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) {
|
||||
// Look up user subscription by stored receipt data
|
||||
subscription, err := h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID)
|
||||
// Audit C13: exact match on the indexed apple_original_transaction_id
|
||||
// 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 {
|
||||
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)
|
||||
@@ -566,9 +586,12 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
|
||||
if messageID != "" {
|
||||
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Google Webhook: Failed to check dedup")
|
||||
// Continue processing on dedup check failure (fail-open)
|
||||
} else if alreadyProcessed {
|
||||
// Audit H6: fail closed — see the Apple handler. Return 500 so
|
||||
// Google Pub/Sub redelivers once the DB is healthy.
|
||||
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")
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
func extractToken(c echo.Context) (string, error) {
|
||||
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
|
||||
`).
|
||||
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).
|
||||
Scan(&row).Error
|
||||
if err != nil || row.Key == "" {
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestTokenAuth_RejectsExpiredToken(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestTokenAuth_AcceptsValidToken(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestTokenAuth_AcceptsTokenAtBoundary(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestTokenAuth_BearerScheme_Accepted(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestTokenAuth_InvalidScheme_Rejected(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestTokenAuth_InactiveUser_Rejected(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestOptionalTokenAuth_ValidToken_SetsUser(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestOptionalTokenAuth_ExpiredToken_IgnoresUser(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -242,7 +242,7 @@ func TestNewAuthMiddlewareWithConfig_CustomExpiryDays(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -270,7 +270,7 @@ func TestNewAuthMiddlewareWithConfig_ExpiredWithCustomExpiry(t *testing.T) {
|
||||
|
||||
e := echo.New()
|
||||
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()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
|
||||
@@ -99,21 +99,23 @@ func parseTimezone(tz string) *time.Location {
|
||||
return loc
|
||||
}
|
||||
|
||||
// Try parsing as UTC offset (e.g., "-08:00", "+05:30")
|
||||
// We parse a reference time with the given offset to extract the offset value
|
||||
t, err := time.Parse("-07:00", tz)
|
||||
if err == nil {
|
||||
// time.Parse returns a time, we need to extract the offset
|
||||
// The parsed time will have the offset embedded
|
||||
_, offset := t.Zone()
|
||||
return time.FixedZone(tz, offset)
|
||||
// Try parsing as a UTC offset (e.g., "-08:00", "+05:30"). Audit H8:
|
||||
// reject absurd offsets — real timezones are within ±14h of UTC — so a
|
||||
// crafted X-Timezone header cannot shift date math arbitrarily.
|
||||
const maxOffsetSeconds = 14 * 3600
|
||||
if t, err := time.Parse("-07:00", tz); err == nil {
|
||||
if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
|
||||
return time.FixedZone(tz, offset)
|
||||
}
|
||||
return time.UTC
|
||||
}
|
||||
|
||||
// Also try without colon (e.g., "-0800")
|
||||
t, err = time.Parse("-0700", tz)
|
||||
if err == nil {
|
||||
_, offset := t.Zone()
|
||||
return time.FixedZone(tz, offset)
|
||||
if t, err := time.Parse("-0700", tz); err == nil {
|
||||
if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
|
||||
return time.FixedZone(tz, offset)
|
||||
}
|
||||
return time.UTC
|
||||
}
|
||||
|
||||
// Default to UTC
|
||||
|
||||
@@ -252,7 +252,8 @@ func TestAuthToken_BeforeCreate_GeneratesKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ type UserSubscription struct {
|
||||
// In-App Purchase data (Apple / Google)
|
||||
AppleReceiptData *string `gorm:"column:apple_receipt_data;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)
|
||||
StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"`
|
||||
|
||||
+55
-20
@@ -2,7 +2,10 @@ package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -37,14 +40,16 @@ func (User) TableName() string {
|
||||
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
|
||||
func (u *User) SetPassword(password string) error {
|
||||
// Django uses PBKDF2_SHA256 by default, but we'll use bcrypt for Go
|
||||
// Note: This means passwords set by Django won't work with Go's check
|
||||
// For migration, you'd need to either:
|
||||
// 1. Force password reset for all users
|
||||
// 2. Implement Django's PBKDF2 hasher in Go
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
// Django uses PBKDF2_SHA256 by default, but we use bcrypt for Go.
|
||||
// Passwords set by Django won't verify with Go's bcrypt check — those
|
||||
// users must reset their password after migration.
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,12 +74,22 @@ func (u *User) GetFullName() string {
|
||||
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 {
|
||||
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"`
|
||||
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
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
@@ -84,10 +99,13 @@ func (AuthToken) TableName() string {
|
||||
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 {
|
||||
if t.Key == "" {
|
||||
t.Key = generateToken()
|
||||
raw := generateToken()
|
||||
t.Plaintext = raw
|
||||
t.Key = HashToken(raw)
|
||||
}
|
||||
if t.Created.IsZero() {
|
||||
t.Created = time.Now().UTC()
|
||||
@@ -95,13 +113,23 @@ func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateToken creates a random 40-character hex token
|
||||
// generateToken creates a random 40-character hex token (the raw value).
|
||||
func generateToken() string {
|
||||
b := make([]byte, 20)
|
||||
rand.Read(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
|
||||
func GetOrCreateToken(tx *gorm.DB, userID uint) (*AuthToken, error) {
|
||||
var token AuthToken
|
||||
@@ -160,15 +188,22 @@ func (c *ConfirmationCode) IsValid() bool {
|
||||
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 {
|
||||
b := make([]byte, 3)
|
||||
rand.Read(b)
|
||||
// Convert to 6-digit number
|
||||
num := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
|
||||
return string(rune('0'+num%10)) + string(rune('0'+(num/10)%10)) +
|
||||
string(rune('0'+(num/100)%10)) + string(rune('0'+(num/1000)%10)) +
|
||||
string(rune('0'+(num/10000)%10)) + string(rune('0'+(num/100000)%10))
|
||||
for {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
continue
|
||||
}
|
||||
// 4294000000 is the largest multiple of 1e6 <= MaxUint32; rejecting
|
||||
// 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
|
||||
@@ -193,7 +228,7 @@ func (PasswordResetCode) TableName() string {
|
||||
|
||||
// SetCode hashes and stores the reset code
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
@@ -194,6 +195,60 @@ func (r *ResidenceRepository) HasAccess(residenceID, userID uint) (bool, error)
|
||||
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
|
||||
func (r *ResidenceRepository) IsOwner(residenceID, userID uint) (bool, error) {
|
||||
var count int64
|
||||
|
||||
@@ -151,6 +151,28 @@ func (r *SubscriptionRepository) FindByAppleReceiptContains(transactionID string
|
||||
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
|
||||
// Used by webhooks to find the user associated with a purchase
|
||||
func (r *SubscriptionRepository) FindByGoogleToken(purchaseToken string) (*models.UserSubscription, error) {
|
||||
|
||||
@@ -226,3 +226,48 @@ func TestUpdateExpiresAt(t *testing.T) {
|
||||
require.NotNil(t, updated.ExpiresAt)
|
||||
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"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,10 +174,12 @@ func (r *UserRepository) GetOrCreateToken(userID uint) (*models.AuthToken, error
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// FindTokenByKey looks up an auth token by its key value.
|
||||
func (r *UserRepository) FindTokenByKey(key string) (*models.AuthToken, error) {
|
||||
// FindTokenByKey looks up an auth token by its raw key value. The raw token
|
||||
// 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
|
||||
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) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
@@ -195,9 +197,46 @@ func (r *UserRepository) CreateToken(userID uint) (*models.AuthToken, error) {
|
||||
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 {
|
||||
result := r.db.Where("key = ?", token).Delete(&models.AuthToken{})
|
||||
result := r.db.Where("key = ?", models.HashToken(token)).Delete(&models.AuthToken{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestUserRepository_FindTokenByKey(t *testing.T) {
|
||||
token, err := repo.GetOrCreateToken(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := repo.FindTokenByKey(token.Key)
|
||||
found, err := repo.FindTokenByKey(token.Plaintext)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, token.Key, found.Key)
|
||||
assert.Equal(t, user.ID, found.UserID)
|
||||
@@ -128,10 +128,10 @@ func TestUserRepository_DeleteToken(t *testing.T) {
|
||||
token, err := repo.GetOrCreateToken(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = repo.DeleteToken(token.Key)
|
||||
err = repo.DeleteToken(token.Plaintext)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = repo.FindTokenByKey(token.Key)
|
||||
_, err = repo.FindTokenByKey(token.Plaintext)
|
||||
assert.ErrorIs(t, err, ErrTokenNotFound)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,10 +75,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
// responses are unaffected — they don't load any assets, so any CSP is fine.
|
||||
// frame-ancestors stays 'none' to block clickjacking.
|
||||
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",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSMaxAge: 31536000,
|
||||
HSTSMaxAge: 63072000, // 2 years — preload-eligible (audit L5/CODE-L3)
|
||||
HSTSPreloadEnabled: true,
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
ContentSecurityPolicy: "default-src 'self'; " +
|
||||
"style-src 'self' https://fonts.googleapis.com; " +
|
||||
@@ -86,6 +89,8 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
"img-src 'self' data:; " +
|
||||
"script-src 'self'; " +
|
||||
"connect-src 'self'; " +
|
||||
"object-src 'none'; " + // audit L8 — disable plugins/embeds
|
||||
"base-uri 'self'; " + // audit L8 — block <base> hijacking
|
||||
"frame-ancestors 'none'",
|
||||
}))
|
||||
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
|
||||
@@ -136,9 +141,20 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
// labeled by route pattern, method, and status code.
|
||||
e.Use(prom.HTTPMiddleware())
|
||||
|
||||
// /metrics endpoint exposed for vmagent scrape. No auth — bound to
|
||||
// the cluster network only; not exposed via Cloudflare.
|
||||
e.GET("/metrics", prom.Handler())
|
||||
// /metrics endpoint for the in-cluster vmagent scrape (audit LIVE-L1).
|
||||
// vmagent scrapes api pods directly (pod-to-pod), so its requests carry
|
||||
// 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)
|
||||
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
|
||||
// read it on the request hot path. Cache is best-effort; nil cache is OK.
|
||||
if deps.Cache != nil {
|
||||
authService.SetCacheService(deps.Cache) // per-account login lockout (audit M5)
|
||||
residenceService.SetCacheService(deps.Cache)
|
||||
taskService.SetCacheService(deps.Cache)
|
||||
contractorService.SetCacheService(deps.Cache)
|
||||
@@ -316,7 +333,7 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
protected.Use(custommiddleware.TimezoneMiddleware())
|
||||
{
|
||||
setupProtectedAuthRoutes(protected, authHandler)
|
||||
setupResidenceRoutes(protected, residenceHandler)
|
||||
setupResidenceRoutes(protected, residenceHandler, authMiddleware.RequireVerified())
|
||||
setupTaskRoutes(protected, taskHandler)
|
||||
setupSuggestionRoutes(protected, suggestionHandler)
|
||||
setupContractorRoutes(protected, contractorHandler)
|
||||
@@ -583,7 +600,7 @@ func setupPublicDataRoutes(api *echo.Group, residenceHandler *handlers.Residence
|
||||
}
|
||||
|
||||
// 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.GET("/", residenceHandler.ListResidences)
|
||||
@@ -598,8 +615,11 @@ func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceH
|
||||
residences.DELETE("/:id/", residenceHandler.DeleteResidence)
|
||||
|
||||
residences.GET("/:id/share-code/", residenceHandler.GetShareCode)
|
||||
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode)
|
||||
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage)
|
||||
// Audit LIVE-L19: generating a residence share code requires a
|
||||
// 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.GET("/:id/users/", residenceHandler.GetResidenceUsers)
|
||||
residences.DELETE("/:id/users/:user_id/", residenceHandler.RemoveResidenceUser)
|
||||
|
||||
@@ -75,9 +75,9 @@ func TestRefreshToken_FreshToken_ReturnsExisting(t *testing.T) {
|
||||
|
||||
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)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -88,23 +88,25 @@ func TestRefreshToken_InRenewalWindow_ReturnsNewToken(t *testing.T) {
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
// Verify old token was deleted
|
||||
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)
|
||||
assert.Equal(t, int64(0), count, "old token should be deleted")
|
||||
|
||||
// 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")
|
||||
|
||||
// Verify new token belongs to the same user
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -115,7 +117,7 @@ func TestRefreshToken_ExpiredToken_Returns401(t *testing.T) {
|
||||
|
||||
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)
|
||||
assert.Nil(t, resp)
|
||||
assert.Contains(t, err.Error(), "error.token_expired")
|
||||
@@ -130,9 +132,9 @@ func TestRefreshToken_AtExactBoundary60Days(t *testing.T) {
|
||||
|
||||
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)
|
||||
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) {
|
||||
@@ -155,7 +157,7 @@ func TestRefreshToken_WrongUser_Returns401(t *testing.T) {
|
||||
svc := newTestAuthService(db)
|
||||
|
||||
// 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)
|
||||
assert.Nil(t, resp)
|
||||
assert.Contains(t, err.Error(), "error.invalid_token")
|
||||
@@ -168,7 +170,7 @@ func TestRefreshToken_FreshTokenAt59Days_ReturnsExisting(t *testing.T) {
|
||||
|
||||
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)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -36,13 +37,32 @@ var (
|
||||
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
|
||||
type AuthService struct {
|
||||
userRepo *repositories.UserRepository
|
||||
notificationRepo *repositories.NotificationRepository
|
||||
cache *CacheService
|
||||
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
|
||||
func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService {
|
||||
return &AuthService{
|
||||
@@ -56,34 +76,89 @@ func (s *AuthService) SetNotificationRepository(notificationRepo *repositories.N
|
||||
s.notificationRepo = notificationRepo
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a token
|
||||
func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest) (*responses.LoginResponse, error) {
|
||||
// dummyPasswordHash is a valid bcrypt hash used to keep login response time
|
||||
// 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
|
||||
identifier := req.Username
|
||||
if identifier == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrUserNotFound) {
|
||||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||||
}
|
||||
if err != nil && !errors.Is(err, repositories.ErrUserNotFound) {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return nil, apperrors.Unauthorized("error.account_inactive")
|
||||
// Constant-time login (audit LIVE-L11): always run a bcrypt comparison,
|
||||
// even when the account does not exist or is inactive, so response
|
||||
// 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
|
||||
if !user.CheckPassword(req.Password) {
|
||||
// One generic error for not-found, inactive, and wrong-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")
|
||||
}
|
||||
|
||||
// Successful authentication — clear the failure counter (audit M5).
|
||||
if s.cache != nil && lockKey != "" {
|
||||
_ = s.cache.ClearLoginFailures(ctx, lockKey)
|
||||
}
|
||||
|
||||
// Get or create auth token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -95,7 +170,7 @@ func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest) (*r
|
||||
}
|
||||
|
||||
return &responses.LoginResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
}, 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)
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, "", apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return &responses.RegisterResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
Message: "Registration successful. Please check your email to verify your account.",
|
||||
}, code, nil
|
||||
@@ -243,7 +318,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, tokenKey string, userID
|
||||
}
|
||||
|
||||
return &responses.RefreshTokenResponse{
|
||||
Token: newToken.Key,
|
||||
Token: newToken.Plaintext,
|
||||
Message: "Token refreshed successfully.",
|
||||
}, nil
|
||||
}
|
||||
@@ -390,26 +465,26 @@ func (s *AuthService) VerifyEmail(ctx context.Context, userID uint, code string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find and validate confirmation code
|
||||
confirmCode, err := s.userRepo.WithContext(ctx).FindConfirmationCode(userID, code)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrCodeNotFound) {
|
||||
// Audit M4: validate the code, consume it, and flip the verified flag in
|
||||
// one transaction so the three writes commit or roll back together.
|
||||
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
|
||||
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")
|
||||
}
|
||||
if errors.Is(err, repositories.ErrCodeExpired) {
|
||||
if errors.Is(txErr, repositories.ErrCodeExpired) {
|
||||
return apperrors.BadRequest("error.verification_code_expired")
|
||||
}
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// 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 apperrors.Internal(txErr)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Hash the code before storing
|
||||
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), models.BcryptCost)
|
||||
if err != nil {
|
||||
return "", nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -596,7 +671,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
}
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
return &responses.AppleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
IsNewUser: false,
|
||||
}, nil
|
||||
@@ -638,7 +713,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
_ = s.userRepo.WithContext(ctx).SetProfileVerified(existingUser.ID, true)
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(existingUser.ID)
|
||||
token, err := s.freshToken(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -653,7 +728,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
}
|
||||
|
||||
return &responses.AppleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(existingUser),
|
||||
IsNewUser: false,
|
||||
}, nil
|
||||
@@ -704,7 +779,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
}
|
||||
|
||||
// Create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -716,7 +791,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
}
|
||||
|
||||
return &responses.AppleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
IsNewUser: true,
|
||||
}, nil
|
||||
@@ -749,7 +824,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
return &responses.GoogleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
IsNewUser: false,
|
||||
}, nil
|
||||
@@ -794,7 +869,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(existingUser.ID)
|
||||
token, err := s.freshToken(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -809,7 +884,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
return &responses.GoogleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(existingUser),
|
||||
IsNewUser: false,
|
||||
}, nil
|
||||
@@ -861,7 +936,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
// Create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -873,7 +948,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
return &responses.GoogleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
IsNewUser: true,
|
||||
}, nil
|
||||
@@ -882,14 +957,19 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
// Helper functions
|
||||
|
||||
func generateSixDigitCode() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
num := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||
if num < 0 {
|
||||
num = -num
|
||||
// Uniform 000000–999999 via rejection sampling on crypto/rand,
|
||||
// removing the modulo bias of `n % 1000000` (audit H4).
|
||||
for {
|
||||
var b [4]byte
|
||||
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 {
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestAuthService_Login(t *testing.T) {
|
||||
Password: "Password123",
|
||||
}
|
||||
|
||||
resp, err := service.Login(context.Background(), req)
|
||||
resp, err := service.Login(context.Background(), req, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.Token)
|
||||
assert.Equal(t, "testuser", resp.User.Username)
|
||||
@@ -75,7 +75,7 @@ func TestAuthService_Login_ByEmail(t *testing.T) {
|
||||
Password: "Password123",
|
||||
}
|
||||
|
||||
resp, err := service.Login(context.Background(), req)
|
||||
resp, err := service.Login(context.Background(), req, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.Token)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestAuthService_Login_InvalidCredentials(t *testing.T) {
|
||||
Password: "WrongPassword1",
|
||||
}
|
||||
|
||||
_, err := service.Login(context.Background(), req)
|
||||
_, err := service.Login(context.Background(), req, "")
|
||||
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestAuthService_Login_UserNotFound(t *testing.T) {
|
||||
Password: "Password123",
|
||||
}
|
||||
|
||||
_, err := service.Login(context.Background(), req)
|
||||
_, err := service.Login(context.Background(), req, "")
|
||||
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
|
||||
}
|
||||
|
||||
@@ -134,8 +134,10 @@ func TestAuthService_Login_InactiveUser(t *testing.T) {
|
||||
Password: "Password123",
|
||||
}
|
||||
|
||||
_, err := service.Login(context.Background(), req)
|
||||
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.account_inactive")
|
||||
_, err := service.Login(context.Background(), req, "")
|
||||
// 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 ===
|
||||
@@ -443,7 +445,7 @@ func TestAuthService_ResetPassword(t *testing.T) {
|
||||
Username: "testuser",
|
||||
Password: "NewPassword123",
|
||||
}
|
||||
loginResp, err := service.Login(context.Background(), loginReq)
|
||||
loginResp, err := service.Login(context.Background(), loginReq, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, loginResp.Token)
|
||||
}
|
||||
@@ -472,7 +474,7 @@ func TestAuthService_Logout(t *testing.T) {
|
||||
Username: "testuser",
|
||||
Password: "Password123",
|
||||
}
|
||||
loginResp, err := service.Login(context.Background(), loginReq)
|
||||
loginResp, err := service.Login(context.Background(), loginReq, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Logout
|
||||
@@ -659,7 +661,7 @@ func TestAuthService_Login_EmptyPassword(t *testing.T) {
|
||||
Password: "",
|
||||
}
|
||||
|
||||
_, err := service.Login(context.Background(), req)
|
||||
_, err := service.Login(context.Background(), req, "")
|
||||
testutil.AssertAppError(t, err, http.StatusUnauthorized, "error.invalid_credentials")
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
|
||||
// CacheService provides Redis caching functionality
|
||||
@@ -139,22 +140,25 @@ const (
|
||||
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
|
||||
func (c *CacheService) CacheAuthToken(ctx context.Context, token string, userID uint) error {
|
||||
key := AuthTokenPrefix + token
|
||||
return c.SetString(ctx, key, fmt.Sprintf("%d", userID), TokenCacheTTL)
|
||||
return c.SetString(ctx, authTokenCacheKey(token), fmt.Sprintf("%d", userID), TokenCacheTTL)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
key := AuthTokenPrefix + token
|
||||
return c.SetString(ctx, key, fmt.Sprintf("%d|%d", userID, createdUnix), TokenCacheTTL)
|
||||
return c.SetString(ctx, authTokenCacheKey(token), fmt.Sprintf("%d|%d", userID, createdUnix), TokenCacheTTL)
|
||||
}
|
||||
|
||||
// GetCachedAuthToken gets a cached user ID for a token
|
||||
func (c *CacheService) GetCachedAuthToken(ctx context.Context, token string) (uint, error) {
|
||||
key := AuthTokenPrefix + token
|
||||
val, err := c.GetString(ctx, key)
|
||||
val, err := c.GetString(ctx, authTokenCacheKey(token))
|
||||
if err != nil {
|
||||
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.
|
||||
// Returns userID, createdUnix, error. createdUnix is 0 if not stored (legacy format).
|
||||
func (c *CacheService) GetCachedAuthTokenWithCreated(ctx context.Context, token string) (uint, int64, error) {
|
||||
key := AuthTokenPrefix + token
|
||||
val, err := c.GetString(ctx, key)
|
||||
val, err := c.GetString(ctx, authTokenCacheKey(token))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
@@ -184,8 +187,62 @@ func (c *CacheService) GetCachedAuthTokenWithCreated(ctx context.Context, token
|
||||
|
||||
// InvalidateAuthToken removes a cached token
|
||||
func (c *CacheService) InvalidateAuthToken(ctx context.Context, token string) error {
|
||||
key := AuthTokenPrefix + token
|
||||
return c.Delete(ctx, key)
|
||||
return c.Delete(ctx, authTokenCacheKey(token))
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -296,9 +296,14 @@ func (s *ContractorService) ToggleFavorite(ctx context.Context, contractorID, us
|
||||
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)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperrors.NotFound("error.contractor_not_found")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FileOwnershipService checks whether a user owns a file referenced by URL.
|
||||
// It queries task completion images, document files, and document images
|
||||
// to determine ownership through residence access.
|
||||
// FileOwnershipService checks whether a user has access to a file referenced
|
||||
// by URL. It queries task completion images, document files, and document
|
||||
// images, resolving access through residence ownership or membership.
|
||||
type FileOwnershipService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -17,16 +17,31 @@ func NewFileOwnershipService(db *gorm.DB) *FileOwnershipService {
|
||||
return &FileOwnershipService{db: db}
|
||||
}
|
||||
|
||||
// IsFileOwnedByUser checks if the given file URL belongs to a record
|
||||
// that the user has access to (via residence membership).
|
||||
// accessibleResidenceIDs returns a subquery of residence IDs the user can
|
||||
// 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) {
|
||||
// Check task completion images: image_url -> completion -> task -> residence -> user access
|
||||
// Task completion images: image_url -> completion -> task -> residence.
|
||||
var completionImageCount int64
|
||||
err := s.db.Model(&models.TaskCompletionImage{}).
|
||||
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 residence_residence_users ON residence_residence_users.residence_id = task_task.residence_id").
|
||||
Where("task_taskcompletionimage.image_url = ? AND residence_residence_users.user_id = ?", fileURL, userID).
|
||||
Where("task_taskcompletionimage.image_url = ?", fileURL).
|
||||
Where("task_task.residence_id IN (?)", s.accessibleResidenceIDs(userID)).
|
||||
Count(&completionImageCount).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -35,11 +50,11 @@ func (s *FileOwnershipService) IsFileOwnedByUser(fileURL string, userID uint) (b
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check document files: file_url -> document -> residence -> user access
|
||||
// Document files: file_url -> document -> residence.
|
||||
var documentCount int64
|
||||
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 = ? AND residence_residence_users.user_id = ?", fileURL, userID).
|
||||
Where("task_document.file_url = ?", fileURL).
|
||||
Where("task_document.residence_id IN (?)", s.accessibleResidenceIDs(userID)).
|
||||
Count(&documentCount).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -48,12 +63,12 @@ func (s *FileOwnershipService) IsFileOwnedByUser(fileURL string, userID uint) (b
|
||||
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
|
||||
err = s.db.Model(&models.DocumentImage{}).
|
||||
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 = ? AND residence_residence_users.user_id = ?", fileURL, userID).
|
||||
Where("task_documentimage.image_url = ?", fileURL).
|
||||
Where("task_document.residence_id IN (?)", s.accessibleResidenceIDs(userID)).
|
||||
Count(&documentImageCount).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -2,132 +2,306 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
)
|
||||
|
||||
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 (
|
||||
ErrInvalidGoogleToken = errors.New("invalid Google ID token")
|
||||
ErrGoogleTokenExpired = errors.New("Google ID token has expired")
|
||||
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
|
||||
type GoogleTokenInfo struct {
|
||||
Sub string `json:"sub"` // Unique Google user ID
|
||||
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
|
||||
// GoogleJWKS represents Google's JSON Web Key Set.
|
||||
type GoogleJWKS struct {
|
||||
Keys []GoogleJWK `json:"keys"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return t.EmailVerified == "true"
|
||||
}
|
||||
|
||||
// GoogleAuthService handles Google Sign In token verification
|
||||
// GoogleAuthService handles Google Sign In token verification.
|
||||
type GoogleAuthService struct {
|
||||
cache *CacheService
|
||||
config *config.Config
|
||||
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 {
|
||||
return &GoogleAuthService{
|
||||
cache: cache,
|
||||
config: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
client: &http.Client{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) {
|
||||
// Call Google's tokeninfo endpoint to verify the token
|
||||
url := fmt.Sprintf("%s?id_token=%s", googleTokenInfoURL, idToken)
|
||||
|
||||
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 {
|
||||
// Parse the token header to get the key ID.
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidGoogleToken
|
||||
}
|
||||
|
||||
var tokenInfo GoogleTokenInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token info: %w", err)
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
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)
|
||||
if !s.verifyAudience(tokenInfo.Aud, tokenInfo.Azp) {
|
||||
publicKey, err := s.getPublicKey(ctx, header.Kid)
|
||||
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
|
||||
}
|
||||
|
||||
// Verify the token is not expired (tokeninfo endpoint already checks this,
|
||||
// but we double-check for security)
|
||||
if tokenInfo.Sub == "" {
|
||||
if claims.Subject == "" {
|
||||
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).
|
||||
// In production (non-debug), an empty clientID causes verification to fail
|
||||
// rather than silently bypassing the check.
|
||||
func (s *GoogleAuthService) verifyAudience(aud, azp string) bool {
|
||||
// verifyAudience checks the token audience against our configured client IDs.
|
||||
// In production (non-debug) an empty client ID fails verification rather than
|
||||
// silently bypassing the check.
|
||||
func (s *GoogleAuthService) verifyAudience(audience jwt.ClaimStrings, azp string) bool {
|
||||
clientID := s.config.GoogleAuth.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
|
||||
}
|
||||
// In production, missing client ID means we cannot verify the audience
|
||||
return false
|
||||
for _, aud := range audience {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -68,13 +68,14 @@ type AppleTransactionInfo struct {
|
||||
|
||||
// AppleValidationResult contains the result of Apple receipt validation
|
||||
type AppleValidationResult struct {
|
||||
Valid bool
|
||||
TransactionID string
|
||||
ProductID string
|
||||
ExpiresAt time.Time
|
||||
IsTrialPeriod bool
|
||||
AutoRenewEnabled bool
|
||||
Environment string
|
||||
Valid bool
|
||||
TransactionID string
|
||||
OriginalTransactionID string // stable across renewals — the replay key
|
||||
ProductID string
|
||||
ExpiresAt time.Time
|
||||
IsTrialPeriod bool
|
||||
AutoRenewEnabled bool
|
||||
Environment string
|
||||
}
|
||||
|
||||
// GoogleValidationResult contains the result of Google token validation
|
||||
@@ -95,6 +96,21 @@ func NewAppleIAPClient(cfg config.AppleIAPConfig) (*AppleIAPClient, error) {
|
||||
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
|
||||
keyData, err := os.ReadFile(cfg.KeyPath)
|
||||
if err != nil {
|
||||
@@ -215,11 +231,12 @@ func (c *AppleIAPClient) ValidateTransaction(ctx context.Context, transactionID
|
||||
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
|
||||
|
||||
return &AppleValidationResult{
|
||||
Valid: true,
|
||||
TransactionID: transactionInfo.TransactionID,
|
||||
ProductID: transactionInfo.ProductID,
|
||||
ExpiresAt: expiresAt,
|
||||
Environment: transactionInfo.Environment,
|
||||
Valid: true,
|
||||
TransactionID: transactionInfo.TransactionID,
|
||||
OriginalTransactionID: transactionInfo.OriginalTransactionID,
|
||||
ProductID: transactionInfo.ProductID,
|
||||
ExpiresAt: expiresAt,
|
||||
Environment: transactionInfo.Environment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -243,11 +260,12 @@ func (c *AppleIAPClient) ValidateReceipt(ctx context.Context, receiptData string
|
||||
if err == nil {
|
||||
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
|
||||
return &AppleValidationResult{
|
||||
Valid: true,
|
||||
TransactionID: transactionInfo.TransactionID,
|
||||
ProductID: transactionInfo.ProductID,
|
||||
ExpiresAt: expiresAt,
|
||||
Environment: transactionInfo.Environment,
|
||||
Valid: true,
|
||||
TransactionID: transactionInfo.TransactionID,
|
||||
OriginalTransactionID: transactionInfo.OriginalTransactionID,
|
||||
ProductID: transactionInfo.ProductID,
|
||||
ExpiresAt: expiresAt,
|
||||
Environment: transactionInfo.Environment,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -317,11 +335,12 @@ func (c *AppleIAPClient) ValidateReceipt(ctx context.Context, receiptData string
|
||||
expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0)
|
||||
|
||||
return &AppleValidationResult{
|
||||
Valid: true,
|
||||
TransactionID: transactionInfo.TransactionID,
|
||||
ProductID: transactionInfo.ProductID,
|
||||
ExpiresAt: expiresAt,
|
||||
Environment: transactionInfo.Environment,
|
||||
Valid: true,
|
||||
TransactionID: transactionInfo.TransactionID,
|
||||
OriginalTransactionID: transactionInfo.OriginalTransactionID,
|
||||
ProductID: transactionInfo.ProductID,
|
||||
ExpiresAt: expiresAt,
|
||||
Environment: transactionInfo.Environment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -418,13 +437,14 @@ func (c *AppleIAPClient) validateLegacyReceiptWithSandbox(ctx context.Context, r
|
||||
}
|
||||
|
||||
return &AppleValidationResult{
|
||||
Valid: true,
|
||||
TransactionID: latestReceipt.TransactionID,
|
||||
ProductID: latestReceipt.ProductID,
|
||||
ExpiresAt: expiresAt,
|
||||
IsTrialPeriod: latestReceipt.IsTrialPeriod == "true",
|
||||
AutoRenewEnabled: autoRenew,
|
||||
Environment: legacyResponse.Environment,
|
||||
Valid: true,
|
||||
TransactionID: latestReceipt.TransactionID,
|
||||
OriginalTransactionID: latestReceipt.OriginalTransactionID,
|
||||
ProductID: latestReceipt.ProductID,
|
||||
ExpiresAt: expiresAt,
|
||||
IsTrialPeriod: latestReceipt.IsTrialPeriod == "true",
|
||||
AutoRenewEnabled: autoRenew,
|
||||
Environment: legacyResponse.Environment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -308,7 +308,18 @@ func (s *NotificationService) registerAPNSDevice(ctx context.Context, userID uin
|
||||
// Check if device exists
|
||||
existing, err := s.notificationRepo.WithContext(ctx).FindAPNSDeviceByToken(req.RegistrationID)
|
||||
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.Active = true
|
||||
existing.Name = req.Name
|
||||
@@ -337,7 +348,18 @@ func (s *NotificationService) registerGCMDevice(ctx context.Context, userID uint
|
||||
// Check if device exists
|
||||
existing, err := s.notificationRepo.WithContext(ctx).FindGCMDeviceByToken(req.RegistrationID)
|
||||
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.Active = true
|
||||
existing.Name = req.Name
|
||||
|
||||
@@ -559,30 +559,22 @@ func (s *ResidenceService) GenerateSharePackage(ctx context.Context, residenceID
|
||||
}, 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) {
|
||||
// Find the share code
|
||||
shareCode, err := s.residenceRepo.WithContext(ctx).FindShareCodeByCode(code)
|
||||
residenceID, alreadyMember, err := s.residenceRepo.WithContext(ctx).JoinWithShareCode(code, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperrors.NotFound("error.share_code_invalid")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if alreadyMember {
|
||||
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 {
|
||||
// The joining user's residence-IDs cache is now stale, and their
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
residence, err := s.residenceRepo.WithContext(ctx).FindByID(shareCode.ResidenceID)
|
||||
residence, err := s.residenceRepo.WithContext(ctx).FindByID(residenceID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
@@ -399,99 +399,135 @@ func (s *SubscriptionService) GetActivePromotions(ctx context.Context, userID ui
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ProcessApplePurchase processes an Apple IAP purchase
|
||||
// Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID)
|
||||
// ProcessApplePurchase processes an Apple IAP purchase.
|
||||
// Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID).
|
||||
func (s *SubscriptionService) ProcessApplePurchase(ctx context.Context, userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) {
|
||||
// Store receipt/transaction data
|
||||
dataToStore := receiptData
|
||||
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.
|
||||
// Apple IAP client must be configured — without server-side validation
|
||||
// we cannot trust client-provided receipts.
|
||||
if s.appleClient == nil {
|
||||
log.Error().Uint("user_id", userID).Msg("Apple IAP validation not configured, rejecting purchase")
|
||||
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()
|
||||
|
||||
var result *AppleValidationResult
|
||||
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 != "" {
|
||||
result, err = s.appleClient.ValidateTransaction(ctx, transactionID)
|
||||
result, err = s.appleClient.ValidateTransaction(vctx, transactionID)
|
||||
} else if receiptData != "" {
|
||||
result, err = s.appleClient.ValidateReceipt(ctx, receiptData)
|
||||
result, err = s.appleClient.ValidateReceipt(vctx, receiptData)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Validation failed -- do NOT fall through to grant Pro.
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Apple validation failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
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
|
||||
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(ctx).UpgradeToPro(userID, expiresAt, "ios"); err != nil {
|
||||
if err := s.subscriptionRepo.WithContext(vctx).UpgradeToPro(userID, expiresAt, "ios"); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Tier flipped — drop cached SubscriptionStatusResponse so the next call
|
||||
// returns Pro immediately instead of stale Free.
|
||||
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
|
||||
// productID is optional but helps validate the specific subscription
|
||||
func (s *SubscriptionService) ProcessGooglePurchase(ctx context.Context, userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
|
||||
// Store purchase token first
|
||||
if err := s.subscriptionRepo.WithContext(ctx).UpdatePurchaseToken(userID, purchaseToken); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Google IAP client must be configured to validate purchases.
|
||||
// Without server-side validation, we cannot trust client-provided tokens.
|
||||
// Google IAP client must be configured — without server-side validation
|
||||
// we cannot trust client-provided tokens.
|
||||
if s.googleClient == nil {
|
||||
log.Error().Uint("user_id", userID).Msg("Google IAP validation not configured, rejecting purchase")
|
||||
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()
|
||||
|
||||
var result *GoogleValidationResult
|
||||
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 != "" {
|
||||
result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken)
|
||||
result, err = s.googleClient.ValidateSubscription(vctx, productID, purchaseToken)
|
||||
} else {
|
||||
result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs)
|
||||
result, err = s.googleClient.ValidatePurchaseToken(vctx, purchaseToken, KnownSubscriptionIDs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Validation failed -- do NOT fall through to grant Pro.
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Google purchase validation failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, apperrors.BadRequest("error.no_purchase_token")
|
||||
}
|
||||
@@ -499,24 +535,23 @@ func (s *SubscriptionService) ProcessGooglePurchase(ctx context.Context, userID
|
||||
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")
|
||||
|
||||
// Acknowledge the subscription if not already acknowledged
|
||||
// Acknowledge the subscription if not already acknowledged.
|
||||
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")
|
||||
// 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(ctx).UpgradeToPro(userID, expiresAt, "android"); err != nil {
|
||||
if err := s.subscriptionRepo.WithContext(vctx).UpgradeToPro(userID, expiresAt, "android"); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user