Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,35 @@ TOTP_ISSUER_NAME=
WEBAUTHN_RP_ID=
WEBAUTHN_RP_DISPLAY_NAME=
WEBAUTHN_RP_ORIGINS=

# Login anomaly detection and credential-stuffing detection. Both are
# report-only — a flagged attempt writes an audit event and nothing else,
# no login is ever blocked — and both are off until ANOMALY_DETECTION is
# set, since they share one store as their on/off switch. The threshold
# vars below default to the engine's own values and only need setting to
# tune them. Zero means whatever the engine says it means per knob — off
# for most, "no event suppression" for the stuffing cooldown — so check
# cryden's own security package before setting one to 0.
ANOMALY_DETECTION=
ANOMALY_WINDOW_MINUTES=
ANOMALY_HISTORY_SIZE=
ANOMALY_USER_FAILURE_VELOCITY=
ANOMALY_IP_FAILURE_VELOCITY=
ANOMALY_MAX_CONCURRENT_SESSIONS=
ANOMALY_TOKEN_REUSE_LOOKBACK_MINUTES=
CREDENTIAL_STUFFING_WINDOW_MINUTES=
CREDENTIAL_STUFFING_TARGET_ACCOUNTS=
CREDENTIAL_STUFFING_COOLDOWN_MINUTES=

# Engine-level rate limiter (per-user, on login/signup/magic-link).
# Leave REDIS_URL unset to keep the in-process limiter — correct for a
# single instance, but with several replicas each keeps its own counters,
# so the effective limit is the configured one times the replica count.
# Set REDIS_URL to share one window across every replica; an unreachable
# Redis then fails those calls closed rather than letting them run
# unlimited. RATE_LIMIT_ATTEMPTS / RATE_LIMIT_WINDOW_SECONDS default to
# cryden's own 10 per minute. This does not affect EDGE_RATE_LIMIT, the
# coarse per-IP limiter, which stays in-process either way.
REDIS_URL=
RATE_LIMIT_ATTEMPTS=
RATE_LIMIT_WINDOW_SECONDS=
3 changes: 2 additions & 1 deletion CODEX.md → CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ all, it's this repo's job to add, not a reason to go patch cryden.
that gets its own branch, same as cryden's own tiered branches.
- **Conventional commits** (`feat:`, `fix:`, `chore:`, `docs:`,
`test:`), one logical step per commit, same discipline cryden's own
history follows. Don't squash unrelated changes into one commit.
history follows. Don't squash unrelated changes into one commit,
commit messages should not be more that five lines.
- **Update the docs at the end of each tier**: mark it done in
`NEXT.md`, add its section to `CURRENT-STATE.md`, log it in
`PROGRESS.md`. A tier isn't finished until the docs say so.
Expand Down
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,22 @@ Magic-link login needs no extra configuration — it reuses the same `Verificati
## Rate limiting

Two independent layers:
- **Engine-level** (per-user, on login/signup specifically) — already built into CrydenSync itself, protects against credential stuffing.
- **Edge-level** (per-IP, applied to every request) — coarser, protects the whole API surface from being hammered generally. Configurable via `EDGE_RATE_LIMIT` (default 100 requests/minute per IP). In-memory, per-process — like the engine's own limiter, this does NOT share state across multiple instances behind a load balancer. Fine for a single instance; a Redis-backed version is the natural upgrade path once you scale horizontally.
- **Engine-level** (per-user, on login/signup/magic-link) — already built into CrydenSync itself. `RATE_LIMIT_ATTEMPTS` / `RATE_LIMIT_WINDOW_SECONDS` tune it (default 10 per minute). In-memory and per-process by default, which is correct for exactly one instance; set `REDIS_URL` and every replica counts against one shared window instead of each keeping its own. Note the trade-off that comes with that: cryden fails those three entry points closed while Redis is unreachable, rather than letting them run unlimited.
- **Edge-level** (per-IP, applied to every request) — coarser, protects the whole API surface from being hammered generally. Configurable via `EDGE_RATE_LIMIT` (default 100 requests/minute per IP). Still in-memory and per-process, `REDIS_URL` or not — a shared window for the coarse per-IP guard is its own decision, not this one.

## Anomaly detection

Off by default. Set `ANOMALY_DETECTION=true` to turn on cryden's login anomaly detection and credential-stuffing detection — they share one store as their on/off switch, because they are the same login-attempt history read two ways. Both are **report-only**: a flagged attempt records an audit event (`anomaly_detected`, `credential_stuffing_detected`) and nothing else. No login is ever blocked, delayed or challenged by them, and neither returns an error a client could branch on.

Every threshold below defaults to the engine's own value and only needs setting to tune it. Zero means whatever the engine says it means per knob (off for most, "no event suppression" for the stuffing cooldown) — check cryden's own `security` package before setting one to `0`.

```
ANOMALY_WINDOW_MINUTES=15 ANOMALY_HISTORY_SIZE=20
ANOMALY_USER_FAILURE_VELOCITY=5 ANOMALY_IP_FAILURE_VELOCITY=20
ANOMALY_MAX_CONCURRENT_SESSIONS=10 ANOMALY_TOKEN_REUSE_LOOKBACK_MINUTES=1440
CREDENTIAL_STUFFING_WINDOW_MINUTES=60 CREDENTIAL_STUFFING_TARGET_ACCOUNTS=10
CREDENTIAL_STUFFING_COOLDOWN_MINUTES=15
```

## Response contract

Expand Down Expand Up @@ -142,8 +156,12 @@ POST /v1/login/totp (completes a paused login)
POST /v1/login/passkey/begin (completes a paused login)
POST /v1/login/passkey/finish (completes a paused login)
POST /v1/login/recovery-code (completes a paused login)

GET /v1/admin/oauth/health (admin required)
```

`GET /v1/sessions` answers with *named* sessions: each entry keeps its `id`, `ip`, `user_agent` and `created_at`, and gains `label`, `device` and `location`, all computed on read from the session's own IP and User-Agent — nothing new is stored and no migration exists for it. `label` is the string a "your devices" screen shows (`Chrome on macOS`, or `Unknown device` for a client that sent no User-Agent). `location` is present but empty unless a geolocator is configured, and this repo wires none on purpose: every implementation of that interface calls somebody else's internet service, which is a deployment's decision rather than this repo's. The response shape is documented in `openapi/spec.yaml`.

`{provider}` is `google`, `github`, `microsoft`, `discord`, `gitlab` or `apple`.
The two OAuth flows are separate
on purpose:
Expand All @@ -163,6 +181,30 @@ call `/oauth/{provider}/link` while authenticated to resolve it.

Authenticated endpoints expect `Authorization: Bearer <access_token>`.

## Admin endpoints

Everything under `/v1/admin` requires an **operator** token: a valid access token whose `role` claim is `admin`. Operator status is this repo's own concept, not cryden's — it lives in its own `operators` table (`migrations/003_operators.*.sql`), and the claim is attached to the token at issue time by the `AccessTokenClaims` provider in `main.go`. An ordinary user's token carries no `role` claim at all, so "revoked operator", "never was one" and "no such user" are indistinguishable to a caller, deliberately.

The first operator is created with `cmd/grant-operator`, from a machine with direct database access — deliberately not an HTTP bootstrap route, which would be needless attack surface reachable over the network:

```
go run ./cmd/grant-operator -db "$DATABASE_URL" -email you@example.com
go run ./cmd/grant-operator -db "$DATABASE_URL" -email you@example.com -revoke
```

Because the claim is baked in at issue time, a grant takes effect on that user's next login or refresh, and a revoke the same way — an already-issued token keeps its claim until it expires (15 minutes by default).

`GET /v1/admin/oauth/health` reports, per provider, whether it is configured and whether its authorize endpoint answers:

| `status` | meaning |
| --- | --- |
| `ok` | Answered below 500. A bare GET with no OAuth parameters legitimately gets a 4xx, which still proves the endpoint is serving. |
| `degraded` | Answered 5xx. |
| `unreachable` | No HTTP response at all (DNS, TLS, connect, timeout) — `error` carries the transport error. |
| `not_configured` | No client ID/secret for it, so nothing was probed. |

Probes run concurrently with a 5-second timeout each, carry no OAuth parameters and cannot start or complete a login. This is api-side logic: cryden knows whether a provider is configured, not whether it is reachable.

## Design notes

- `CORS_ORIGINS` is required, no wildcard default — an API handling auth tokens should never allow every origin.
Expand Down
156 changes: 156 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"time"

"github.com/crydensync/cryden/v2/security"
)

type Config struct {
Expand Down Expand Up @@ -69,6 +71,48 @@ type Config struct {
WebAuthnRPID string
WebAuthnRPDisplayName string
WebAuthnRPOrigins []string

// AnomalyDetection switches cryden's login anomaly detection and
// credential-stuffing detection on. They share one store as their
// on/off switch (see main.go) because they are the same
// login-attempt history read two ways, and the engine has no partial
// mode: with no store set neither runs and nothing about login
// changes. Off unless explicitly enabled, and report-only either way
// — a flagged attempt records an audit event, it never blocks.
AnomalyDetection bool

// AnomalyThresholds and CredentialStuffingThresholds start from the
// engine's own security.Default* values and are then overridden one
// env var at a time. That order matters: cryden reads every field of
// a non-zero thresholds struct, so a struct built from only the env
// vars that happened to be set would silently zero every knob left
// out rather than falling back to its default.
AnomalyThresholds security.AnomalyThresholds
CredentialStuffingThresholds security.CredentialStuffingThresholds

// RedisURL points the engine's own rate limiter — the fine-grained,
// per-user one covering login, signup and magic-link requests — at a
// Redis so every replica counts against one window. Empty keeps
// cryden's in-process limiter, which is correct for exactly one
// process: three replicas behind a load balancer each keep their own
// counters, making the effective limit three times what was
// configured.
//
// This does not touch the coarse per-IP edge limiter in
// httpapi/ratelimit.go, which stays in-process either way.
RedisURL string

// RateLimitAttempts and RateLimitWindow are the engine limiter's
// bounds in either mode. Their defaults are cryden's own (10 per
// minute) restated here rather than left at zero, because the engine
// only fills a zero value in for the in-process limiter it builds
// itself: with RedisURL set it is this repo that calls
// security.NewRedisRateLimiter, and that constructor rejects a zero
// bound outright. Leaving them at zero would make REDIS_URL on its
// own a startup failure, which is not a setting anyone would expect
// to need company.
RateLimitAttempts int
RateLimitWindow time.Duration
}

// Load reads .env (if present, filling only gaps — real env vars
Expand Down Expand Up @@ -166,9 +210,121 @@ func Load() (Config, error) {
}
}

// Anomaly detection and credential-stuffing detection — one switch,
// because they are one store. Both threshold sets begin as the
// engine's defaults and every knob below only replaces the one it
// names (see the field comments for why that ordering is not
// cosmetic).
var err error
if cfg.AnomalyDetection, err = envBool("ANOMALY_DETECTION", false); err != nil {
return cfg, err
}
cfg.AnomalyThresholds = security.DefaultAnomalyThresholds
cfg.CredentialStuffingThresholds = security.DefaultCredentialStuffingThresholds

if cfg.AnomalyThresholds.Window, err = envMinutes("ANOMALY_WINDOW_MINUTES", cfg.AnomalyThresholds.Window); err != nil {
return cfg, err
}
if cfg.AnomalyThresholds.HistorySize, err = envInt("ANOMALY_HISTORY_SIZE", cfg.AnomalyThresholds.HistorySize); err != nil {
return cfg, err
}
if cfg.AnomalyThresholds.UserFailureVelocity, err = envInt("ANOMALY_USER_FAILURE_VELOCITY", cfg.AnomalyThresholds.UserFailureVelocity); err != nil {
return cfg, err
}
if cfg.AnomalyThresholds.IPFailureVelocity, err = envInt("ANOMALY_IP_FAILURE_VELOCITY", cfg.AnomalyThresholds.IPFailureVelocity); err != nil {
return cfg, err
}
// Zero disables the concurrent-session check specifically, the same
// off switch every other AnomalyThresholds knob has.
if cfg.AnomalyThresholds.MaxConcurrentSessions, err = envInt("ANOMALY_MAX_CONCURRENT_SESSIONS", cfg.AnomalyThresholds.MaxConcurrentSessions); err != nil {
return cfg, err
}
if cfg.AnomalyThresholds.TokenReuseLookback, err = envMinutes("ANOMALY_TOKEN_REUSE_LOOKBACK_MINUTES", cfg.AnomalyThresholds.TokenReuseLookback); err != nil {
return cfg, err
}
if cfg.CredentialStuffingThresholds.Window, err = envMinutes("CREDENTIAL_STUFFING_WINDOW_MINUTES", cfg.CredentialStuffingThresholds.Window); err != nil {
return cfg, err
}
if cfg.CredentialStuffingThresholds.TargetAccounts, err = envInt("CREDENTIAL_STUFFING_TARGET_ACCOUNTS", cfg.CredentialStuffingThresholds.TargetAccounts); err != nil {
return cfg, err
}
if cfg.CredentialStuffingThresholds.Cooldown, err = envMinutes("CREDENTIAL_STUFFING_COOLDOWN_MINUTES", cfg.CredentialStuffingThresholds.Cooldown); err != nil {
return cfg, err
}

// Engine rate limiter. REDIS_URL is the only thing that decides where
// the counters live (see the field comments for why the bounds
// default to cryden's own numbers instead of zero); a URL main.go
// cannot parse is a startup failure rather than a setting that was
// quietly ignored.
cfg.RedisURL = os.Getenv("REDIS_URL")
if cfg.RateLimitAttempts, err = envInt("RATE_LIMIT_ATTEMPTS", 10); err != nil {
return cfg, err
}
if cfg.RateLimitWindow, err = envSeconds("RATE_LIMIT_WINDOW_SECONDS", time.Minute); err != nil {
return cfg, err
}

return cfg, nil
}

// envInt reads an optional integer env var, falling back to def when it
// is unset or empty.
func envInt(name string, def int) (int, error) {
v := os.Getenv(name)
if v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s must be a number: %w", name, err)
}
return n, nil
}

// envBool reads an optional boolean env var, falling back to def when it
// is unset or empty. Accepted spellings are strconv.ParseBool's —
// 1/0, t/f, true/false, T/F, TRUE/FALSE, True/False — so there is
// exactly one set of rules to remember rather than a second dialect
// defined here.
func envBool(name string, def bool) (bool, error) {
v := os.Getenv(name)
if v == "" {
return def, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("%s must be true or false: %w", name, err)
}
return b, nil
}

func envMinutes(name string, def time.Duration) (time.Duration, error) {
return envDurationIn(name, time.Minute, "minutes", def)
}

func envSeconds(name string, def time.Duration) (time.Duration, error) {
return envDurationIn(name, time.Second, "seconds", def)
}

// envDurationIn reads an optional duration env var written as a whole
// number of unit (time.Minute or time.Second — the two granularities any
// knob here needs), falling back to def when it is unset or empty. A
// value of zero is passed through deliberately: it is a real "switch
// this check off" setting for several thresholds, and policing ranges
// here would mean a second copy of each knob's own valid range.
func envDurationIn(name string, unit time.Duration, unitName string, def time.Duration) (time.Duration, error) {
v := os.Getenv(name)
if v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s must be a number of %s: %w", name, unitName, err)
}
return time.Duration(n) * unit, nil
}

func loadEnvFile(path string) {
f, err := os.Open(path)
if err != nil {
Expand Down
Loading
Loading