mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 12:44:07 -08:00
feat(core): Update hashing strategy for JWTs (#8810)
This commit is contained in:
parent
e38e96bbec
commit
cdec7c9334
|
@ -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 */
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue