I am currently facing a challenge within our LoopBack4 application. We have implemented controllers and are using JWT for Authorization. In the token's payload, we include a list of rights granted to the requesting user. Additionally, we have added an AuthorizationInterceptor to verify permissions.
My mistake was storing the token data in a static variable and accessing it from various services and locations within the application. This led to issues where concurrent requests would overwrite each other's tokens. As a result, Request A ended up utilizing the rights of Request B.
The Issue:
- A client sends Request A to the LB4 application with a token
- The application stores this token in a static variable
- Simultaneously, an incoming Request B provides a different token
- The application replaces the token of Request A with that of Request B
- Request A ends up operating with the rights of Request B
Application Structure:
In every Controller:
export class MiscController
{
constructor(@inject(AServiceBindings.VALUE) public aService: AService) {}
@get('/hasright', {})
@authenticate('jwt', {"required":[1,2,3]}) // authorization checked by AuthorizationInterceptor
async getVersion(): Promise<object>
{
return {hasRight: JWTService.checkRight(4)};
}
}
JWT Service:
export class JWTService implements TokenService
{
static AuthToken: Authtoken|null;
static rights: number[];
// constructor ...
/** Method to check rights */
static hasRight(rightId: number): boolean
{
return inArray(rightId, JWTService.rights);
}
async verifyToken(token: string): Promise<UserProfile>
{
// verify the token ...
// store the Token data in static variables
JWTService.AuthToken = authtoken;
JWTService.rights = rightIds;
return userProfile;
}
}
export const JWTServiceBindings = {
VALUE: BindingKey.create<JWTService>("services.JWTService")
};
AuthorizeInterceptor.ts
@globalInterceptor('', {tags: {name: 'authorize'}})
export class AuthorizationInterceptor implements Provider<Interceptor>
{
constructor(
@inject(AuthenticationBindings.METADATA) public metadata: AuthenticationMetadata,
@inject(TokenServiceBindings.USER_PERMISSIONS) protected checkPermissions: UserPermissionsFn,
@inject.getter(AuthenticationBindings.CURRENT_USER) public getCurrentUser: Getter<MyUserProfile>
) {}
/**
* Interceptor function for the binding.
*
* @returns An interceptor function
*/
value()
{
return this.intercept.bind(this);
}
/**
* Intercepting method
* @param invocationCtx - Invocation context
* @param next - Function to invoke next interceptor or target method
*/
async intercept(invocationCtx: InvocationContext, next: () => ValueOrPromise<InvocationResult>)
{
if(!this.metadata)
{
return next();
}
const requiredPermissions = this.metadata.options as RequiredPermissions;
const user = await this.getCurrentUser();
if(!this.checkPermissions(user.permissions, requiredPermissions))
{
throw new HttpErrors.Forbidden('Permission denied! You do not have the needed right to request this function.');
}
return next();
}
}
JWTAuthenticationStrategy
export class JWTAuthenticationStrategy implements AuthenticationStrategy
{
name = 'jwt';
constructor(@inject(JWTServiceBindings.VALUE) public tokenService: JWTService) {}
async authenticate(request: Request): Promise<UserProfile | undefined>
{
const token: string = this.extractCredentials(request);
return this.tokenService.verifyToken(token);
}
// extract credentials etc ...
}
application.ts
export class MyApplication extends BootMixin(ServiceMixin(RepositoryMixin(RestApplication)))
{
constructor(options: ApplicationConfig = {})
{
super(options);
// Bind authentication related elements
this.component(AuthenticationComponent);
registerAuthenticationStrategy(this, JWTAuthenticationStrategy);
this.bind(JWTServiceBindings.VALUE).toClass(JWTService);
this.bind(TokenServiceBindings.USER_PERMISSIONS).toProvider(UserPermissionsProvider);
this.bind(TokenServiceBindings.TOKEN_SECRET).to(TokenServiceConstants.TOKEN_SECRET_VALUE);
// Set up custom sequence
this.sequence(MySequence);
// additional bindings and setup tasks...
}
}
sequence.ts
export class MySequence implements SequenceHandler
{
// constructor ...
async handle(context: RequestContext)
{
try
{
const {request, response} = context;
const route = this.findRoute(request);
// authenticate request
await this.authenticateRequest(request);
userId = getMyUserId(); // using helper method
// Proceed with invoking controller after successful authentication
const args = await this.parseParams(request, route);
const result = await this.invoke(route, args);
this.send(response, result);
}
catch(err)
{
this.reject(context, err);
}
finally
{
// perform actions using userId ...
}
}
}
helper.ts // simple file with utility functions
export function getMyUserId(): number
{
return ((JWTService.AuthToken && JWTService.AuthToken.UserId) || 0);
}
Additionally, I need a solution to access user data within services and other parts of the application, such as the authorized user's token. Where should I place and how should I manage the token?
Resources consulted: How to use stateful requests in Loopback 4? -> https://github.com/strongloop/loopback-next/issues/1863
I came across recommendations suggesting the use of
const session = this.restoreSession(context);
in a custom sequence.ts file. Although I followed this advice, the function restoreSession is not recognized.
Another suggestion involved implementing the express-session package. However, this option was deemed unsuitable due to client limitations regarding cookie storage.