Skip to content
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

[PB-267]: feat/add last passwordChangedAt middleware #256

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions src/lib/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,11 @@ export function verifyToken(token: string, secret: string) {
export function verifyWithDefaultSecret(token: string) {
return verify(token, getEnv().secrets.jwt);
}

export function getTokenDefaultIat() {
return Math.floor(Date.now() / 1000);
}

export function isTokenIatGreaterThanDate(date: Date, iat: number) {
return Math.floor(date.getTime() / 1000) < iat;
}
117 changes: 117 additions & 0 deletions src/modules/auth/jwt.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Test, TestingModule } from '@nestjs/testing';
import { createMock } from '@golevelup/ts-jest';
import { UserUseCases } from '../user/user.usecase';
import { JwtStrategy } from './jwt.strategy';
import { newUser } from '../../../test/fixtures';
import { ConfigService } from '@nestjs/config';
import { UnauthorizedException } from '@nestjs/common';
import { getTokenDefaultIat } from '../../lib/jwt';

describe('Jwt strategy', () => {
let userUseCases: UserUseCases;
let strategy: JwtStrategy;

beforeEach(async () => {
const moduleRef = await createTestingModule();
userUseCases = moduleRef.get<UserUseCases>(UserUseCases);
strategy = moduleRef.get<JwtStrategy>(JwtStrategy);
});

it('When token is old, then fail', async () => {
await expect(strategy.validate({ email: '[email protected]' })).rejects.toThrow(
new UnauthorizedException('Old token version detected'),
);
});

it('When user does not exist, then fail', async () => {
jest.spyOn(userUseCases, 'getUser').mockResolvedValue(null);

await expect(
strategy.validate({ payload: { uuid: 'anyUuid' } }),
).rejects.toThrow(UnauthorizedException);
});

it('When token iat is older than lastPasswordChangedAt , then fail', async () => {
const user = newUser();
const greaterDate = new Date();
greaterDate.setMinutes(greaterDate.getMinutes() + 1);
user.lastPasswordChangedAt = greaterDate;
const tokenIat = getTokenDefaultIat();

jest.spyOn(userUseCases, 'getUser').mockResolvedValue(user);

await expect(
strategy.validate({ payload: { uuid: 'anyUuid' }, iat: tokenIat }),
).rejects.toThrow(UnauthorizedException);
});

it('When user has lastPasswordChangedAt older than token iat, then return user', async () => {
const tokenIat = getTokenDefaultIat();
const user = newUser();
const olderDate = new Date();
olderDate.setMinutes(olderDate.getMinutes() - 1);
user.lastPasswordChangedAt = olderDate;

jest.spyOn(userUseCases, 'getUser').mockResolvedValue(user);

await expect(
strategy.validate({ payload: { uuid: 'anyUuid' }, iat: tokenIat }),
).resolves.toBe(user);
});

it('When token has iat but user has not lastPasswordChangedAt, then return user', async () => {
const tokenIat = getTokenDefaultIat();
const user = newUser();
user.lastPasswordChangedAt = null;

jest.spyOn(userUseCases, 'getUser').mockResolvedValue(user);

await expect(
strategy.validate({ payload: { uuid: 'anyUuid' }, iat: tokenIat }),
).resolves.toBe(user);
});

it('When user is guest on shared workspace, then return owner', async () => {
const guestUser = newUser();
const owner = newUser();
const anyUuid = 'testUuid';
guestUser.bridgeUser = owner.username;
const olderDate = new Date();
olderDate.setMinutes(olderDate.getMinutes() - 1);
guestUser.lastPasswordChangedAt = olderDate;

const tokenIat = getTokenDefaultIat();

const getUserSpy = jest
.spyOn(userUseCases, 'getUser')
.mockResolvedValue(guestUser);

const getUserByUsernameSpy = jest
.spyOn(userUseCases, 'getUserByUsername')
.mockResolvedValue(owner);

await expect(
strategy.validate({ payload: { uuid: anyUuid }, iat: tokenIat }),
).resolves.toBe(owner);

expect(getUserSpy).toHaveBeenCalledWith(anyUuid);
expect(getUserByUsernameSpy).toHaveBeenCalledWith(owner.username);
});
});

const createTestingModule = (): Promise<TestingModule> => {
return Test.createTestingModule({
controllers: [],
providers: [
{
provide: UserUseCases,
useValue: createMock<UserUseCases>(),
},
{
provide: ConfigService,
useValue: createMock<ConfigService>(),
},
JwtStrategy,
],
}).compile();
};
18 changes: 18 additions & 0 deletions src/modules/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { User } from '../user/user.domain';
import { UserUseCases } from '../user/user.usecase';
import { isTokenIatGreaterThanDate } from '../../lib/jwt';

export interface JwtPayload {
email: string;
Expand Down Expand Up @@ -42,6 +43,23 @@ export class JwtStrategy extends PassportStrategy(Strategy, strategyId) {
throw new UnauthorizedException();
}

const userWithoutLastPasswordChangedAt =
user.lastPasswordChangedAt === null;

const tokenOlderThanLastPasswordChangedAt =
user.lastPasswordChangedAt &&
!isTokenIatGreaterThanDate(
new Date(user.lastPasswordChangedAt),
payload.iat,
);

if (
!userWithoutLastPasswordChangedAt &&
tokenOlderThanLastPasswordChangedAt
) {
throw new UnauthorizedException();
}

if (user.isGuestOnSharedWorkspace()) {
return this.userUseCases.getUserByUsername(user.bridgeUser);
}
Expand Down
2 changes: 2 additions & 0 deletions src/modules/user/user.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { AttemptChangeEmailHasExpiredException } from './exception/attempt-chang
import { AttemptChangeEmailNotFoundException } from './exception/attempt-change-email-not-found.exception';
import { UserEmailAlreadyInUseException } from './exception/user-email-already-in-use.exception';
import { UserNotFoundException } from './exception/user-not-found.exception';
import { getTokenDefaultIat } from '../../lib/jwt';

class ReferralsNotAvailableError extends Error {
constructor() {
Expand Down Expand Up @@ -533,6 +534,7 @@ export class UserUseCases {
pass: userData.userId,
},
},
iat: getTokenDefaultIat(),
};
}

Expand Down
Loading