mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(auth): preserve remember-me when password change re-issues the session (#1927)
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
import { decodeSessionClaims } from './jwt-verify';
|
||||
import { getClientIp } from '../audit/client-ip';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { User } from '../../types';
|
||||
@@ -97,13 +98,17 @@ export class AuthController {
|
||||
@Put('me/password')
|
||||
changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.auth.changePassword(user.id, user.email, body);
|
||||
// Carry the session's remember choice into the re-issued token/cookie so a
|
||||
// "remember me" login survives a password change (#1927). Bearer callers
|
||||
// have no cookie → undefined → the historical default duration.
|
||||
const remember = decodeSessionClaims((req.cookies as Record<string, string> | undefined)?.trek_session)?.remember;
|
||||
const result = this.auth.changePassword(user.id, user.email, body, remember);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
// Refresh this device's cookie with the new password_version so the user
|
||||
// stays logged in here while all other sessions are invalidated.
|
||||
if (result.token) this.auth.setAuthCookie(res, result.token, req);
|
||||
if (result.token) this.auth.setAuthCookie(res, result.token, req, remember);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ import { MailerService } from '../../src/nest/notifications/mailer/mailer.servic
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetRateLimits } from '../helpers/test-db';
|
||||
import { AuthModule } from '../../src/nest/auth/auth.module';
|
||||
import { AuthService } from '../../src/nest/auth/auth.service';
|
||||
import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.interceptor';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
@@ -70,6 +73,9 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
|
||||
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
|
||||
// by metatype, exactly as they do under buildApp().
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
// Mirror the production APP_INTERCEPTOR: sliding session renewal (#1927).
|
||||
// Inert for the harness's exp-less tokens and freshly issued logins.
|
||||
nest.useGlobalInterceptors(new SessionRenewalInterceptor(moduleRef.get(AuthService)));
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
@@ -154,6 +160,75 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
|
||||
expect(cookie).not.toMatch(/Expires/i);
|
||||
}, 10000);
|
||||
|
||||
it('renews the session cookie when the token is past half its lifetime (#1927)', async () => {
|
||||
// 70% of a 24h token consumed → the interceptor re-issues the cookie.
|
||||
const res = await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 60000 }));
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = (res.headers['set-cookie'] ?? []) as unknown as string[];
|
||||
const renewed = setCookie.find((c) => c.startsWith('trek_session='))!;
|
||||
expect(renewed).toBeTruthy();
|
||||
expect(renewed).toMatch(/Max-Age=86400/i);
|
||||
// The renewed token is a fresh full-lifetime session for the same user.
|
||||
const jwt = require('jsonwebtoken');
|
||||
const decoded = jwt.decode(/trek_session=([^;]+)/.exec(renewed)![1]) as { id: number; exp: number; iat: number };
|
||||
expect(decoded.id).toBe(userId);
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
}, 10000);
|
||||
|
||||
it('renews a remembered session with the long window and a remember:false one as a session cookie (#1927)', async () => {
|
||||
const long = await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', sessionCookie(userId, 0, { lifetime: 2592000, consumed: 1600000, remember: true }));
|
||||
expect(long.status).toBe(200);
|
||||
const longCookie = ((long.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
|
||||
expect(longCookie).toMatch(/Max-Age=2592000/i);
|
||||
|
||||
const sess = await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 60000, remember: false }));
|
||||
expect(sess.status).toBe(200);
|
||||
const sessCookie = ((sess.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
|
||||
expect(sessCookie).not.toMatch(/Max-Age/i);
|
||||
expect(sessCookie).not.toMatch(/Expires/i);
|
||||
}, 10000);
|
||||
|
||||
it('does not renew a young session cookie (#1927)', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 1000 }));
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = (res.headers['set-cookie'] ?? []) as unknown as string[];
|
||||
expect(setCookie.some((c) => c.startsWith('trek_session='))).toBe(false);
|
||||
}, 10000);
|
||||
|
||||
it('PUT /me/password preserves the remember choice on the re-issued cookie (#1927)', async () => {
|
||||
resetRateLimits(app); // earlier login cases share the same per-ip 'login' bucket
|
||||
const seeded = createUser(db as never, { username: 'pw-remember', email: 'pw-remember@example.test' });
|
||||
const login = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: seeded.user.email, password: seeded.password, remember_me: true });
|
||||
expect(login.status).toBe(200);
|
||||
const loginCookie = ((login.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session=')))!;
|
||||
const sessionValue = /trek_session=([^;]+)/.exec(loginCookie)![1];
|
||||
|
||||
const change = await request(server)
|
||||
.put('/api/auth/me/password')
|
||||
.set('Cookie', `trek_session=${sessionValue}`)
|
||||
.send({ current_password: seeded.password, new_password: 'New1234!x' });
|
||||
expect(change.status).toBe(200);
|
||||
// The handler's re-issue is appended after any interceptor renewal — the
|
||||
// browser keeps the last trek_session cookie, so assert on that one.
|
||||
const setCookie = (change.headers['set-cookie'] ?? []) as unknown as string[];
|
||||
const finalCookie = setCookie.filter((c) => c.startsWith('trek_session=')).pop()!;
|
||||
expect(finalCookie).toMatch(/Max-Age=2592000/i);
|
||||
const jwt = require('jsonwebtoken');
|
||||
const decoded = jwt.decode(/trek_session=([^;]+)/.exec(finalCookie)![1]) as { remember?: boolean; exp: number; iat: number };
|
||||
expect(decoded.remember).toBe(true);
|
||||
expect(decoded.exp - decoded.iat).toBe(2592000);
|
||||
}, 10000);
|
||||
|
||||
it('POST /register creates the user, sets the cookie and audits user.register', async () => {
|
||||
const before = auditRows('user.register');
|
||||
const res = await request(server)
|
||||
|
||||
@@ -265,7 +265,20 @@ describe('AuthController (authenticated)', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.changePassword(user, anyBody(), req, res)).toEqual({ success: true });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req);
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req, undefined);
|
||||
});
|
||||
|
||||
it('change-password carries the session cookie remember claim into the service and the re-issued cookie (#1927)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const jwt = require('jsonwebtoken');
|
||||
const remembered = jwt.sign({ id: 1, pv: 0, remember: true }, 'any-secret');
|
||||
const reqCookie = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: remembered } } as unknown as Request;
|
||||
const setAuthCookie = vi.fn();
|
||||
const changePassword = vi.fn().mockReturnValue({ token: 'tk3' });
|
||||
const c = ac(asvc({ changePassword, setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.changePassword(user, anyBody(), reqCookie, res)).toEqual({ success: true });
|
||||
expect(changePassword).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), true);
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk3', reqCookie, true);
|
||||
});
|
||||
|
||||
it('delete-account maps error, else audits and succeeds', () => {
|
||||
|
||||
Reference in New Issue
Block a user