-
Notifications
You must be signed in to change notification settings - Fork 0
Add current password verification endpoint #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
7df68f6
fix(auth): add current password verification endpoint
7883fc7
fix(auth): rate limit password verification per session
ef75cc1
test(auth): wire verify password limiter in harnesses
864cfb0
fix(auth): default verify password limiter middleware
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| const { | ||
| loginKey, | ||
| mfaLoginKey, | ||
| verifyPasswordKey, | ||
| registerKey, | ||
| reconcileKey, | ||
| voteKey, | ||
|
|
@@ -70,6 +71,30 @@ describe('rate-limit key generators', () => { | |
| }); | ||
| }); | ||
|
|
||
| describe('verifyPasswordKey', () => { | ||
| function mkSession(body, ip, session) { | ||
| return { ...mk(body, ip, { id: 42 }), session }; | ||
| } | ||
|
|
||
| test('buckets by authenticated session.id when present', () => { | ||
| const a = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 100 })); | ||
| const b = verifyPasswordKey(mkSession({}, '9.9.9.9', { id: 100 })); | ||
| expect(a).toBe(b); | ||
| expect(a).toBe('verify-password|s100'); | ||
| }); | ||
|
|
||
| test('two sessions for the same user get distinct password-check buckets', () => { | ||
| const a = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 100 })); | ||
| const b = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 200 })); | ||
| expect(a).not.toBe(b); | ||
| }); | ||
|
|
||
| test('falls back to IP bucket when the user is missing', () => { | ||
| const a = verifyPasswordKey(mk({}, '1.2.3.4')); | ||
| expect(a).toBe('verify-password|ip|1.2.3.4'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('registerKey', () => { | ||
| test('is scoped per IPv4 only', () => { | ||
| const a = registerKey(mk({ email: '[email protected]' }, '1.2.3.4')); | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -303,6 +303,119 @@ describe('auth routes', () => { | |
| expect(res.status).toBe(401); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------- | ||
| // POST /auth/verify-password — read-only "is this the user's current | ||
| // password?" probe. Used by callers that need to bind a client-side | ||
| // derivation to the account credential before acting on it (vault | ||
| // first-write being the motivating case). MUST be a no-op on success | ||
| // (204) and not rotate sessions / counters / anything. | ||
| // --------------------------------------------------------------------- | ||
|
|
||
| describe('POST /auth/verify-password', () => { | ||
| test('204 on matching authHash; the session and the stored credential are untouched', async () => { | ||
| const agent = request.agent(ctx.app); | ||
| await agent | ||
| .post('/auth/register') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1]; | ||
| await agent.post('/auth/verify-email').send({ token }); | ||
| const loginRes = await agent | ||
| .post('/auth/login') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| const csrf = extractCookies(loginRes).csrf; | ||
|
|
||
| const res = await agent | ||
| .post('/auth/verify-password') | ||
| .set('X-CSRF-Token', csrf) | ||
| .send({ authHash: SAMPLE_AUTH }); | ||
| expect(res.status).toBe(204); | ||
| // 204 → no body. | ||
| expect(res.text).toBe(''); | ||
|
|
||
| // The current session is still usable (verify did not log us out). | ||
| const me = await agent.get('/auth/me'); | ||
| expect(me.status).toBe(200); | ||
|
|
||
| // And the credential itself was not rotated by the call — a fresh | ||
| // login with the same authHash still succeeds. | ||
| const fresh = await request(ctx.app) | ||
| .post('/auth/login') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| expect(fresh.status).toBe(200); | ||
| }); | ||
|
|
||
| test('401 on mismatching authHash; subsequent login with the real password still works', async () => { | ||
| const agent = request.agent(ctx.app); | ||
| await agent | ||
| .post('/auth/register') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1]; | ||
| await agent.post('/auth/verify-email').send({ token }); | ||
| const loginRes = await agent | ||
| .post('/auth/login') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| const csrf = extractCookies(loginRes).csrf; | ||
|
|
||
| const bad = await agent | ||
| .post('/auth/verify-password') | ||
| .set('X-CSRF-Token', csrf) | ||
| .send({ authHash: 'deadbeef'.repeat(8) }); | ||
| expect(bad.status).toBe(401); | ||
| expect(bad.body.error).toBe('invalid_credentials'); | ||
|
|
||
| // Re-prove the credential really wasn't rotated. | ||
| const fresh = await request(ctx.app) | ||
| .post('/auth/login') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| expect(fresh.status).toBe(200); | ||
| }); | ||
|
|
||
| test('400 on malformed body', async () => { | ||
| const agent = request.agent(ctx.app); | ||
| await agent | ||
| .post('/auth/register') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1]; | ||
| await agent.post('/auth/verify-email').send({ token }); | ||
| const loginRes = await agent | ||
| .post('/auth/login') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| const csrf = extractCookies(loginRes).csrf; | ||
|
|
||
| const res = await agent | ||
| .post('/auth/verify-password') | ||
| .set('X-CSRF-Token', csrf) | ||
| .send({ authHash: 'not-hex' }); | ||
| expect(res.status).toBe(400); | ||
| expect(res.body.error).toBe('invalid_body'); | ||
| }); | ||
|
|
||
| test('401 when unauthenticated', async () => { | ||
| const res = await request(ctx.app) | ||
| .post('/auth/verify-password') | ||
| .send({ authHash: SAMPLE_AUTH }); | ||
| expect(res.status).toBe(401); | ||
| }); | ||
|
|
||
| test('403 when CSRF header is missing', async () => { | ||
| const agent = request.agent(ctx.app); | ||
| await agent | ||
| .post('/auth/register') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
| const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1]; | ||
| await agent.post('/auth/verify-email').send({ token }); | ||
| await agent | ||
| .post('/auth/login') | ||
| .send({ email: '[email protected]', authHash: SAMPLE_AUTH }); | ||
|
|
||
| const res = await agent | ||
| .post('/auth/verify-password') | ||
| .send({ authHash: SAMPLE_AUTH }); | ||
| expect(res.status).toBe(403); | ||
| expect(res.body.error).toBe('csrf_missing'); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------- | ||
| // PR 7 — atomic vault rewrap inside /auth/change-password | ||
| // --------------------------------------------------------------------- | ||
|
|
@@ -1463,7 +1576,7 @@ describe('createAuthRouter factory contract (Codex round-2 P3)', () => { | |
| }, | ||
| sessionMw: { requireAuth: mw, parse: mw, setSessionCookie: noop, clearSessionCookie: noop }, | ||
| csrfMw: { require: mw, parse: mw, issueCookie: noop, clearCookie: noop }, | ||
| limiters: { login: mw, mfaLogin: mw, register: mw, verifyEmail: mw, vote: mw }, | ||
| limiters: { login: mw, mfaLogin: mw, verifyPassword: mw, register: mw, verifyEmail: mw, vote: mw }, | ||
| baseUrl: 'http://api.test', | ||
| frontendUrl: 'http://app.test', | ||
| scheduler: (fn) => fn(), | ||
|
|
@@ -1498,6 +1611,23 @@ describe('createAuthRouter factory contract (Codex round-2 P3)', () => { | |
| expect(() => createAuthRouter(buildArgs({ vaults: undefined }))).not.toThrow(); | ||
| }); | ||
|
|
||
| test('accepts previous limiter shape without verifyPassword', () => { | ||
| const mw = (_req, _res, next) => next(); | ||
| expect(() => | ||
| createAuthRouter( | ||
| buildArgs({ | ||
| limiters: { | ||
| login: mw, | ||
| mfaLogin: mw, | ||
| register: mw, | ||
| verifyEmail: mw, | ||
| vote: mw, | ||
| }, | ||
| }) | ||
| ) | ||
| ).not.toThrow(); | ||
| }); | ||
|
|
||
| test('still rejects missing runAtomic regardless of vaults', () => { | ||
| expect(() => | ||
| createAuthRouter(buildArgs({ vaults: undefined, runAtomic: undefined })) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
POST /auth/verify-passwordis wired with onlyrequireAuthand CSRF checks, so it exposes an unthrottled 401/204 credential oracle (verifyPasswordStepUp) for any actor who obtains a live session+CSRF token (for example via session theft or XSS). Unlike/auth/change-password, successful guesses here have no user-visible side effect (no password rotation email/session churn), which makes password discovery significantly stealthier and easier to automate; this endpoint should use a limiter (ideally per user/session) similar to other auth-sensitive probes.Useful? React with 👍 / 👎.