| import { Logger } from '@n8n/backend-common'; |
| import { GlobalConfig } from '@n8n/config'; |
| import type { User } from '@n8n/db'; |
| import { InvalidAuthTokenRepository, UserRepository } from '@n8n/db'; |
| import { Service } from '@n8n/di'; |
| import { createHash } from 'crypto'; |
| import type { NextFunction, Response } from 'express'; |
| import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; |
| import type { StringValue as TimeUnitValue } from 'ms'; |
|
|
| import config from '@/config'; |
| import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants'; |
| import { AuthError } from '@/errors/response-errors/auth.error'; |
| import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; |
| import { License } from '@/license'; |
| import type { AuthenticatedRequest } from '@/requests'; |
| import { JwtService } from '@/services/jwt.service'; |
| import { UrlService } from '@/services/url.service'; |
|
|
| interface AuthJwtPayload { |
| |
| id: string; |
| |
| hash: string; |
| |
| browserId?: string; |
| } |
|
|
| interface IssuedJWT extends AuthJwtPayload { |
| exp: number; |
| } |
|
|
| interface PasswordResetToken { |
| sub: string; |
| hash: string; |
| } |
|
|
| @Service() |
| export class AuthService { |
| |
| private skipBrowserIdCheckEndpoints: string[]; |
|
|
| constructor( |
| private readonly globalConfig: GlobalConfig, |
| private readonly logger: Logger, |
| private readonly license: License, |
| private readonly jwtService: JwtService, |
| private readonly urlService: UrlService, |
| private readonly userRepository: UserRepository, |
| private readonly invalidAuthTokenRepository: InvalidAuthTokenRepository, |
| ) { |
| |
| this.authMiddleware = this.authMiddleware.bind(this); |
|
|
| const restEndpoint = globalConfig.endpoints.rest; |
| this.skipBrowserIdCheckEndpoints = [ |
| |
| |
| `/${restEndpoint}/push`, |
|
|
| |
| `/${restEndpoint}/binary-data/`, |
|
|
| |
| `/${restEndpoint}/oauth1-credential/callback`, |
| `/${restEndpoint}/oauth2-credential/callback`, |
| ]; |
| } |
|
|
| async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) { |
| const token = req.cookies[AUTH_COOKIE_NAME]; |
| if (token) { |
| try { |
| const isInvalid = await this.invalidAuthTokenRepository.existsBy({ token }); |
| if (isInvalid) throw new AuthError('Unauthorized'); |
| req.user = await this.resolveJwt(token, req, res); |
| } catch (error) { |
| if (error instanceof JsonWebTokenError || error instanceof AuthError) { |
| this.clearCookie(res); |
| } else { |
| throw error; |
| } |
| } |
| } |
|
|
| if (req.user) next(); |
| else res.status(401).json({ status: 'error', message: 'Unauthorized' }); |
| } |
|
|
| clearCookie(res: Response) { |
| res.clearCookie(AUTH_COOKIE_NAME); |
| } |
|
|
| async invalidateToken(req: AuthenticatedRequest) { |
| const token = req.cookies[AUTH_COOKIE_NAME]; |
| if (!token) return; |
| try { |
| const { exp } = this.jwtService.decode(token); |
| if (exp) { |
| await this.invalidAuthTokenRepository.insert({ |
| token, |
| expiresAt: new Date(exp * 1000), |
| }); |
| } |
| } catch (e) { |
| this.logger.warn('failed to invalidate auth token', { error: (e as Error).message }); |
| } |
| } |
|
|
| issueCookie(res: Response, user: User, browserId?: string) { |
| |
| |
| const isWithinUsersLimit = this.license.isWithinUsersLimit(); |
| if ( |
| config.getEnv('userManagement.isInstanceOwnerSetUp') && |
| user.role !== 'global:owner' && |
| !isWithinUsersLimit |
| ) { |
| throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); |
| } |
|
|
| const token = this.issueJWT(user, browserId); |
| const { samesite, secure } = this.globalConfig.auth.cookie; |
| res.cookie(AUTH_COOKIE_NAME, token, { |
| maxAge: this.jwtExpiration * Time.seconds.toMilliseconds, |
| httpOnly: true, |
| sameSite: samesite, |
| secure, |
| }); |
| } |
|
|
| issueJWT(user: User, browserId?: string) { |
| const payload: AuthJwtPayload = { |
| id: user.id, |
| hash: this.createJWTHash(user), |
| browserId: browserId && this.hash(browserId), |
| }; |
| return this.jwtService.sign(payload, { |
| expiresIn: this.jwtExpiration, |
| }); |
| } |
|
|
| async resolveJwt(token: string, req: AuthenticatedRequest, res: Response): Promise<User> { |
| const jwtPayload: IssuedJWT = this.jwtService.verify(token, { |
| algorithms: ['HS256'], |
| }); |
|
|
| |
| const user = await this.userRepository.findOne({ |
| where: { id: jwtPayload.id }, |
| }); |
|
|
| if ( |
| |
| !user || |
| |
| user.disabled || |
| |
| jwtPayload.hash !== this.createJWTHash(user) |
| ) { |
| throw new AuthError('Unauthorized'); |
| } |
|
|
| |
| const endpoint = req.route ? `${req.baseUrl}${req.route.path}` : req.baseUrl; |
| if (req.method === 'GET' && this.skipBrowserIdCheckEndpoints.includes(endpoint)) { |
| this.logger.debug(`Skipped browserId check on ${endpoint}`); |
| } else if ( |
| jwtPayload.browserId && |
| (!req.browserId || jwtPayload.browserId !== this.hash(req.browserId)) |
| ) { |
| this.logger.warn(`browserId check failed on ${endpoint}`); |
| throw new AuthError('Unauthorized'); |
| } |
|
|
| if (jwtPayload.exp * 1000 - Date.now() < this.jwtRefreshTimeout) { |
| this.logger.debug('JWT about to expire. Will be refreshed'); |
| this.issueCookie(res, user, req.browserId); |
| } |
|
|
| return user; |
| } |
|
|
| generatePasswordResetToken(user: User, expiresIn: TimeUnitValue = '20m') { |
| const payload: PasswordResetToken = { sub: user.id, hash: this.createJWTHash(user) }; |
| return this.jwtService.sign(payload, { expiresIn }); |
| } |
|
|
| generatePasswordResetUrl(user: User) { |
| const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); |
| const url = new URL(`${instanceBaseUrl}/change-password`); |
|
|
| url.searchParams.append('token', this.generatePasswordResetToken(user)); |
| url.searchParams.append('mfaEnabled', user.mfaEnabled.toString()); |
|
|
| return url.toString(); |
| } |
|
|
| async resolvePasswordResetToken(token: string): Promise<User | undefined> { |
| let decodedToken: PasswordResetToken; |
| try { |
| decodedToken = this.jwtService.verify(token); |
| } catch (e) { |
| if (e instanceof TokenExpiredError) { |
| this.logger.debug('Reset password token expired', { token }); |
| } else { |
| this.logger.debug('Error verifying token', { token }); |
| } |
| return; |
| } |
|
|
| const user = await this.userRepository.findOne({ |
| where: { id: decodedToken.sub }, |
| relations: ['authIdentities'], |
| }); |
|
|
| if (!user) { |
| this.logger.debug( |
| 'Request to resolve password token failed because no user was found for the provided user ID', |
| { userId: decodedToken.sub, token }, |
| ); |
| return; |
| } |
|
|
| if (decodedToken.hash !== this.createJWTHash(user)) { |
| this.logger.debug('Password updated since this token was generated'); |
| return; |
| } |
|
|
| return user; |
| } |
|
|
| createJWTHash({ email, password, mfaEnabled, mfaSecret }: User) { |
| const payload = [email, password]; |
| if (mfaEnabled && mfaSecret) { |
| payload.push(mfaSecret.substring(0, 3)); |
| } |
| return this.hash(payload.join(':')).substring(0, 10); |
| } |
|
|
| private hash(input: string) { |
| return createHash('sha256').update(input).digest('base64'); |
| } |
|
|
| |
| get jwtRefreshTimeout() { |
| const { jwtRefreshTimeoutHours, jwtSessionDurationHours } = config.get('userManagement'); |
| if (jwtRefreshTimeoutHours === 0) { |
| return Math.floor(jwtSessionDurationHours * 0.25 * Time.hours.toMilliseconds); |
| } else { |
| return Math.floor(jwtRefreshTimeoutHours * Time.hours.toMilliseconds); |
| } |
| } |
|
|
| |
| get jwtExpiration() { |
| return config.get('userManagement.jwtSessionDurationHours') * Time.hours.toSeconds; |
| } |
| } |
|
|