feat(core): Update hashing strategy for JWTs (#8810)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-03-05 15:06:29 +01:00 committed by GitHub
parent e38e96bbec
commit cdec7c9334
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 38 additions and 31 deletions

View file

@ -1,7 +1,7 @@
import { Service } from 'typedi';
import type { NextFunction, Response } from 'express';
import { createHash } from 'crypto';
import { JsonWebTokenError, TokenExpiredError, type JwtPayload } from 'jsonwebtoken';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import config from '@/config';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
@ -18,16 +18,19 @@ import { UrlService } from '@/services/url.service';
interface AuthJwtPayload {
/** User Id */
id: string;
/** User's email */
email: string | null;
/** SHA-256 hash of bcrypt hash of the user's password */
password: string | null;
/** This hash is derived from email and bcrypt of password */
hash: string;
}
interface IssuedJWT extends AuthJwtPayload {
exp: number;
}
interface PasswordResetToken {
sub: string;
hash: string;
}
@Service()
export class AuthService {
constructor(
@ -83,11 +86,9 @@ export class AuthService {
}
issueJWT(user: User) {
const { id, email, password } = user;
const payload: AuthJwtPayload = {
id,
email,
password: password ? this.createPasswordSha(user) : null,
id: user.id,
hash: this.createJWTHash(user),
};
return this.jwtService.sign(payload, {
expiresIn: this.jwtExpiration,
@ -104,18 +105,13 @@ export class AuthService {
where: { id: jwtPayload.id },
});
// TODO: include these checks in the cache, to avoid computed this over and over again
const passwordHash = user?.password ? this.createPasswordSha(user) : null;
if (
// If not user is found
!user ||
// or, If the user has been deactivated (i.e. LDAP users)
user.disabled ||
// or, If the password has been updated
jwtPayload.password !== passwordHash ||
// or, If the email has been updated
user.email !== jwtPayload.email
// or, If the email or password has been updated
jwtPayload.hash !== this.createJWTHash(user)
) {
throw new AuthError('Unauthorized');
}
@ -129,10 +125,8 @@ export class AuthService {
}
generatePasswordResetToken(user: User, expiresIn = '20m') {
return this.jwtService.sign(
{ sub: user.id, passwordSha: this.createPasswordSha(user) },
{ expiresIn },
);
const payload: PasswordResetToken = { sub: user.id, hash: this.createJWTHash(user) };
return this.jwtService.sign(payload, { expiresIn });
}
generatePasswordResetUrl(user: User) {
@ -146,7 +140,7 @@ export class AuthService {
}
async resolvePasswordResetToken(token: string): Promise<User | undefined> {
let decodedToken: JwtPayload & { passwordSha: string };
let decodedToken: PasswordResetToken;
try {
decodedToken = this.jwtService.verify(token);
} catch (e) {
@ -171,7 +165,7 @@ export class AuthService {
return;
}
if (this.createPasswordSha(user) !== decodedToken.passwordSha) {
if (decodedToken.hash !== this.createJWTHash(user)) {
this.logger.debug('Password updated since this token was generated');
return;
}
@ -179,10 +173,11 @@ export class AuthService {
return user;
}
private createPasswordSha({ password }: User) {
return createHash('sha256')
.update(password.slice(password.length / 2))
.digest('hex');
createJWTHash({ email, password }: User) {
const hash = createHash('sha256')
.update(email + ':' + password)
.digest('base64');
return hash.substring(0, 10);
}
/** How many **milliseconds** before expiration should a JWT be renewed */

View file

@ -22,7 +22,7 @@ describe('AuthService', () => {
mfaEnabled: false,
};
const validToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInBhc3N3b3JkIjoiMzE1MTNjNWE5ZTNjNWFmZTVjMDZkNTY3NWFjZTc0ZThiYzNmYWRkOTc0NGFiNWQ4OWMzMTFmMmE2MmNjYmQzOSIsImlhdCI6MTcwNjc1MDYyNSwiZXhwIjoxNzA3MzU1NDI1fQ.mtXKUwQDHOhiHn0YNuCeybmxevtNG6LXTAv_sQL63Zc';
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjV9.JwY3doH0YrxHdX4nTOlTN4-QMaXsAu5OFOaFcIHSHBI';
const user = mock<User>(userData);
const jwtService = new JwtService(mock());
@ -39,6 +39,20 @@ describe('AuthService', () => {
config.set('userManagement.jwtRefreshTimeoutHours', 0);
});
describe('createJWTHash', () => {
it('should generate unique hashes', () => {
expect(authService.createJWTHash(user)).toEqual('mJAYx4Wb7k');
expect(
authService.createJWTHash(mock<User>({ email: user.email, password: 'newPasswordHash' })),
).toEqual('FVALtU7AE0');
expect(
authService.createJWTHash(
mock<User>({ email: 'test1@example.com', password: user.password }),
),
).toEqual('y8ha6X01jd');
});
});
describe('authMiddleware', () => {
const req = mock<AuthenticatedRequest>({ cookies: {}, user: undefined });
const res = mock<Response>();
@ -198,7 +212,7 @@ describe('AuthService', () => {
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.instance');
const url = authService.generatePasswordResetUrl(user);
expect(url).toEqual(
'https://n8n.instance/change-password?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJwYXNzd29yZFNoYSI6IjMxNTEzYzVhOWUzYzVhZmU1YzA2ZDU2NzVhY2U3NGU4YmMzZmFkZDk3NDRhYjVkODljMzExZjJhNjJjY2JkMzkiLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNjc1MTgyNX0.wsdEpbK2zhFucaPwga7f8EOcwiJcv0iW23HcnvJs-s8&mfaEnabled=false',
'https://n8n.instance/change-password?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJoYXNoIjoibUpBWXg0V2I3ayIsImlhdCI6MTcwNjc1MDYyNSwiZXhwIjoxNzA2NzUxODI1fQ.rg90I7MKjc_KC77mov59XYAeRc-CoW9ka4mt1dCfrnk&mfaEnabled=false',
);
});
});
@ -214,9 +228,7 @@ describe('AuthService', () => {
expect(decoded.sub).toEqual(user.id);
expect(decoded.exp - decoded.iat).toEqual(1200); // Expires in 20 minutes
expect(decoded.passwordSha).toEqual(
'31513c5a9e3c5afe5c06d5675ace74e8bc3fadd9744ab5d89c311f2a62ccbd39',
);
expect(decoded.hash).toEqual('mJAYx4Wb7k');
});
});