diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 6e5ddd4be5..ebd3f98031 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -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 { - 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 */ diff --git a/packages/cli/test/unit/auth/auth.service.test.ts b/packages/cli/test/unit/auth/auth.service.test.ts index 3d432ebb64..6084eecfea 100644 --- a/packages/cli/test/unit/auth/auth.service.test.ts +++ b/packages/cli/test/unit/auth/auth.service.test.ts @@ -22,7 +22,7 @@ describe('AuthService', () => { mfaEnabled: false, }; const validToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInBhc3N3b3JkIjoiMzE1MTNjNWE5ZTNjNWFmZTVjMDZkNTY3NWFjZTc0ZThiYzNmYWRkOTc0NGFiNWQ4OWMzMTFmMmE2MmNjYmQzOSIsImlhdCI6MTcwNjc1MDYyNSwiZXhwIjoxNzA3MzU1NDI1fQ.mtXKUwQDHOhiHn0YNuCeybmxevtNG6LXTAv_sQL63Zc'; + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjV9.JwY3doH0YrxHdX4nTOlTN4-QMaXsAu5OFOaFcIHSHBI'; const user = mock(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({ email: user.email, password: 'newPasswordHash' })), + ).toEqual('FVALtU7AE0'); + expect( + authService.createJWTHash( + mock({ email: 'test1@example.com', password: user.password }), + ), + ).toEqual('y8ha6X01jd'); + }); + }); + describe('authMiddleware', () => { const req = mock({ cookies: {}, user: undefined }); const res = mock(); @@ -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'); }); });