Sheldon Guo cab54e9ebb
fix(auth): update client auth middleware logic to prevent token update failure due to concurrency (#6135)
* feat(auth): add logging for token renewal process and new error code for stream requests

* feat(auth): session expiration check everytime

* feat(auth): validate token validity period against session validity period

* fix(auth): correct wording for session validity period in localization file

* fix(auth): update redirect logic to handle specific auth error codes

* fix(auth): correct error response structure for token renewal and stream requests

* fix(auth): add token expiration check to update token status

* fix(notification): add skipAuth option to SSE stream request

* fix(auth): simplify redirect logic in auth check middleware

* fix(auth): update logging to include request headers and enhance error handling in auth middleware
2025-02-06 15:31:18 +08:00

258 lines
7.3 KiB
TypeScript

/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Collection, Model } from '@nocobase/database';
import { Cache } from '@nocobase/cache';
import jwt from 'jsonwebtoken';
import { Auth, AuthConfig, AuthErrorCode, AuthError } from '../auth';
import { JwtService } from './jwt-service';
import { ITokenControlService } from './token-control-service';
const localeNamespace = 'auth';
/**
* BaseAuth
* @description A base class with jwt provide some common methods.
*/
export class BaseAuth extends Auth {
protected userCollection: Collection;
constructor(
config: AuthConfig & {
userCollection: Collection;
},
) {
const { userCollection } = config;
super(config);
this.userCollection = userCollection;
}
get userRepository() {
return this.userCollection.repository;
}
/**
* @internal
*/
get jwt(): JwtService {
return this.ctx.app.authManager.jwt;
}
get tokenController(): ITokenControlService {
return this.ctx.app.authManager.tokenController;
}
set user(user: Model) {
this.ctx.state.currentUser = user;
}
get user() {
return this.ctx.state.currentUser;
}
/**
* @internal
*/
getCacheKey(userId: number) {
return `auth:${userId}`;
}
/**
* @internal
*/
validateUsername(username: string) {
return /^[^@.<>"'/]{1,50}$/.test(username);
}
async check(): ReturnType<Auth['check']> {
const token = this.ctx.getBearerToken();
if (!token) {
this.ctx.throw(401, {
message: this.ctx.t('Unauthenticated. Please sign in to continue.', { ns: localeNamespace }),
code: AuthErrorCode.EMPTY_TOKEN,
});
}
let tokenStatus: 'valid' | 'expired' | 'invalid';
let payload;
try {
payload = await this.jwt.decode(token);
tokenStatus = 'valid';
} catch (err) {
if (err.name === 'TokenExpiredError') {
tokenStatus = 'expired';
payload = jwt.decode(token);
} else {
this.ctx.logger.error(err, { method: 'jwt.decode' });
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.INVALID_TOKEN,
});
}
}
const { userId, roleName, iat, temp, jti, exp, signInTime } = payload ?? {};
const tokenPolicy = await this.tokenController.getConfig();
if (!signInTime || Date.now() - signInTime > tokenPolicy.sessionExpirationTime) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.EXPIRED_SESSION,
});
}
if (tokenStatus === 'valid' && Date.now() - iat * 1000 > tokenPolicy.tokenExpirationTime) {
tokenStatus = 'expired';
}
const blocked = await this.jwt.blacklist.has(jti ?? token);
if (blocked) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.BLOCKED_TOKEN,
});
}
if (roleName) {
this.ctx.headers['x-role'] = roleName;
}
const cache = this.ctx.cache as Cache;
const user = await cache.wrap(this.getCacheKey(userId), () =>
this.userRepository.findOne({
filter: {
id: userId,
},
raw: true,
}),
);
if (!temp && tokenStatus !== 'valid') {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.INVALID_TOKEN,
});
}
if (tokenStatus === 'valid' && user.passwordChangeTz && iat * 1000 < user.passwordChangeTz) {
this.ctx.throw(401, {
message: this.ctx.t('User password changed, please signin again.', { ns: localeNamespace }),
code: AuthErrorCode.INVALID_TOKEN,
});
}
if (tokenStatus === 'expired') {
if (tokenPolicy.expiredTokenRenewLimit > 0 && Date.now() - exp * 1000 > tokenPolicy.expiredTokenRenewLimit) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.EXPIRED_SESSION,
});
}
try {
this.ctx.logger.info('token renewing', {
method: 'auth.check',
url: this.ctx.originalUrl,
headers: JSON.stringify(this.ctx?.req?.headers),
});
const isStreamRequest = this.ctx.req.headers['accept'] === 'text/event-stream';
if (isStreamRequest) {
this.ctx.throw(401, {
message: 'Stream api not allow renew token.',
code: AuthErrorCode.SKIP_TOKEN_RENEW,
});
}
const renewedResult = await this.tokenController.renew(jti);
this.ctx.logger.info('token renewed', {
method: 'auth.check',
url: this.ctx.originalUrl,
headers: JSON.stringify(this.ctx?.req?.headers),
});
const expiresIn = Math.floor(tokenPolicy.tokenExpirationTime / 1000);
const newToken = this.jwt.sign({ userId, roleName, temp, signInTime }, { jwtid: renewedResult.jti, expiresIn });
this.ctx.res.setHeader('x-new-token', newToken);
return user;
} catch (err) {
this.ctx.logger.info('token renew failed', {
method: 'auth.check',
url: this.ctx.originalUrl,
err,
headers: JSON.stringify(this.ctx?.req?.headers),
});
const options =
err instanceof AuthError
? { code: err.code, message: err.message }
: { message: err.message, code: err.code ?? AuthErrorCode.INVALID_TOKEN };
this.ctx.throw(401, {
message: this.ctx.t(options.message, { ns: localeNamespace }),
code: options.code,
});
}
}
return user;
}
async validate(): Promise<Model> {
return null;
}
async signIn() {
let user: Model;
try {
user = await this.validate();
} catch (err) {
this.ctx.throw(err.status || 401, err.message, {
...err,
});
}
if (!user) {
this.ctx.throw(401, {
message: this.ctx.t('User not found. Please sign in again to continue.', { ns: localeNamespace }),
code: AuthErrorCode.NOT_EXIST_USER,
});
}
const tokenInfo = await this.tokenController.add({ userId: user.id });
const expiresIn = Math.floor((await this.tokenController.getConfig()).tokenExpirationTime / 1000);
const token = this.jwt.sign(
{
userId: user.id,
temp: true,
iat: Math.floor(tokenInfo.issuedTime / 1000),
signInTime: tokenInfo.signInTime,
},
{
jwtid: tokenInfo.jti,
expiresIn,
},
);
return {
user,
token,
};
}
async signOut(): Promise<any> {
const token = this.ctx.getBearerToken();
if (!token) {
return;
}
const { userId } = await this.jwt.decode(token);
await this.ctx.app.emitAsync('cache:del:roles', { userId });
await this.ctx.cache.del(this.getCacheKey(userId));
return await this.jwt.block(token);
}
}