Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
208 commits
Select commit Hold shift + click to select a range
6556017
docs: Improve formatting of roadmap in README.md
raymondproguy Mar 9, 2026
a5198bf
ci: add release automation
raymondproguy Mar 9, 2026
4d239f8
ci: fix test path to internal/tests
raymondproguy Mar 9, 2026
3e4252e
ci: add release workflow
raymondproguy Mar 9, 2026
20391a7
fix: add root package for correct imports
raymondproguy Mar 9, 2026
a5d8a96
docs: add better test examples
raymondproguy Mar 9, 2026
ee17e7b
fix: remove old faced directory
raymondproguy Mar 9, 2026
e0f59f4
ci: fix release workflow for public repo
raymondproguy Mar 9, 2026
485f37a
fix: add root package file
raymondproguy Mar 9, 2026
f045ada
Add permissions for contents write access
raymondproguy Mar 9, 2026
ad04905
Fix import statement formatting in main.go
raymondproguy Mar 9, 2026
f380adf
Fix import statement formatting in main.go
raymondproguy Mar 9, 2026
9b05273
Update release.yml
raymondproguy Mar 9, 2026
f643dd9
docs: Add under the hood for crydensync
raymondproguy Mar 10, 2026
4907407
docs: remove duplicate notes
raymondproguy Mar 10, 2026
fcff335
docs: Fixed link typo
raymondproguy Mar 10, 2026
0697563
feat: Update session with hash and lookup for tokens
raymondproguy Mar 24, 2026
4411753
feat: Update session interface with hash and lookup for tokens
raymondproguy Mar 24, 2026
982be13
feat: new session store with hash and lookup for tokens
raymondproguy Mar 24, 2026
5f7f013
feat: update session memory store with hash and lookup for tokens
raymondproguy Mar 24, 2026
6d51a7d
feat: service for hash and lookup for tokens
raymondproguy Mar 24, 2026
6cbc893
feat: Upadate sqlite schemas with new token hash
raymondproguy Mar 24, 2026
e1007b4
feat: Upadte engine with new token hash
raymondproguy Mar 24, 2026
a1fcf08
test: Test for token hashing
raymondproguy Mar 24, 2026
c86cfc8
feat: Session mongodb storage implementation
raymondproguy Mar 24, 2026
f664315
feat: Session postgre sql storage implementation
raymondproguy Mar 24, 2026
a663a7f
fix: Update session postgre sql auto migration
raymondproguy Mar 24, 2026
3d823fa
feat: Add postgres and mongodb method to faced cryden.go file"
raymondproguy Mar 24, 2026
02b84bf
fix: Fixed typo in session store and add getDB method for sqlite
raymondproguy Mar 25, 2026
ca09886
fix: Fixed typo in memory session store
raymondproguy Mar 25, 2026
ddf9818
fix: Fixed rate limit reset in login func
raymondproguy Mar 25, 2026
05b57d2
fix: Fix typo, and used getDB in faced cryden.go file
raymondproguy Mar 25, 2026
868f8a5
fix: Fixed password validation
raymondproguy Mar 25, 2026
3ee5aad
fix: Fixed allow func typos and reset limit
raymondproguy Mar 25, 2026
d8160b6
feat: add close() method
raymondproguy Mar 25, 2026
f6cb37f
fix: Fixed rate limit in login func
raymondproguy Mar 25, 2026
edee87f
feat: New generate secure ID to randomize ID's
raymondproguy Mar 25, 2026
9dacec6
feat: Add basic Close() for memory stores
raymondproguy Mar 25, 2026
875dc99
feat: Add Close() func to cancel db conections in sqlite
raymondproguy Mar 25, 2026
a1e66e6
feat: Add Close() func to cancel db conections in postgre sql and mon…
raymondproguy Mar 25, 2026
7e8fc0f
fix: Fixed typo in token hashing test and also in cryden faced
raymondproguy Apr 3, 2026
5079335
refactor: Refactor package core auth section and clear engine file
raymondproguy Apr 3, 2026
6776066
refactor: split engine.go into auth, user, session files
raymondproguy Apr 4, 2026
fc9fa65
feat: add file audit logger with JSON output and rotation support
raymondproguy Apr 4, 2026
ee9ba7e
test: Added file logger test
raymondproguy Apr 4, 2026
7fa6719
fixed typos and import path
raymondproguy Apr 5, 2026
9d56c29
feat: Device tracking implementation
raymondproguy Apr 5, 2026
14eef16
feat: Add device tracking to cryden faced, add data to memory stores
raymondproguy Apr 5, 2026
9882650
test: Device tracking tests
raymondproguy Apr 5, 2026
2b81468
feat: Update all stores with device tracking, tanles and migration
raymondproguy Apr 5, 2026
8fd2234
chore: remove v1 codebase to start v2 rewrite
raymondproguy02 Jul 26, 2026
6439dec
chore: deleted: .github/workflows/go.yml deleted: .github/workf…
raymondproguy02 Jul 26, 2026
560d81d
feat(store): define UserStore, SessionStore, AuditStore interfaces
raymondproguy02 Jul 26, 2026
db0fbe6
feat(store): define core interfaces and sentinel errors
raymondproguy02 Jul 26, 2026
b680181
feat: sentinel error and hasher interface defination and bcrypt imple…
raymondproguy02 Jul 27, 2026
579c73b
test: salt test and cost validation
raymondproguy02 Jul 27, 2026
b279199
feat: Google uuid ID generator defination and implementation
raymondproguy02 Jul 27, 2026
3a9d56a
test: add unit tests for UUIDv7 uniqueness and sortability
raymondproguy02 Jul 27, 2026
accf0a1
feat: Rate limiter defination and implementation
raymondproguy02 Jul 28, 2026
65a43c9
test: Rate limiter unit test basic
raymondproguy02 Jul 28, 2026
30255b6
feat: token sentinel errors and token random generation
raymondproguy02 Jul 28, 2026
122c039
feat: Access token implemetation and verification
raymondproguy02 Jul 28, 2026
5b25a5f
feat: opaque token rotation and verification
raymondproguy02 Jul 28, 2026
ccc8d1a
test: token generator and hash test
raymondproguy02 Jul 28, 2026
77f8a35
feat: access token tests, verify and validate
raymondproguy02 Jul 28, 2026
6730518
feat: add more store defination for more features in v2.1
raymondproguy02 Jul 28, 2026
9b46146
feat: operational logging, engine-internal for dev debug: warn, debug…
raymondproguy02 Jul 28, 2026
65bba7c
feat: operational logging, engine-internal for dev debug: warn, debug…
raymondproguy02 Jul 28, 2026
a91cedc
feat: email delivery for verification, no imlm in v2
raymondproguy02 Aug 9, 2026
b31df33
feat(auth): signup and it's test
raymondproguy02 Aug 9, 2026
2a9ba73
feat(auth): login and it's login_test
raymondproguy02 Aug 9, 2026
63ba6e9
feat(auth): user account and test
raymondproguy02 Aug 9, 2026
c6efaca
feat(auth): email feature, verify and test
raymondproguy02 Aug 9, 2026
e7b5456
feat(auth): sentinel error, logout and test
raymondproguy02 Aug 9, 2026
7b23448
feat: token refresh or access
raymondproguy02 Aug 9, 2026
8c31f0a
feat(auth): session verification, validattion revocation and test
raymondproguy02 Aug 10, 2026
c82e125
feat(store): memory impl for test and local persistence
raymondproguy02 Aug 10, 2026
56fad58
feat(store): postgres impl for remote persistence
raymondproguy02 Aug 10, 2026
24d8f2c
feat: Public facade and configs, wrapping up
raymondproguy02 Aug 11, 2026
3cfcc4c
fix: typo in config test file
raymondproguy02 Aug 11, 2026
233ff22
docs: Readme contributing security code-of-conduct docs
raymondproguy02 Aug 11, 2026
808cc73
Merge pull request #2 from crydensync/crydenv2
raymondproguy02 Aug 11, 2026
d413769
format codebase with gofmt
raymondproguy02 Aug 11, 2026
ad10913
fix: fix hasher err not nil value
raymondproguy02 Aug 11, 2026
abfeb9f
test: postgres integration test
raymondproguy02 Aug 11, 2026
b001c44
ci: fix test path, go version, add vet and postgres integration
raymondproguy02 Aug 11, 2026
6275263
Update README.md
raymondproguy02 Aug 11, 2026
12d2b72
format codebase with gofmt
raymondproguy02 Aug 11, 2026
50a8d20
Update README.md
raymondproguy02 Aug 11, 2026
5f29c50
Update README.md
raymondproguy02 Aug 11, 2026
43499fd
Update SECURITY.md
raymondproguy02 Aug 11, 2026
9fe9b93
feat: extend interface defination for new features
raymondproguy Aug 20, 2026
1818b15
chore: temporarily remove CI while iterating on oauth/ai branch
raymondproguy Aug 20, 2026
fb1e933
feat: implement new methods to package store
raymondproguy Aug 20, 2026
0b664c4
feat: add all new methods to cryden facade
raymondproguy Aug 20, 2026
f92bd30
feat: oauth defination and implemtation with tests
raymondproguy Aug 21, 2026
6de8890
feat: oauth stores implemtation and db migration
raymondproguy Aug 21, 2026
ac66808
feat: ai types defination, validation and execution
raymondproguy Aug 21, 2026
f7e7b72
test: test suite for ai validation and execution
raymondproguy Aug 21, 2026
db04bb7
feat: added ai query to store interface defination and one real postg…
raymondproguy Aug 21, 2026
a49ae62
feat: update facade, config struct and engine with new oauth and ai f…
raymondproguy Aug 21, 2026
063587d
docs: update README with new oauth ai features
raymondproguy Aug 21, 2026
c81fe15
format codebase with gofmt
raymondproguy Aug 21, 2026
d84e1a6
feat: smoketest for new oauth ai features
raymondproguy Aug 21, 2026
5a41a3c
ci: added as it was remove previously for test
raymondproguy Aug 24, 2026
9677f6a
Merge pull request #3 from crydensync/oauth-ai
raymondproguy Aug 24, 2026
702094c
fix: return real error instead of swallowing crypto/rand failure
raymondproguy Aug 30, 2026
da264a3
test: add regression coverage for the swallowed rand.Read error
raymondproguy Aug 30, 2026
7eca8c7
fix: close login timing side-channel for nonexistent-email attempts
raymondproguy Aug 30, 2026
e9a5aeb
test: add timing regression test for the login enumeration fix
raymondproguy Aug 30, 2026
bfc9d89
feat: add Encryptor interface and AES-256-GCM implementation
raymondproguy Aug 30, 2026
08cdc12
test: add Encryptor unit tests
raymondproguy Aug 30, 2026
cb146f3
feat: add TOTPGenerator interface backed by pquerna/otp
raymondproguy Aug 30, 2026
87434fe
test: add TOTPGenerator unit tests
raymondproguy Aug 30, 2026
adf1f27
feat: add TOTPStore interface and TOTPSecret type
raymondproguy Aug 30, 2026
c182f7b
feat: add in-memory TOTPStore implementation
raymondproguy Aug 30, 2026
30435ac
feat: add Postgres TOTPStore implementation
raymondproguy Aug 30, 2026
7ce93d3
feat: add totp_secrets table migration
raymondproguy Aug 30, 2026
83ba8db
feat: add MFAPendingIssuer for second-factor login handoff
raymondproguy Aug 30, 2026
f909e86
feat: add TOTP enrollment, confirmation, and disable flows
raymondproguy Aug 30, 2026
2135756
test: add unit tests for TOTP enrollment/confirm/disable
raymondproguy Aug 30, 2026
2584a2a
feat: pause Login with ErrTOTPRequired for accounts with 2FA enabled
raymondproguy Aug 30, 2026
c3354e2
test: add Login/CompleteLoginWithTOTP integration tests
raymondproguy Aug 30, 2026
3c2c801
feat: wire TOTP into Config, Engine, and the public facade
raymondproguy Aug 30, 2026
4fa2374
docs: document TOTP (2FA) setup and usage in README
raymondproguy Aug 30, 2026
295cb71
docs: add manual testing guide for 2FA/TOTP
raymondproguy Aug 30, 2026
2cb21f2
feat: add in-memory smoke test for 2FA/TOTP
raymondproguy Aug 30, 2026
f8e36f5
feat: add WebAuthnCredential type and WebAuthnCredentialStore interface
raymondproguy Aug 31, 2026
5c0f146
feat: add in-memory WebAuthnCredentialStore implementation
raymondproguy Aug 31, 2026
72d77b3
feat: add Postgres WebAuthnCredentialStore implementation
raymondproguy Aug 31, 2026
1e2c415
feat: add webauthn_credentials table migration
raymondproguy Aug 31, 2026
3217c19
feat: add WebAuthnProvider interface backed by go-webauthn
raymondproguy Aug 31, 2026
0b83b1b
test: add WebAuthnProvider unit tests using a real simulated authenti…
raymondproguy Aug 31, 2026
71c0dd7
refactor: generalize ErrTOTPRequired into ErrSecondFactorRequired
raymondproguy Aug 31, 2026
495a958
feat: check WebAuthn enrollment in Login's second-factor detection
raymondproguy Aug 31, 2026
ff58049
feat: add passkey registration, listing, deletion, and login completion
raymondproguy Aug 31, 2026
e4a0e45
test: add passkey registration/listing/deletion/login tests
raymondproguy Aug 31, 2026
4d0cb69
test: add Login unified second-factor detection tests
raymondproguy Aug 31, 2026
b44a7e0
feat: wire WebAuthn into Config, Engine, and the public facade
raymondproguy Aug 31, 2026
2dfd4ab
docs: document passkeys (WebAuthn second factor) in README
raymondproguy Aug 31, 2026
3ae512d
docs: add manual testing guide for WebAuthn/passkeys
raymondproguy Aug 31, 2026
6702a99
feat: add in-memory smoke test for WebAuthn/passkeys
raymondproguy Aug 31, 2026
5c5ca49
fix: update 2FA/TOTP smoke test for the ErrSecondFactorRequired rename
raymondproguy Aug 31, 2026
b8754c0
feat: update go.mod go.sum
raymondproguy Aug 31, 2026
c9cc745
feat: add PurposeMagicLink, reusing the existing VerificationStore
raymondproguy Sep 1, 2026
a4b54f8
feat: add MagicLinkSender interface
raymondproguy Sep 1, 2026
0926387
refactor: extract completePrimaryAuth out of Login
raymondproguy Sep 1, 2026
8ce6b99
feat: add RequestMagicLink and CompleteMagicLink
raymondproguy Sep 1, 2026
a024ddc
test: add RequestMagicLink/CompleteMagicLink tests
raymondproguy Sep 1, 2026
11c0218
feat: wire magic-link login into Config, Engine, and the public facade
raymondproguy Sep 1, 2026
a36c6bc
docs: document magic-link login in README
raymondproguy Sep 1, 2026
162a998
docs: add manual testing guide for magic-link login
raymondproguy Sep 1, 2026
4438fbe
feat: add in-memory smoke test for magic-link login
raymondproguy Sep 1, 2026
8d0a2da
fix: configure TOTP in the magic-link smoke test's engine
raymondproguy Sep 1, 2026
9aa732b
feature/magic-link
raymondproguy Sep 1, 2026
9b68030
feat: add RecoveryCode type and RecoveryCodeStore interface
raymondproguy Sep 1, 2026
f64bf9b
feat: add in-memory RecoveryCodeStore implementation
raymondproguy Sep 1, 2026
1c9c822
feat: add Postgres RecoveryCodeStore implementation
raymondproguy Sep 1, 2026
3c0dc5d
feat: add recovery_codes table migration
raymondproguy Sep 1, 2026
3bac5c1
fix: route LoginWithOAuth through completePrimaryAuth
raymondproguy Sep 1, 2026
40c89f3
test: add regression tests for LoginWithOAuth's second-factor gate
raymondproguy Sep 1, 2026
433208d
feat: add recovery code generation and login completion
raymondproguy Sep 1, 2026
63acaad
test: add recovery code tests
raymondproguy Sep 1, 2026
fd466f8
feat: wire recovery codes into Config, Engine, and the public facade
raymondproguy Sep 1, 2026
0cfdfe6
docs: document recovery codes in README
raymondproguy Sep 1, 2026
0ee01a1
docs: add manual testing guide for recovery codes
raymondproguy Sep 1, 2026
a21679d
feat: add in-memory smoke test for recovery codes
raymondproguy Sep 1, 2026
126eb0d
fix: update login_second_factor_test.go for the recoveryCodeStore param
raymondproguy Sep 1, 2026
7df7b9d
fix: generate recovery codes via crypto/rand directly, not TokenGener…
raymondproguy Sep 1, 2026
f87285c
feat: add BreachedPasswordChecker interface
raymondproguy Sep 3, 2026
75adf25
feat: add ErrPasswordBreached and password_breach_rejected audit event
raymondproguy Sep 3, 2026
ede92df
feat: check breached passwords in SignUp and ChangePassword
raymondproguy Sep 3, 2026
a57a93a
test: add breach-check tests
raymondproguy Sep 3, 2026
4230ead
docs: document breached-password check in README
raymondproguy Sep 3, 2026
b9cae19
docs: add manual testing guide for breached-password check
raymondproguy Sep 3, 2026
b206744
feat: add in-memory smoke test for breached-password check
raymondproguy Sep 3, 2026
d3b5762
feat: add PasswordPolicy struct and validation
raymondproguy Sep 3, 2026
aaceea8
test: add PasswordPolicy unit tests
raymondproguy Sep 3, 2026
1348b9c
feat: enforce password policy in SignUp and ChangePassword
raymondproguy Sep 3, 2026
b01f1e3
test: add password policy enforcement tests
raymondproguy Sep 3, 2026
97acd69
feat: restore breach-checker-skipped-by-policy case in the smoke test
raymondproguy Sep 3, 2026
ac7eaf9
docs: document password policy in README
raymondproguy Sep 3, 2026
57a309f
docs: add manual testing guide for password policy
raymondproguy Sep 3, 2026
95d2be1
feat: add in-memory smoke test for password policy
raymondproguy Sep 3, 2026
87beace
fix: don't clobber a partial custom PasswordPolicy on defaulting
raymondproguy Sep 3, 2026
3c5aeee
feat/password-policy
raymondproguy Sep 3, 2026
c267141
feat/breached-password-check
raymondproguy Sep 3, 2026
504ffb8
Merge pull request #4 from crydensync/feature/2fa-totp
raymondproguy Sep 3, 2026
1b9ce38
Merge pull request #5 from crydensync/feature/webauthn-passkeys
raymondproguy Sep 3, 2026
a385c1a
fix/oauth-second-factor-and-recovery-codes
raymondproguy Sep 3, 2026
bc0e013
Merge pull request #7 from crydensync/feature/magic-link
raymondproguy Sep 3, 2026
a872399
Merge pull request #6 from crydensync/fix/oauth-second-factor-and-rec…
raymondproguy Sep 3, 2026
5919a5e
Merge pull request #8 from crydensync/feat/breached-password-check
raymondproguy Sep 3, 2026
1f19988
Merge pull request #9 from crydensync/feat/password-policy
raymondproguy Sep 3, 2026
93418ea
fix: stop mutating the global crypto/rand.Reader in tests
raymondproguy Sep 3, 2026
27a0681
fix: correct wrong signature-counter assumption in webauthn test
raymondproguy Sep 3, 2026
d18bd10
fix: update breach_test.go call sites for the policy param
raymondproguy Sep 3, 2026
e0f8f71
feat/password-policy2
raymondproguy Sep 3, 2026
8835f98
Merge pull request #10 from crydensync/feat/password-policy2
raymondproguy Sep 3, 2026
5b6c7f5
cryden internal docs
raymondproguy Sep 4, 2026
5280b86
feat: add anomaly detection logic and store.AnomalyStore interface
raymondproguy Sep 4, 2026
c7b5398
feat: add memory and postgres AnomalyStore plus migration 0006
raymondproguy Sep 4, 2026
22958be
feat: wire anomaly detection into every primary auth path
raymondproguy Sep 4, 2026
0f70d61
test: cover anomaly detection logic, stores and wiring
raymondproguy Sep 4, 2026
56c3158
test: add anomaly-detection smoke test
raymondproguy Sep 4, 2026
2559fbc
docs: add anomaly-detection manual test guide
raymondproguy Sep 4, 2026
d30ed74
docs: mark anomaly detection done, queue item 9
raymondproguy Sep 4, 2026
c1b6557
Merge branch 'main' into feat/anomaly-detection
raymondproguy Sep 13, 2026
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
81 changes: 81 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# cryden — instructions for Claude Code

Read this file at the start of every session. It is the whole
protocol. Do not deviate to save credits — deviating IS what wastes
them.

## Startup — do exactly this, nothing more

1. Read `docs/development/CURRENT-STATE.md`.
2. Read `docs/development/NEXT.md`.
3. Pick the **first unstarted item** in `NEXT.md`. That is your only
job this session.

Do not read anything else first. Do not "review the codebase to get
oriented." Do not open other branches to "see what's there." The two
files above ARE your orientation — they exist specifically so you
never have to rebuild it from scratch. If a specific implementation
detail in `docs/development/CRYDEN-REVIEW.md` is genuinely needed for
the item you're building, read that one file for that one section —
not the whole thing, not the whole source tree.

## Hard rules — no exceptions

- **Never use the Task tool, subagents, or any background/parallel
worker.** One agent, one thread, one file at a time, foreground
only. If you're about to spin up a helper to "work on this in
parallel," stop — that's exactly the failure mode this file exists
to prevent.
- **Never re-read a file you already read this session**, unless you
just edited it and need to confirm the edit landed correctly.
- **Never re-verify or re-review a feature `NEXT.md`/`CURRENT-STATE.md`
says is already done.** Done means done. Trust the files.
- **Build exactly one item per session, completely, then stop.** Don't
chain into the next item in `NEXT.md` automatically. The human
re-invokes you for the next one — that's the checkpoint, not a
courtesy.
- **One git branch per item**, branched from the current tip of
whatever you're on (check with `git branch --show-current` once,
don't second-guess it after). Name it `feat/<item-slug>` or
`fix/<item-slug>`.
- **Never merge to `main`. Never push, even if you have credentials
configured.** The human reviews and pushes by hand, always.
- **Commit at every real step** (new interface, migration, wiring,
tests, docs, smoke test) — not one giant commit at the end.
- **Commit messages: 5 lines maximum.** One summary line, optionally
2-4 lines of real "why," nothing more. No essay-length commits.
- **Don't ask the human questions mid-task.** If `NEXT.md`'s spec for
the item is ambiguous on some point, make the most reasonable
engineering decision yourself, write one line about it in
`PROGRESS.md`, and keep going. An unattended terminal run can't wait
on an answer — deciding and noting it is strictly better than
blocking.
- Every feature still gets: a `docs/testing/<item>.md` manual test
guide, and a runnable in-memory smoke test at
`cmd/smoketest/<item>/main.go` printing ✓/✗ per step, including
negative cases. This hasn't changed from before.
- `gofmt -l` every changed file before each commit. Run `go build
./...` and `go test ./...` if your environment has real network/
toolchain access; if it doesn't, say so plainly in `PROGRESS.md`
rather than claiming untested code compiles.
- Standard placeholder identity in all examples/tests, unchanged:
`raymondproguy@dev.com` / `Tr0ubl3-Fr33!2026`.

## Before you stop for the session

1. Update `docs/development/CURRENT-STATE.md` — move the item you
built from "in progress"/"not started" to "done," name the branch.
2. Update `docs/development/NEXT.md` — remove the finished item (or
mark it done, whichever the file's own convention is by then),
leave the queue ready for the next invocation.
3. Append one short entry to `docs/development/PROGRESS.md` — date,
item, branch, one line on what got built, one line on any
assumption you made.
4. Commit those three doc updates together, one small commit,
`docs:` prefix.
5. Print a short summary to the terminal: item built, branch name,
what's next in the queue. Nothing else — no recap of the whole
session, no restated plan.

That's the whole loop. Read state → build one thing → update state →
stop.
188 changes: 188 additions & 0 deletions auth/anomaly.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package auth

import (
"context"
"strconv"
"time"

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

// tokenReuseAuditScanLimit bounds how many of a user's most recent
// audit events are scanned for token-reuse history. Bounded on purpose:
// this runs on every successful login, so it must stay a single small
// indexed read. AuditStore has no by-user-AND-type query (ListByUser is
// per-user, SearchByType is system-wide), so the filtering happens here
// — which means a user with more than this many events since their last
// reuse event will not trip the signal. That's an acceptable miss for a
// report-only annotation, and the reuse event itself is still in the
// audit trail regardless.
const tokenReuseAuditScanLimit = 100

// detectLoginAnomalies evaluates one primary-authentication success
// against the account's recent history and records
// store.EventAnomalyDetected if anything looks unusual.
//
// It returns nothing. That is deliberate and not an oversight: this
// feature reports, it never decides. There is no error for a caller to
// branch on, no sentinel for "suspicious," and no way for a failing
// AnomalyStore to stop a legitimate login — every storage error below
// is logged and treated as "no evidence." A detector that can lock
// people out of their own accounts on a false positive (travel, a new
// browser, a shared office IP) is worse than no detector.
//
// Ordering matters: observations are gathered BEFORE this attempt is
// recorded, so the attempt can't appear in its own baseline and quietly
// mark its own IP familiar.
func detectLoginAnomalies(
ctx context.Context,
anomalies store.AnomalyStore,
sessions store.SessionStore,
audit store.AuditStore,
log logger.Logger,
thresholds security.AnomalyThresholds,
user store.User,
callerIP string,
userAgent string,
) {
if anomalies == nil {
return
}

attempt := security.LoginAttemptContext{IP: callerIP, UserAgent: userAgent}
obs := gatherObservations(ctx, anomalies, sessions, audit, log, thresholds, user.ID, callerIP)
signals := thresholds.Evaluate(attempt, obs)

if len(signals) > 0 {
metadata := map[string]string{"signals": security.JoinAnomalySignals(signals)}
// Only the counts behind signals that actually fired — a
// metadata blob of mostly-zero fields makes the ones that matter
// harder to spot in whatever the host app pipes this into.
for _, s := range signals {
switch s {
case security.SignalUserFailureVelocity:
metadata["user_failures"] = strconv.Itoa(obs.RecentUserFailures)
case security.SignalIPFailureVelocity:
metadata["ip_failures"] = strconv.Itoa(obs.RecentIPFailures)
case security.SignalTokenReuse:
metadata["token_reuse_events"] = strconv.Itoa(obs.RecentTokenReuseEvents)
case security.SignalConcurrentSessions:
metadata["active_sessions"] = strconv.Itoa(obs.ActiveSessions)
}
}
if err := audit.Record(ctx, store.AuditEvent{
Type: store.EventAnomalyDetected,
UserID: user.ID,
IP: callerIP,
Metadata: metadata,
}); err != nil {
log.Error("anomaly: audit record failed", map[string]string{"error": err.Error(), "user_id": user.ID})
}
log.Warn("anomaly: login flagged", map[string]string{
"user_id": user.ID,
"ip": callerIP,
"signals": metadata["signals"],
})
}

RecordLoginAttempt(ctx, anomalies, log, store.LoginAttempt{
UserID: user.ID,
IP: callerIP,
UserAgent: userAgent,
Outcome: store.OutcomeSuccess,
})
}

// gatherObservations turns four storage reads into the plain snapshot
// security.AnomalyThresholds.Evaluate judges. Each read degrades
// independently: a failure leaves that one field zero-valued rather
// than abandoning the whole pass, so a broken AnomalyStore doesn't also
// blind the session-count and token-reuse signals.
func gatherObservations(
ctx context.Context,
anomalies store.AnomalyStore,
sessions store.SessionStore,
audit store.AuditStore,
log logger.Logger,
thresholds security.AnomalyThresholds,
userID string,
callerIP string,
) security.AnomalyObservations {
var obs security.AnomalyObservations
now := time.Now()

recent, err := anomalies.ListRecentSuccesses(ctx, userID, thresholds.HistorySize)
if err != nil {
log.Error("anomaly: recent-success lookup failed", map[string]string{"error": err.Error(), "user_id": userID})
} else {
// HasLoginHistory stays false when there's nothing here, which
// suppresses new_ip/new_device for a first-ever login — there is
// no baseline yet to deviate from. It also, deliberately, keeps
// the signals quiet when the read failed above: inventing
// "everything is unfamiliar" out of a storage error would flag
// every login during an outage.
obs.HasLoginHistory = len(recent) > 0
for _, a := range recent {
if a.IP != "" {
obs.KnownIPs = append(obs.KnownIPs, a.IP)
}
if a.UserAgent != "" {
obs.KnownUserAgents = append(obs.KnownUserAgents, a.UserAgent)
}
}
}

since := now.Add(-thresholds.Window)
if count, err := anomalies.CountFailuresForUser(ctx, userID, since); err != nil {
log.Error("anomaly: per-user failure count failed", map[string]string{"error": err.Error(), "user_id": userID})
} else {
obs.RecentUserFailures = count
}

if count, err := anomalies.CountFailuresForIP(ctx, callerIP, since); err != nil {
log.Error("anomaly: per-IP failure count failed", map[string]string{"error": err.Error(), "ip": callerIP})
} else {
obs.RecentIPFailures = count
}

if sessions != nil {
if active, err := sessions.ListByUser(ctx, userID); err != nil {
log.Error("anomaly: active-session count failed", map[string]string{"error": err.Error(), "user_id": userID})
} else {
// ListByUser already filters out revoked sessions in every
// implementation, so this is the active count, not a total.
obs.ActiveSessions = len(active)
}
}

if audit != nil && thresholds.TokenReuseLookback > 0 {
events, err := audit.ListByUser(ctx, userID, tokenReuseAuditScanLimit)
if err != nil {
log.Error("anomaly: token-reuse lookup failed", map[string]string{"error": err.Error(), "user_id": userID})
} else {
cutoff := now.Add(-thresholds.TokenReuseLookback)
for _, e := range events {
if e.Type == store.EventTokenReuseDetected && !e.CreatedAt.Before(cutoff) {
obs.RecentTokenReuseEvents++
}
}
}
}

return obs
}

// RecordLoginAttempt stores one observation, best-effort. Exported so
// every primary-auth path can feed the same history — including the
// failure paths, which are what per-user and per-IP velocity are
// counted from. A nil store is a no-op, so callers never need to check.
func RecordLoginAttempt(ctx context.Context, anomalies store.AnomalyStore, log logger.Logger, attempt store.LoginAttempt) {
if anomalies == nil {
return
}
if err := anomalies.RecordAttempt(ctx, attempt); err != nil {
log.Error("anomaly: attempt record failed", map[string]string{"error": err.Error()})
}
}
Loading
Loading