feat(core): Prevent session hijacking (#9057)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2024-04-09 11:20:35 +02:00
committed by GitHub
parent 5793e5644a
commit 28261047c3
15 changed files with 124 additions and 53 deletions

View File

@@ -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 */