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
a54c24c
docs: Improve formatting of roadmap in README.md
raymondproguy Mar 9, 2026
68c19b6
ci: add release automation
raymondproguy Mar 9, 2026
12653c2
ci: fix test path to internal/tests
raymondproguy Mar 9, 2026
fa9fbd4
ci: add release workflow
raymondproguy Mar 9, 2026
12beff3
fix: add root package for correct imports
raymondproguy Mar 9, 2026
c0c8dda
docs: add better test examples
raymondproguy Mar 9, 2026
cb647a4
fix: remove old faced directory
raymondproguy Mar 9, 2026
2c37cc2
ci: fix release workflow for public repo
raymondproguy Mar 9, 2026
db18422
fix: add root package file
raymondproguy Mar 9, 2026
fdf4469
Add permissions for contents write access
raymondproguy Mar 9, 2026
852cf63
Fix import statement formatting in main.go
raymondproguy Mar 9, 2026
5fab527
Fix import statement formatting in main.go
raymondproguy Mar 9, 2026
eed5389
Update release.yml
raymondproguy Mar 9, 2026
962f18f
docs: Add under the hood for crydensync
raymondproguy Mar 10, 2026
f3f2731
docs: remove duplicate notes
raymondproguy Mar 10, 2026
818255d
docs: Fixed link typo
raymondproguy Mar 10, 2026
9cf05b0
feat: Update session with hash and lookup for tokens
raymondproguy Mar 24, 2026
e6e3b2f
feat: Update session interface with hash and lookup for tokens
raymondproguy Mar 24, 2026
5f14089
feat: new session store with hash and lookup for tokens
raymondproguy Mar 24, 2026
c00651e
feat: update session memory store with hash and lookup for tokens
raymondproguy Mar 24, 2026
9025d3c
feat: service for hash and lookup for tokens
raymondproguy Mar 24, 2026
b22c91d
feat: Upadate sqlite schemas with new token hash
raymondproguy Mar 24, 2026
309da93
feat: Upadte engine with new token hash
raymondproguy Mar 24, 2026
a99b5c5
test: Test for token hashing
raymondproguy Mar 24, 2026
eeb7ac0
feat: Session mongodb storage implementation
raymondproguy Mar 24, 2026
00f7ba2
feat: Session postgre sql storage implementation
raymondproguy Mar 24, 2026
488ac60
fix: Update session postgre sql auto migration
raymondproguy Mar 24, 2026
1ec80d0
feat: Add postgres and mongodb method to faced cryden.go file"
raymondproguy Mar 24, 2026
e7421df
fix: Fixed typo in session store and add getDB method for sqlite
raymondproguy Mar 25, 2026
4aff200
fix: Fixed typo in memory session store
raymondproguy Mar 25, 2026
efd97da
fix: Fixed rate limit reset in login func
raymondproguy Mar 25, 2026
0494528
fix: Fix typo, and used getDB in faced cryden.go file
raymondproguy Mar 25, 2026
9eadb04
fix: Fixed password validation
raymondproguy Mar 25, 2026
b4f8e6f
fix: Fixed allow func typos and reset limit
raymondproguy Mar 25, 2026
9767faa
feat: add close() method
raymondproguy Mar 25, 2026
c36409a
fix: Fixed rate limit in login func
raymondproguy Mar 25, 2026
b6c4ead
feat: New generate secure ID to randomize ID's
raymondproguy Mar 25, 2026
8b81228
feat: Add basic Close() for memory stores
raymondproguy Mar 25, 2026
0cc9f82
feat: Add Close() func to cancel db conections in sqlite
raymondproguy Mar 25, 2026
186fb15
feat: Add Close() func to cancel db conections in postgre sql and mon…
raymondproguy Mar 25, 2026
3fb33ba
fix: Fixed typo in token hashing test and also in cryden faced
raymondproguy Apr 3, 2026
3b0d8e5
refactor: Refactor package core auth section and clear engine file
raymondproguy Apr 3, 2026
7ae34b5
refactor: split engine.go into auth, user, session files
raymondproguy Apr 4, 2026
bcc98e6
feat: add file audit logger with JSON output and rotation support
raymondproguy Apr 4, 2026
a2b5390
test: Added file logger test
raymondproguy Apr 4, 2026
b52f068
fixed typos and import path
raymondproguy Apr 5, 2026
f2bf08f
feat: Device tracking implementation
raymondproguy Apr 5, 2026
8dfd3c9
feat: Add device tracking to cryden faced, add data to memory stores
raymondproguy Apr 5, 2026
b4f79b9
test: Device tracking tests
raymondproguy Apr 5, 2026
f4be302
feat: Update all stores with device tracking, tanles and migration
raymondproguy Apr 5, 2026
893f570
chore: remove v1 codebase to start v2 rewrite
raymondproguy02 Jul 26, 2026
16aee20
chore: deleted: .github/workflows/go.yml deleted: .github/workf…
raymondproguy02 Jul 26, 2026
07adaa6
feat(store): define UserStore, SessionStore, AuditStore interfaces
raymondproguy02 Jul 26, 2026
a63624f
feat(store): define core interfaces and sentinel errors
raymondproguy02 Jul 26, 2026
cc8d525
feat: sentinel error and hasher interface defination and bcrypt imple…
raymondproguy02 Jul 27, 2026
7161650
test: salt test and cost validation
raymondproguy02 Jul 27, 2026
a27e567
feat: Google uuid ID generator defination and implementation
raymondproguy02 Jul 27, 2026
46005a8
test: add unit tests for UUIDv7 uniqueness and sortability
raymondproguy02 Jul 27, 2026
1ae036d
feat: Rate limiter defination and implementation
raymondproguy02 Jul 28, 2026
0e3d524
test: Rate limiter unit test basic
raymondproguy02 Jul 28, 2026
9dba678
feat: token sentinel errors and token random generation
raymondproguy02 Jul 28, 2026
8ec4eae
feat: Access token implemetation and verification
raymondproguy02 Jul 28, 2026
26a395d
feat: opaque token rotation and verification
raymondproguy02 Jul 28, 2026
637f263
test: token generator and hash test
raymondproguy02 Jul 28, 2026
db39f88
feat: access token tests, verify and validate
raymondproguy02 Jul 28, 2026
97f8464
feat: add more store defination for more features in v2.1
raymondproguy02 Jul 28, 2026
4b20c81
feat: operational logging, engine-internal for dev debug: warn, debug…
raymondproguy02 Jul 28, 2026
5afff63
feat: operational logging, engine-internal for dev debug: warn, debug…
raymondproguy02 Jul 28, 2026
73be7c5
feat: email delivery for verification, no imlm in v2
raymondproguy02 Aug 9, 2026
89f75ea
feat(auth): signup and it's test
raymondproguy02 Aug 9, 2026
5a6d778
feat(auth): login and it's login_test
raymondproguy02 Aug 9, 2026
59d3f78
feat(auth): user account and test
raymondproguy02 Aug 9, 2026
eda74ae
feat(auth): email feature, verify and test
raymondproguy02 Aug 9, 2026
b970a96
feat(auth): sentinel error, logout and test
raymondproguy02 Aug 9, 2026
483e064
feat: token refresh or access
raymondproguy02 Aug 9, 2026
1417513
feat(auth): session verification, validattion revocation and test
raymondproguy02 Aug 10, 2026
5217226
feat(store): memory impl for test and local persistence
raymondproguy02 Aug 10, 2026
b7c9bf3
feat(store): postgres impl for remote persistence
raymondproguy02 Aug 10, 2026
57c1af3
feat: Public facade and configs, wrapping up
raymondproguy02 Aug 11, 2026
5618240
fix: typo in config test file
raymondproguy02 Aug 11, 2026
b1b354c
docs: Readme contributing security code-of-conduct docs
raymondproguy02 Aug 11, 2026
2ae8100
Merge pull request #2 from crydensync/crydenv2
raymondproguy02 Aug 11, 2026
c8d40d8
format codebase with gofmt
raymondproguy02 Aug 11, 2026
9671ad4
fix: fix hasher err not nil value
raymondproguy02 Aug 11, 2026
934875d
test: postgres integration test
raymondproguy02 Aug 11, 2026
96c5512
ci: fix test path, go version, add vet and postgres integration
raymondproguy02 Aug 11, 2026
832bebe
Update README.md
raymondproguy02 Aug 11, 2026
0f7c1a0
format codebase with gofmt
raymondproguy02 Aug 11, 2026
aa031e4
Update README.md
raymondproguy02 Aug 11, 2026
a9466da
Update README.md
raymondproguy02 Aug 11, 2026
266ae0f
Update SECURITY.md
raymondproguy02 Aug 11, 2026
bf828b6
feat: extend interface defination for new features
raymondproguy Aug 20, 2026
02337e9
chore: temporarily remove CI while iterating on oauth/ai branch
raymondproguy Aug 20, 2026
163c659
feat: implement new methods to package store
raymondproguy Aug 20, 2026
099b1ee
feat: add all new methods to cryden facade
raymondproguy Aug 20, 2026
540b29b
feat: oauth defination and implemtation with tests
raymondproguy Aug 21, 2026
2fce6a7
feat: oauth stores implemtation and db migration
raymondproguy Aug 21, 2026
9b16a75
feat: ai types defination, validation and execution
raymondproguy Aug 21, 2026
98c7f3a
test: test suite for ai validation and execution
raymondproguy Aug 21, 2026
f7645c1
feat: added ai query to store interface defination and one real postg…
raymondproguy Aug 21, 2026
f4c9385
feat: update facade, config struct and engine with new oauth and ai f…
raymondproguy Aug 21, 2026
79bb330
docs: update README with new oauth ai features
raymondproguy Aug 21, 2026
11bc15a
format codebase with gofmt
raymondproguy Aug 21, 2026
0b6e10e
feat: smoketest for new oauth ai features
raymondproguy Aug 21, 2026
dd54761
ci: added as it was remove previously for test
raymondproguy Aug 24, 2026
010b7dd
Merge pull request #3 from crydensync/oauth-ai
raymondproguy Aug 24, 2026
c42dac5
fix: return real error instead of swallowing crypto/rand failure
raymondproguy Aug 30, 2026
6847f27
test: add regression coverage for the swallowed rand.Read error
raymondproguy Aug 30, 2026
effab17
fix: close login timing side-channel for nonexistent-email attempts
raymondproguy Aug 30, 2026
9e69de8
test: add timing regression test for the login enumeration fix
raymondproguy Aug 30, 2026
08ce5b2
feat: add Encryptor interface and AES-256-GCM implementation
raymondproguy Aug 30, 2026
16e7fcb
test: add Encryptor unit tests
raymondproguy Aug 30, 2026
e200423
feat: add TOTPGenerator interface backed by pquerna/otp
raymondproguy Aug 30, 2026
02023a4
test: add TOTPGenerator unit tests
raymondproguy Aug 30, 2026
966f947
feat: add TOTPStore interface and TOTPSecret type
raymondproguy Aug 30, 2026
beffe3b
feat: add in-memory TOTPStore implementation
raymondproguy Aug 30, 2026
2e262c8
feat: add Postgres TOTPStore implementation
raymondproguy Aug 30, 2026
a86696c
feat: add totp_secrets table migration
raymondproguy Aug 30, 2026
96f3627
feat: add MFAPendingIssuer for second-factor login handoff
raymondproguy Aug 30, 2026
8691fe6
feat: add TOTP enrollment, confirmation, and disable flows
raymondproguy Aug 30, 2026
7395644
test: add unit tests for TOTP enrollment/confirm/disable
raymondproguy Aug 30, 2026
3d664cd
feat: pause Login with ErrTOTPRequired for accounts with 2FA enabled
raymondproguy Aug 30, 2026
0fd2dcc
test: add Login/CompleteLoginWithTOTP integration tests
raymondproguy Aug 30, 2026
ff39f23
feat: wire TOTP into Config, Engine, and the public facade
raymondproguy Aug 30, 2026
e48d696
docs: document TOTP (2FA) setup and usage in README
raymondproguy Aug 30, 2026
b9a0db8
docs: add manual testing guide for 2FA/TOTP
raymondproguy Aug 30, 2026
fea68e6
feat: add in-memory smoke test for 2FA/TOTP
raymondproguy Aug 30, 2026
54b9e43
feat: add WebAuthnCredential type and WebAuthnCredentialStore interface
raymondproguy Aug 31, 2026
187f7f2
feat: add in-memory WebAuthnCredentialStore implementation
raymondproguy Aug 31, 2026
42ce2a4
feat: add Postgres WebAuthnCredentialStore implementation
raymondproguy Aug 31, 2026
e0470a1
feat: add webauthn_credentials table migration
raymondproguy Aug 31, 2026
817855f
feat: add WebAuthnProvider interface backed by go-webauthn
raymondproguy Aug 31, 2026
70fd2f9
test: add WebAuthnProvider unit tests using a real simulated authenti…
raymondproguy Aug 31, 2026
a4779c2
refactor: generalize ErrTOTPRequired into ErrSecondFactorRequired
raymondproguy Aug 31, 2026
db5577e
feat: check WebAuthn enrollment in Login's second-factor detection
raymondproguy Aug 31, 2026
16a90b9
feat: add passkey registration, listing, deletion, and login completion
raymondproguy Aug 31, 2026
0c23ba6
test: add passkey registration/listing/deletion/login tests
raymondproguy Aug 31, 2026
939327e
test: add Login unified second-factor detection tests
raymondproguy Aug 31, 2026
8ff46a3
feat: wire WebAuthn into Config, Engine, and the public facade
raymondproguy Aug 31, 2026
301f153
docs: document passkeys (WebAuthn second factor) in README
raymondproguy Aug 31, 2026
1c775d4
docs: add manual testing guide for WebAuthn/passkeys
raymondproguy Aug 31, 2026
b1cdaf5
feat: add in-memory smoke test for WebAuthn/passkeys
raymondproguy Aug 31, 2026
eb3c2a3
fix: update 2FA/TOTP smoke test for the ErrSecondFactorRequired rename
raymondproguy Aug 31, 2026
cc45f88
feat: update go.mod go.sum
raymondproguy Aug 31, 2026
e3f56fd
feat: add PurposeMagicLink, reusing the existing VerificationStore
raymondproguy Sep 1, 2026
fa32d75
feat: add MagicLinkSender interface
raymondproguy Sep 1, 2026
065f55a
refactor: extract completePrimaryAuth out of Login
raymondproguy Sep 1, 2026
0c92434
feat: add RequestMagicLink and CompleteMagicLink
raymondproguy Sep 1, 2026
8bc8593
test: add RequestMagicLink/CompleteMagicLink tests
raymondproguy Sep 1, 2026
7c76bff
feat: wire magic-link login into Config, Engine, and the public facade
raymondproguy Sep 1, 2026
ea6da7d
docs: document magic-link login in README
raymondproguy Sep 1, 2026
4804c6f
docs: add manual testing guide for magic-link login
raymondproguy Sep 1, 2026
68a97f9
feat: add in-memory smoke test for magic-link login
raymondproguy Sep 1, 2026
71b7bd5
fix: configure TOTP in the magic-link smoke test's engine
raymondproguy Sep 1, 2026
84998cc
feature/magic-link
raymondproguy Sep 1, 2026
e039f72
feat: add RecoveryCode type and RecoveryCodeStore interface
raymondproguy Sep 1, 2026
c1c0fd5
feat: add in-memory RecoveryCodeStore implementation
raymondproguy Sep 1, 2026
054fdbe
feat: add Postgres RecoveryCodeStore implementation
raymondproguy Sep 1, 2026
99bb746
feat: add recovery_codes table migration
raymondproguy Sep 1, 2026
ebb12eb
fix: route LoginWithOAuth through completePrimaryAuth
raymondproguy Sep 1, 2026
9ac7a7f
test: add regression tests for LoginWithOAuth's second-factor gate
raymondproguy Sep 1, 2026
abf4654
feat: add recovery code generation and login completion
raymondproguy Sep 1, 2026
37d6d7c
test: add recovery code tests
raymondproguy Sep 1, 2026
37178ac
feat: wire recovery codes into Config, Engine, and the public facade
raymondproguy Sep 1, 2026
0580e04
docs: document recovery codes in README
raymondproguy Sep 1, 2026
b30a2c3
docs: add manual testing guide for recovery codes
raymondproguy Sep 1, 2026
e8ec24c
feat: add in-memory smoke test for recovery codes
raymondproguy Sep 1, 2026
145c52e
fix: update login_second_factor_test.go for the recoveryCodeStore param
raymondproguy Sep 1, 2026
9c052e0
fix: generate recovery codes via crypto/rand directly, not TokenGener…
raymondproguy Sep 1, 2026
7e63266
feat: add BreachedPasswordChecker interface
raymondproguy Sep 3, 2026
1198db6
feat: add ErrPasswordBreached and password_breach_rejected audit event
raymondproguy Sep 3, 2026
aa90b63
feat: check breached passwords in SignUp and ChangePassword
raymondproguy Sep 3, 2026
eed56a3
test: add breach-check tests
raymondproguy Sep 3, 2026
77bb193
docs: document breached-password check in README
raymondproguy Sep 3, 2026
b58db32
docs: add manual testing guide for breached-password check
raymondproguy Sep 3, 2026
0a57267
feat: add in-memory smoke test for breached-password check
raymondproguy Sep 3, 2026
58c62e3
feat: add PasswordPolicy struct and validation
raymondproguy Sep 3, 2026
22880b6
test: add PasswordPolicy unit tests
raymondproguy Sep 3, 2026
4c44a4b
feat: enforce password policy in SignUp and ChangePassword
raymondproguy Sep 3, 2026
18d0f29
test: add password policy enforcement tests
raymondproguy Sep 3, 2026
25a5094
feat: restore breach-checker-skipped-by-policy case in the smoke test
raymondproguy Sep 3, 2026
31f0ed5
docs: document password policy in README
raymondproguy Sep 3, 2026
34317d7
docs: add manual testing guide for password policy
raymondproguy Sep 3, 2026
735d82d
feat: add in-memory smoke test for password policy
raymondproguy Sep 3, 2026
17080e0
fix: don't clobber a partial custom PasswordPolicy on defaulting
raymondproguy Sep 3, 2026
735a1af
feat/password-policy
raymondproguy Sep 3, 2026
8fe4e30
feat/breached-password-check
raymondproguy Sep 3, 2026
4ab6093
Merge pull request #4 from crydensync/feature/2fa-totp
raymondproguy Sep 3, 2026
89ab1c9
Merge pull request #5 from crydensync/feature/webauthn-passkeys
raymondproguy Sep 3, 2026
5735135
fix/oauth-second-factor-and-recovery-codes
raymondproguy Sep 3, 2026
8e06036
Merge pull request #7 from crydensync/feature/magic-link
raymondproguy Sep 3, 2026
2ecd86f
Merge pull request #6 from crydensync/fix/oauth-second-factor-and-rec…
raymondproguy Sep 3, 2026
f983528
Merge pull request #8 from crydensync/feat/breached-password-check
raymondproguy Sep 3, 2026
1d86ba1
Merge pull request #9 from crydensync/feat/password-policy
raymondproguy Sep 3, 2026
4bf725d
fix: stop mutating the global crypto/rand.Reader in tests
raymondproguy Sep 3, 2026
86a2aad
fix: correct wrong signature-counter assumption in webauthn test
raymondproguy Sep 3, 2026
da0e58d
fix: update breach_test.go call sites for the policy param
raymondproguy Sep 3, 2026
a0c53e4
feat/password-policy2
raymondproguy Sep 3, 2026
8e95ef7
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