mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-21 20:00:02 +00:00
feat(core): Prevent session hijacking (#9057)
This commit is contained in:
committed by
GitHub
parent
5793e5644a
commit
28261047c3
@@ -33,7 +33,7 @@ import {
|
||||
TEMPLATES_DIR,
|
||||
} from '@/constants';
|
||||
import { CredentialsController } from '@/credentials/credentials.controller';
|
||||
import type { CurlHelper } from '@/requests';
|
||||
import type { APIRequest, CurlHelper } from '@/requests';
|
||||
import { registerController } from '@/decorators';
|
||||
import { AuthController } from '@/controllers/auth.controller';
|
||||
import { BinaryDataController } from '@/controllers/binaryData.controller';
|
||||
@@ -235,6 +235,13 @@ export class Server extends AbstractServer {
|
||||
frontendService.settings.publicApi.latestVersion = apiLatestVersion;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract BrowserId from headers
|
||||
this.app.use((req: APIRequest, _, next) => {
|
||||
req.browserId = req.headers['browser-id'] as string;
|
||||
next();
|
||||
});
|
||||
|
||||
// Parse cookies for easier access
|
||||
this.app.use(cookieParser());
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ interface AuthJwtPayload {
|
||||
id: string;
|
||||
/** This hash is derived from email and bcrypt of password */
|
||||
hash: string;
|
||||
/** This is a client generated unique string to prevent session hijacking */
|
||||
browserId?: string;
|
||||
}
|
||||
|
||||
interface IssuedJWT extends AuthJwtPayload {
|
||||
@@ -31,6 +33,8 @@ interface PasswordResetToken {
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const pushEndpoint = `/${config.get('endpoints.rest')}/push`;
|
||||
|
||||
@Service()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@@ -48,7 +52,7 @@ export class AuthService {
|
||||
const token = req.cookies[AUTH_COOKIE_NAME];
|
||||
if (token) {
|
||||
try {
|
||||
req.user = await this.resolveJwt(token, res);
|
||||
req.user = await this.resolveJwt(token, req, res);
|
||||
} catch (error) {
|
||||
if (error instanceof JsonWebTokenError || error instanceof AuthError) {
|
||||
this.clearCookie(res);
|
||||
@@ -66,7 +70,8 @@ export class AuthService {
|
||||
res.clearCookie(AUTH_COOKIE_NAME);
|
||||
}
|
||||
|
||||
issueCookie(res: Response, user: User) {
|
||||
issueCookie(res: Response, user: User, browserId?: string) {
|
||||
// TODO: move this check to the login endpoint in AuthController
|
||||
// If the instance has exceeded its user quota, prevent non-owners from logging in
|
||||
const isWithinUsersLimit = this.license.isWithinUsersLimit();
|
||||
if (
|
||||
@@ -77,7 +82,7 @@ export class AuthService {
|
||||
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
}
|
||||
|
||||
const token = this.issueJWT(user);
|
||||
const token = this.issueJWT(user, browserId);
|
||||
res.cookie(AUTH_COOKIE_NAME, token, {
|
||||
maxAge: this.jwtExpiration * Time.seconds.toMilliseconds,
|
||||
httpOnly: true,
|
||||
@@ -86,17 +91,18 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
issueJWT(user: User) {
|
||||
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, res: Response): Promise<User> {
|
||||
async resolveJwt(token: string, req: AuthenticatedRequest, res: Response): Promise<User> {
|
||||
const jwtPayload: IssuedJWT = this.jwtService.verify(token, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
@@ -112,14 +118,20 @@ export class AuthService {
|
||||
// or, If the user has been deactivated (i.e. LDAP users)
|
||||
user.disabled ||
|
||||
// or, If the email or password has been updated
|
||||
jwtPayload.hash !== this.createJWTHash(user)
|
||||
jwtPayload.hash !== this.createJWTHash(user) ||
|
||||
// If the token was issued for another browser session
|
||||
// NOTE: we need to exclude push endpoint from this check because we can't send custom header on websocket requests
|
||||
// TODO: Implement a custom handshake for push, to avoid having to send any data on querystring or headers
|
||||
(req.baseUrl !== pushEndpoint &&
|
||||
jwtPayload.browserId &&
|
||||
(!req.browserId || jwtPayload.browserId !== this.hash(req.browserId)))
|
||||
) {
|
||||
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);
|
||||
this.issueCookie(res, user, jwtPayload.browserId);
|
||||
}
|
||||
|
||||
return user;
|
||||
@@ -175,10 +187,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
createJWTHash({ email, password }: User) {
|
||||
const hash = createHash('sha256')
|
||||
.update(email + ':' + password)
|
||||
.digest('base64');
|
||||
return hash.substring(0, 10);
|
||||
return this.hash(email + ':' + password).substring(0, 10);
|
||||
}
|
||||
|
||||
private hash(input: string) {
|
||||
return createHash('sha256').update(input).digest('base64');
|
||||
}
|
||||
|
||||
/** How many **milliseconds** before expiration should a JWT be renewed */
|
||||
|
||||
@@ -94,7 +94,7 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
this.authService.issueCookie(res, user);
|
||||
this.authService.issueCookie(res, user, req.browserId);
|
||||
void this.internalHooks.onUserLoginSuccess({
|
||||
user,
|
||||
authenticationMethod: usedAuthenticationMethod,
|
||||
|
||||
@@ -164,7 +164,7 @@ export class InvitationController {
|
||||
|
||||
const updatedUser = await this.userRepository.save(invitee, { transaction: false });
|
||||
|
||||
this.authService.issueCookie(res, updatedUser);
|
||||
this.authService.issueCookie(res, updatedUser, req.browserId);
|
||||
|
||||
void this.internalHooks.onUserSignup(updatedUser, {
|
||||
user_type: 'email',
|
||||
|
||||
@@ -85,7 +85,7 @@ export class MeController {
|
||||
|
||||
this.logger.info('User updated successfully', { userId });
|
||||
|
||||
this.authService.issueCookie(res, user);
|
||||
this.authService.issueCookie(res, user, req.browserId);
|
||||
|
||||
const updatedKeys = Object.keys(payload);
|
||||
void this.internalHooks.onUserUpdate({
|
||||
@@ -138,7 +138,7 @@ export class MeController {
|
||||
const updatedUser = await this.userRepository.save(user, { transaction: false });
|
||||
this.logger.info('Password updated successfully', { userId: user.id });
|
||||
|
||||
this.authService.issueCookie(res, updatedUser);
|
||||
this.authService.issueCookie(res, updatedUser, req.browserId);
|
||||
|
||||
void this.internalHooks.onUserUpdate({
|
||||
user: updatedUser,
|
||||
|
||||
@@ -83,7 +83,7 @@ export class OwnerController {
|
||||
|
||||
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully');
|
||||
|
||||
this.authService.issueCookie(res, owner);
|
||||
this.authService.issueCookie(res, owner, req.browserId);
|
||||
|
||||
void this.internalHooks.onInstanceOwnerSetup({ user_id: owner.id });
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ export class PasswordResetController {
|
||||
|
||||
this.logger.info('User password updated successfully', { userId: user.id });
|
||||
|
||||
this.authService.issueCookie(res, user);
|
||||
this.authService.issueCookie(res, user, req.browserId);
|
||||
|
||||
void this.internalHooks.onUserUpdate({
|
||||
user,
|
||||
|
||||
@@ -8,7 +8,7 @@ export const corsMiddleware: RequestHandler = (req, res, next) => {
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, push-ref',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, push-ref, browser-id',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,22 +50,30 @@ export class UserRoleChangePayload {
|
||||
newRoleName: AssignableRole;
|
||||
}
|
||||
|
||||
export type APIRequest<
|
||||
RouteParams = {},
|
||||
ResponseBody = {},
|
||||
RequestBody = {},
|
||||
RequestQuery = {},
|
||||
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
|
||||
browserId?: string;
|
||||
};
|
||||
|
||||
export type AuthlessRequest<
|
||||
RouteParams = {},
|
||||
ResponseBody = {},
|
||||
RequestBody = {},
|
||||
RequestQuery = {},
|
||||
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery>;
|
||||
> = APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
|
||||
user: never;
|
||||
};
|
||||
|
||||
export type AuthenticatedRequest<
|
||||
RouteParams = {},
|
||||
ResponseBody = {},
|
||||
RequestBody = {},
|
||||
RequestQuery = {},
|
||||
> = Omit<
|
||||
express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery>,
|
||||
'user' | 'cookies'
|
||||
> & {
|
||||
> = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & {
|
||||
user: User;
|
||||
cookies: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
@@ -138,7 +138,7 @@ export class SamlController {
|
||||
});
|
||||
// Only sign in user if SAML is enabled, otherwise treat as test connection
|
||||
if (isSamlLicensedAndEnabled()) {
|
||||
this.authService.issueCookie(res, loginResult.authenticatedUser);
|
||||
this.authService.issueCookie(res, loginResult.authenticatedUser, req.browserId);
|
||||
if (loginResult.onboardingRequired) {
|
||||
return res.redirect(this.urlService.getInstanceBaseUrl() + SamlUrls.samlOnboarding);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user