Skip to content

Commit 07f055c

Browse files
os-zhuangclaude
andauthored
feat(auth): last-login audit fields + ADR-0069 status (P1 completion) (#2570)
Fills the one missing ADR-0069 D7 identity field pair — the rest of P1 (complexity/history/expiry/HIBP/lockout/enforced-MFA) was already implemented (this task was evaluate-first, don't re-develop). - platform-objects: add sys_user.last_login_at (datetime) + last_login_ip (text, 45 = IPv6 max), both readonly/Admin group. - plugin-auth: AuthManager.stampLastLogin stamps them on every successful /sign-in/email via the after-hook, independent of the lockout threshold (which gates recordSignInOutcome). IP from the trusted forwarded headers (x-forwarded-for → cf-connecting-ip → x-real-ip), capped to the column width. Best-effort: never turns a valid login into an error. - 4 unit tests (Date stamp + IP, runs when lockout off, IP cap, never throws on engine failure). - ADR-0069 status Proposed → Accepted (P1/P2 implemented) with an implementation-status matrix documenting what is landed and the remaining P2 gaps (per-org sys_organization.allowed_ip_ranges, shared-store rate limiting for multi-node). Verified: plugin-auth 5 files / platform-objects 2 files green; live showcase run shows last_login_at/ip populated after sign-in (IP from x-forwarded-for), and the pre-existing D1 complexity + D2 lockout enforced end-to-end when enabled via settings. Claude-Session: https://claude.ai/code/session_014y5kiH3aPLWtRRRGcVrXcT Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7709db4 commit 07f055c

5 files changed

Lines changed: 124 additions & 1 deletion

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-auth": minor
4+
---
5+
6+
feat(auth): last-login audit fields — sys_user.last_login_at / last_login_ip (ADR-0069 D7)
7+
8+
Completes the ADR-0069 D7 identity-field set: `sys_user.last_login_at` and
9+
`sys_user.last_login_ip` are stamped on every successful `/sign-in/email` by
10+
`AuthManager.stampLastLogin` (a best-effort after-hook, independent of the
11+
lockout-accounting path so it runs even when lockout is disabled). The IP is
12+
taken from the trusted forwarded headers (`x-forwarded-for`
13+
`cf-connecting-ip``x-real-ip`), the same precedence as the D5 IP allow-list
14+
middleware, and capped to the 45-char column width. Both fields are
15+
system-managed, read-only, and land in the Admin group of `sys_user`.
16+
17+
The rest of ADR-0069 P1 (password complexity/history/expiry, HIBP, account
18+
lockout, enforced MFA) was already implemented; this fills the one missing D7
19+
field pair. ADR-0069 status updated Proposed → Accepted (P1/P2 implemented)
20+
with an implementation-status matrix reflecting what is landed vs the remaining
21+
P2 gaps (per-org IP ranges, shared-store rate limiting).

docs/adr/0069-enterprise-authentication-hardening.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0069: Enterprise authentication hardening — password policy, enforced MFA, SSO, session controls, network gating, and anti-brute-force, all enforcement-wired
22

3-
**Status**: Proposed (2026-06-24)
3+
**Status**: Accepted — **P1 + P2 implemented** (2026-07-04); P3 partially landed (see Addendum). Original proposal 2026-06-24.
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (**the governing constraint** — a security property that isn't enforced at runtime is forbidden; no toggle may be a "false surface"), [ADR-0007](./0007-settings-manifest-and-kv-store.md) (settings manifest + cascade KV store), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`sys_role` is platform-native, decoupled from better-auth; org scoping), [ADR-0066](./0066-unified-authorization-model.md) (capability/assignment split), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (`current_user` contract, built-in roles)
66
**Consumers**: `@objectstack/plugin-auth` (better-auth wiring, `bindAuthSettings`/`applyConfigPatch`, auth route middleware), `@objectstack/service-settings` (`auth.manifest.ts`), `@objectstack/platform-objects` (identity objects `sys_user`/`sys_session`/`sys_account`), `@objectstack/rest` (auth request middleware seam), `../objectui` (settings UI rendering)
@@ -112,6 +112,8 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom
112112

113113
Plus admin-facing surface: a `sys_user` list-view filter for locked accounts + an **Unlock** action; a "force password reset" action.
114114

115+
**Implementation status (2026-07-04):** all `sys_user` (incl. `last_login_at`/`last_login_ip`), `sys_session`, `sys_account`, and `sys_organization.require_mfa` fields are landed, plus the `unlock_user` admin action. `last_login_at`/`last_login_ip` are stamped on every successful `/sign-in/email` by `AuthManager.stampLastLogin` (after-hook, independent of lockout config). **Not landed:** a per-record `sys_organization.allowed_ip_ranges` (and optional `sys_user.allowed_ip_ranges` override) — the IP allow-list currently exists only as the **global** `auth.allowed_ip_ranges` setting (D5); per-org/per-user IP scoping is tracked as remaining P2 work.
116+
115117
---
116118

117119
## Enforcement seam summary (where the code hooks)
@@ -132,6 +134,15 @@ Each row in D1-D6 names exactly one of these seams. No setting is introduced wit
132134
- **P2 (defense in depth)**: D4 (idle/absolute/concurrent sessions), D5 (IP allowlist), D6 OIDC trust-list UI.
133135
- **P3 (federation breadth)**: SAML assessment, broader social providers, per-org overrides UI polish.
134136

137+
### Implementation status (2026-07-04)
138+
139+
| Phase | Status | Notes |
140+
|---|---|---|
141+
| **P1** (D1/D2/D3 + D7 fields) |**implemented** | Password complexity/history/expiry (`assertPasswordComplexity`/`assertPasswordNotReused`/`stampPasswordChangedAt`), HIBP (`haveIBeenPwned` plugin), account lockout (`assertAccountNotLocked`/`recordSignInOutcome` + `unlock_user` action), enforced MFA + grace (`computeAuthGate``MFA_REQUIRED`, per-org `require_mfa`), rate-limit tuning (`customRules`). All settings in `auth.manifest.ts`, bound via `bindAuthSettings`. Login-audit fields `last_login_at`/`last_login_ip` stamped on sign-in (`stampLastLogin`). |
142+
| **P2** (D4/D5) | 🟡 **mostly implemented** | Session idle/absolute/concurrent (`enforceSessionControls`/`enforceConcurrentCap`) and the **global** IP allow-list (`isClientIpAllowed`, `auth.allowed_ip_ranges`) are landed. **Remaining:** per-org `sys_organization.allowed_ip_ranges` (+ optional `sys_user.allowed_ip_ranges` override); a **shared/Redis rate-limit store** for multi-node (current store is in-memory). |
143+
| **P2/P3** (D6) | 🟡 partial | Generic OIDC RP wired (`genericOAuth`/`sso`); admin OIDC **trust-list settings UI** still env/`sys_sso_provider`-only. |
144+
| **P3** (SAML, broader social) | 🟡 partial | `@better-auth/sso` present (SAML now better-auth-native — see Addendum); broader settings-driven social providers pending. |
145+
135146
## Out of scope / deferred
136147

137148
- **SCIM / directory provisioning & bulk import** — separate ADR (provisioning, not authentication); tracked in #2246.

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,26 @@ export const SysUser = ObjectSchema.create({
469469
description: 'When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed.',
470470
}),
471471

472+
// ADR-0069 D7 — login audit. Stamped on every successful `/sign-in/email`
473+
// by the auth manager's after-hook (independent of lockout config). Backs
474+
// the admin "last seen" surface + anomaly review; system-managed, read-only.
475+
last_login_at: Field.datetime({
476+
label: 'Last Login At',
477+
required: false,
478+
readonly: true,
479+
group: 'Admin',
480+
description: 'Timestamp of the last successful sign-in. Stamped by the auth manager; system-managed.',
481+
}),
482+
483+
last_login_ip: Field.text({
484+
label: 'Last Login IP',
485+
required: false,
486+
readonly: true,
487+
maxLength: 45, // IPv6 max textual length
488+
group: 'Admin',
489+
description: 'Client IP of the last successful sign-in (from the trusted proxy forwarded header). System-managed.',
490+
}),
491+
472492
ai_access: Field.boolean({
473493
label: 'AI Access',
474494
defaultValue: false,

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,6 +1747,41 @@ describe('AuthManager', () => {
17471747
expect(engine.update).not.toHaveBeenCalled();
17481748
});
17491749

1750+
// ── ADR-0069 D7 — last-login audit stamping ──────────────────────────
1751+
it('stampLastLogin writes last_login_at (a Date) + last_login_ip on success', async () => {
1752+
const engine = makeEngine({ id: 'u1' });
1753+
const m = mgr(engine);
1754+
await (m as any).stampLastLogin('u1', '203.0.113.7');
1755+
const patch = engine.update.mock.calls[0][1];
1756+
expect(patch.id).toBe('u1');
1757+
expect(patch.last_login_at instanceof Date).toBe(true); // Date, never epoch-ms (ADR-0074)
1758+
expect(patch.last_login_ip).toBe('203.0.113.7');
1759+
});
1760+
1761+
it('stampLastLogin runs even when lockout is disabled (independent of threshold)', async () => {
1762+
const engine = makeEngine({ id: 'u1' });
1763+
const m = mgr(engine, { lockoutThreshold: 0 });
1764+
await (m as any).stampLastLogin('u1', undefined);
1765+
expect(engine.update).toHaveBeenCalledTimes(1);
1766+
const patch = engine.update.mock.calls[0][1];
1767+
expect(patch.last_login_at instanceof Date).toBe(true);
1768+
// IP omitted (undetermined) rather than written as null/empty
1769+
expect('last_login_ip' in patch).toBe(false);
1770+
});
1771+
1772+
it('stampLastLogin caps an oversized IP header to the column width (45)', async () => {
1773+
const engine = makeEngine({ id: 'u1' });
1774+
const m = mgr(engine);
1775+
await (m as any).stampLastLogin('u1', 'x'.repeat(200));
1776+
expect(engine.update.mock.calls[0][1].last_login_ip).toHaveLength(45);
1777+
});
1778+
1779+
it('stampLastLogin never throws when the engine write fails', async () => {
1780+
const engine = { ...makeEngine({ id: 'u1' }), update: vi.fn(async () => { throw new Error('db down'); }) };
1781+
const m = mgr(engine);
1782+
await expect((m as any).stampLastLogin('u1', '203.0.113.7')).resolves.toBeUndefined();
1783+
});
1784+
17501785
it('passes a configured rateLimit through to betterAuth', async () => {
17511786
let captured: any;
17521787
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,21 @@ export class AuthManager {
900900
if (succeeded) {
901901
const uid = ctx?.context?.returned?.user?.id;
902902
if (typeof uid === 'string') await this.enforceConcurrentCap(uid);
903+
// ADR-0069 D7 — login audit: stamp last_login_at/ip on success.
904+
// Independent of lockout config (recordSignInOutcome no-ops when
905+
// lockout is off). IP from the trusted forwarded headers, same
906+
// precedence as the D5 allow-list middleware.
907+
if (typeof uid === 'string') {
908+
const hdr = (k: string): string =>
909+
((ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) as string) || '';
910+
const fwd = hdr('x-forwarded-for');
911+
const ip =
912+
(fwd && fwd.split(',')[0].trim()) ||
913+
hdr('cf-connecting-ip') ||
914+
hdr('x-real-ip') ||
915+
undefined;
916+
await this.stampLastLogin(uid, ip);
917+
}
903918
}
904919
}
905920
return;
@@ -2720,6 +2735,27 @@ export class AuthManager {
27202735
}
27212736
}
27222737

2738+
/**
2739+
* ADR-0069 D7 — stamp `last_login_at` (+ `last_login_ip` when known) on a
2740+
* successful sign-in. Best-effort and always fire-and-forget safe: a login
2741+
* audit write must never turn a valid login into an error, and it runs
2742+
* unconditionally (unlike lockout accounting, which is gated on a threshold).
2743+
*/
2744+
private async stampLastLogin(userId: string, ip: string | undefined): Promise<void> {
2745+
const engine = this.getDataEngine();
2746+
if (!engine || !userId) return;
2747+
try {
2748+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2749+
const patch: Record<string, unknown> = { id: userId, last_login_at: new Date() };
2750+
// Cap to the column width (IPv6 textual max 45) — a malformed/oversized
2751+
// forwarded header must not blow up the write.
2752+
if (ip) patch.last_login_ip = ip.slice(0, 45);
2753+
await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any);
2754+
} catch {
2755+
// Login audit is best-effort — never break the auth response.
2756+
}
2757+
}
2758+
27232759
/**
27242760
* ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
27252761
* Resets `failed_login_count` and `locked_until`. Returns false when no data

0 commit comments

Comments
 (0)