Overview

In every web application, we need to have a way to identify access rights of auser for any resource, which is known as Authorization. This is aminimalistic guide for creating such an implementation using Loopback component.This can be part of your main REST Application project or can be created as aLoopback extension for reuse in multiple projects. Latter is the better optionfor obvious reasons - reusability.

Note:

The LoopBack team is working on making authorization an out-of-the-boxfeature in LoopBack 4. It is a work in progress and will soon be there. Untilthen, this implementation guide can be followed.

The requirement

  • Every protected API end point needs to be restricted by specific permissions.
  • API allows access only if logged in user has permission as per end pointrestrictions.
  • API throws 403 Forbidden error if logged in user do not have sufficientpermissions.
  • Publicly accessible APIs must be accessible regardless of user permissions.
  • Every user has a set of permissions. These permissions may be associated viarole attached to the user or directly to the user.
  • A user can be provided additional permissions or denied some permissions overan above its role permissions. This is considered explicit allow/deny andalways takes precedence while calculating permissions.

Considerations

There are a few considerations that are taken into account before thisimplementation can be done.

  • User authentication is already implemented. You can refer to the@loopback/authenticationguide.
  • As part of authentication, client is sent back a token (JWT or similar) whichclient need to pass in every API request headers thereafter.
  • The authenticate action provider parses the token to return AuthResponseobject.
  • AuthResponse contains the logged in user information including associatedrole details.

The implementation

First, let’s define the types needed for this.

/src/authorization/types.ts

  1. import {PermissionKey} from './permission-key';
  2. /**
  3. * Authorize action method interface
  4. */
  5. export interface AuthorizeFn {
  6. // userPermissions - Array of permission keys granted to the user
  7. // This is actually a union of permissions picked up based on role
  8. // attached to the user and allowed permissions at specific user level
  9. (userPermissions: PermissionKey[]): Promise<boolean>;
  10. }
  11. /**
  12. * Authorization metadata interface for the method decorator
  13. */
  14. export interface AuthorizationMetadata {
  15. // Array of permissions required at the method level.
  16. // User need to have at least one of these to access the API method.
  17. permissions: string[];
  18. }
  19. /**
  20. * User Permission model
  21. * used for explicit allow/deny any permission at user level
  22. */
  23. export interface UserPermission {
  24. permission: PermissionKey;
  25. allowed: boolean;
  26. }
  27. /**
  28. * User permissions manipulation method interface.
  29. *
  30. * This is where we can add our business logic to read and
  31. * union permissions associated to user via role with
  32. * those associated directly to the user.
  33. *
  34. */
  35. export interface UserPermissionsFn {
  36. (
  37. userPermissions: UserPermission[],
  38. rolePermissions: PermissionKey[],
  39. ): PermissionKey[];
  40. }

We define four interfaces.

  • AuthorizeFn - This is going to be the interface for authorization actionbusiness logic.
  • AuthorizationMetadata - This interface represents the information to bepassed via decorator for each individual controller method.
  • UserPermission - This is the interface to be used for associating userlevel permissions. It is actually doing explicit allow/deny at user level,over and above role permissions.
  • UserPermissionsFn - This is going to be the interface for userpermissions manipulation, if required any.The PermissionKey is an enum containing all possible permission keys. Here is asample.

/src/authorization/permission-key.ts

  1. export const enum PermissionKey {
  2. // For accessing own (logged in user) profile
  3. ViewOwnUser = 'ViewOwnUser',
  4. // For accessing other users profile.
  5. ViewAnyUser = 'ViewAnyUser',
  6. // For creating a user
  7. CreateAnyUser = 'CreateAnyUser',
  8. // For updating own (logged in user) profile
  9. UpdateOwnUser = 'UpdateOwnUser',
  10. // For updating other users profile
  11. UpdateAnyUser = 'UpdateAnyUser',
  12. // For deleting a user
  13. DeleteAnyUser = 'DeleteAnyUser',
  14. // For accessing a role
  15. ViewRoles = 'ViewRoles',
  16. // For creating a role
  17. CreateRoles = 'CreateRoles',
  18. // For updating a role info
  19. UpdateRoles = 'UpdateRoles',
  20. // For removing a role
  21. DeleteRoles = 'DeleteRoles',
  22. }

Next, we create the binding keys for each type and accessor key for methoddecorator.

/src/authorization/keys.ts

  1. import {BindingKey} from '@loopback/context';
  2. import {MetadataAccessor} from '@loopback/metadata';
  3. import {AuthorizeFn, AuthorizationMetadata, UserPermissionsFn} from './types';
  4. /**
  5. * Binding keys used by this component.
  6. */
  7. export namespace AuthorizatonBindings {
  8. export const AUTHORIZE_ACTION = BindingKey.create<AuthorizeFn>(
  9. 'userAuthorization.actions.authorize',
  10. );
  11. export const METADATA = BindingKey.create<AuthorizationMetadata | undefined>(
  12. 'userAuthorization.operationMetadata',
  13. );
  14. export const USER_PERMISSIONS = BindingKey.create<UserPermissionsFn>(
  15. 'userAuthorization.actions.userPermissions',
  16. );
  17. }
  18. /**
  19. * Metadata accessor key for authorize method decorator
  20. */
  21. export const AUTHORIZATION_METADATA_ACCESSOR = MetadataAccessor.create<
  22. AuthorizationMetadata,
  23. MethodDecorator
  24. >('userAuthorization.accessor.operationMetadata');

Now, we need to create three providers

  • AuthorizationMetadataProvider - This will read the decorator metadatafrom the controller methods wherever the decorator is used.
  • AuthorizeActionProvider - This holds the business logic for accessvalidation of the user based upon access permissions allowed at method levelvia decorator metadata above.
  • UserPermissionsProvider - This is where we can add our business logic toread and unify permissions associated to user via role, with those associateddirectly to the user. In our case, an explicit allow/deny at user level takesprecendence over role permissions. But this business logic may varyapllication to application. So, feel free to customize.

/src/authorization/providers/authorization-metadata.provider.ts

  1. import {
  2. Constructor,
  3. inject,
  4. MetadataInspector,
  5. Provider,
  6. } from '@loopback/context';
  7. import {CoreBindings} from '@loopback/core';
  8. import {AUTHORIZATION_METADATA_ACCESSOR} from '../keys';
  9. import {AuthorizationMetadata} from '../types';
  10. export class AuthorizationMetadataProvider
  11. implements Provider<AuthorizationMetadata | undefined> {
  12. constructor(
  13. @inject(CoreBindings.CONTROLLER_CLASS)
  14. private readonly controllerClass: Constructor<{}>,
  15. @inject(CoreBindings.CONTROLLER_METHOD_NAME)
  16. private readonly methodName: string,
  17. ) {}
  18. value(): AuthorizationMetadata | undefined {
  19. return getAuthorizeMetadata(this.controllerClass, this.methodName);
  20. }
  21. }
  22. export function getAuthorizeMetadata(
  23. controllerClass: Constructor<{}>,
  24. methodName: string,
  25. ): AuthorizationMetadata | undefined {
  26. return MetadataInspector.getMethodMetadata<AuthorizationMetadata>(
  27. AUTHORIZATION_METADATA_ACCESSOR,
  28. controllerClass.prototype,
  29. methodName,
  30. );
  31. }

/src/authorization/providers/authorization-action.provider.ts

  1. import {Getter, inject, Provider} from '@loopback/context';
  2. import {AuthorizatonBindings} from '../keys';
  3. import {AuthorizationMetadata, AuthorizeFn} from '../types';
  4. import {intersection} from 'lodash';
  5. export class AuthorizeActionProvider implements Provider<AuthorizeFn> {
  6. constructor(
  7. @inject.getter(AuthorizatonBindings.METADATA)
  8. private readonly getMetadata: Getter<AuthorizationMetadata>,
  9. ) {}
  10. value(): AuthorizeFn {
  11. return response => this.action(response);
  12. }
  13. async action(userPermissions: string[]): Promise<boolean> {
  14. const metadata: AuthorizationMetadata = await this.getMetadata();
  15. if (!metadata) {
  16. return false;
  17. } else if (metadata.permissions.indexOf('*') === 0) {
  18. // Return immediately with true, if allowed to all
  19. // This is for publicly open routes only
  20. return true;
  21. }
  22. // Add your own business logic to fetch or
  23. // manipulate with user permissions here
  24. const permissionsToCheck = metadata.permissions;
  25. return intersection(userPermissions, permissionsToCheck).length > 0;
  26. }
  27. }

Below is the user permissions manipulation logic. If there is no requirement ofuser level permissions in your application, you can skip the below.

/src/authorization/providers/user-permissions.provider.ts

  1. import {Provider} from '@loopback/context';
  2. import {PermissionKey} from '../permission-key';
  3. import {UserPermission, UserPermissionsFn} from '../types';
  4. export class UserPermissionsProvider implements Provider<UserPermissionsFn> {
  5. constructor() {}
  6. value(): UserPermissionsFn {
  7. return (userPermissions, rolePermissions) =>
  8. this.action(userPermissions, rolePermissions);
  9. }
  10. action(
  11. userPermissions: UserPermission[],
  12. rolePermissions: PermissionKey[],
  13. ): PermissionKey[] {
  14. let perms: PermissionKey[] = [];
  15. // First add all permissions associated with role
  16. perms = perms.concat(rolePermissions);
  17. // Now update permissions based on user permissions
  18. userPermissions.forEach((userPerm: UserPermission) => {
  19. if (userPerm.allowed && perms.indexOf(userPerm.permission) < 0) {
  20. // Add permission if it is not part of role but allowed to user
  21. perms.push(userPerm.permission);
  22. } else if (!userPerm.allowed && perms.indexOf(userPerm.permission) >= 0) {
  23. // Remove permission if it is disallowed for user
  24. perms.splice(perms.indexOf(userPerm.permission), 1);
  25. }
  26. });
  27. return perms;
  28. }
  29. }

Next, we need to expose these providers via Component to be bound to thecontext.

/src/authorization/component.ts

  1. import {Component, ProviderMap} from '@loopback/core';
  2. import {AuthorizatonBindings} from './keys';
  3. import {AuthorizeActionProvider} from './providers/authorization-action.provider';
  4. import {AuthorizationMetadataProvider} from './providers/authorization-metadata.provider';
  5. import {UserPermissionsProvider} from './providers/user-permissions.provider';
  6. export class AuthorizationComponent implements Component {
  7. providers?: ProviderMap;
  8. constructor() {
  9. this.providers = {
  10. [AuthorizatonBindings.AUTHORIZE_ACTION.key]: AuthorizeActionProvider,
  11. [AuthorizatonBindings.METADATA.key]: AuthorizationMetadataProvider,
  12. [AuthorizatonBindings.USER_PERMISSIONS.key]: UserPermissionsProvider,
  13. };
  14. }
  15. }

You can see that we have used the same binding keys which we created earlier.

Now, its time to create our method decorator function. Here it is. We will beusing the same metadata accessor key which we created earlier and the metadatainterface for accessing the data in decorator.

/src/authorization/decorators/authorize.decorator.ts

  1. import {MethodDecoratorFactory} from '@loopback/core';
  2. import {AuthorizationMetadata} from '../types';
  3. import {AUTHORIZATION_METADATA_ACCESSOR} from '../keys';
  4. export function authorize(permissions: string[]) {
  5. return MethodDecoratorFactory.createDecorator<AuthorizationMetadata>(
  6. AUTHORIZATION_METADATA_ACCESSOR,
  7. {
  8. permissions: permissions || [],
  9. },
  10. );
  11. }

For error handling keys, lets create an enum.

/src/authorization/error-keys.ts

  1. export const enum AuthorizeErrorKeys {
  2. NotAllowedAccess = 'Not Allowed Access',
  3. }

Finally, we put everything together in one index file.

/src/authorization/index.ts

  1. export * from './component';
  2. export * from './types';
  3. export * from './keys';
  4. export * from './error-keys';
  5. export * from './permission-key';
  6. export * from './decorators/authorize.decorator';
  7. export * from './providers/authorization-metadata.provider';
  8. export * from './providers/authorization-action.provider';
  9. export * from './providers/user-permissions.provider';

That is all for the authorization component. You can create all of the aboveinto a loopback extension as well. Everything remains the same. Refer to theextension generator guide for creating an extension.

Usage

In order to use the above component into our REST API application, we have a fewmore steps to go.

  • Add component to application.

/src/application.ts

  1. this.component(AuthenticationComponent);
  • Add permissions array to the role model.

/src/models/role.model.ts

  1. @model({
  2. name: 'roles',
  3. })
  4. export class Role extends Entity {
  5. // .....
  6. // other attributes here
  7. // .....
  8. @property.array(String, {
  9. required: true,
  10. })
  11. permissions: PermissionKey[];
  12. constructor(data?: Partial<Role>) {
  13. super(data);
  14. }
  15. }
  • Add user level permissions array to the user model. Do this if there is a usecase of explicit allow/deny of permissions at user-level in the application.You can skip otherwise.

/src/models/user.model.ts

  1. @model({
  2. name: 'users',
  3. })
  4. export class User extends Entity {
  5. // .....
  6. // other attributes here
  7. // .....
  8. @property.array(String)
  9. permissions: UserPermission[];
  10. constructor(data?: Partial<User>) {
  11. super(data);
  12. }
  13. }
  • Add a step in custom sequence to check for authorization whenever any endpoint is hit.

/src/sequence.ts

  1. import {inject} from '@loopback/context';
  2. import {
  3. FindRoute,
  4. getModelSchemaRef,
  5. InvokeMethod,
  6. ParseParams,
  7. Reject,
  8. RequestContext,
  9. RestBindings,
  10. Send,
  11. SequenceHandler,
  12. HttpErrors,
  13. } from '@loopback/rest';
  14. import {AuthenticationBindings, AuthenticateFn} from './authenticate';
  15. import {
  16. AuthorizatonBindings,
  17. AuthorizeFn,
  18. AuthorizeErrorKeys,
  19. } from './authorization';
  20. const SequenceActions = RestBindings.SequenceActions;
  21. export class MySequence implements SequenceHandler {
  22. constructor(
  23. @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
  24. @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
  25. @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
  26. @inject(SequenceActions.SEND) public send: Send,
  27. @inject(SequenceActions.REJECT) public reject: Reject,
  28. @inject(AuthenticationBindings.AUTH_ACTION)
  29. protected authenticateRequest: AuthenticateFn,
  30. @inject(AuthorizatonBindings.USER_PERMISSIONS)
  31. protected fetchUserPermissons: UserPermissionsFn,
  32. @inject(AuthorizatonBindings.AUTHORIZE_ACTION)
  33. protected checkAuthorization: AuthorizeFn,
  34. ) {}
  35. async handle(context: RequestContext) {
  36. try {
  37. const {request, response} = context;
  38. const route = this.findRoute(request);
  39. const args = await this.parseParams(request, route);
  40. // Do authentication of the user and fetch user permissions below
  41. const authUser: AuthResponse = await this.authenticateRequest(request);
  42. // Parse and calculate user permissions based on role and user level
  43. const permissions: PermissionKey[] = this.fetchUserPermissons(
  44. authUser.permissions,
  45. authUser.role.permissions,
  46. );
  47. // This is main line added to sequence
  48. // where we are invoking the authorize action function to check for access
  49. const isAccessAllowed: boolean = await this.checkAuthorization(
  50. permissions,
  51. );
  52. if (!isAccessAllowed) {
  53. throw new HttpErrors.Forbidden(AuthorizeErrorKeys.NotAllowedAccess);
  54. }
  55. const result = await this.invoke(route, args);
  56. this.send(response, result);
  57. } catch (err) {
  58. this.reject(context, err);
  59. }
  60. }
  61. }

Now we can add access permission keys to the controller methods using authorizedecorator as below.

  1. @authorize([PermissionKey.CreateRoles])
  2. @post(rolesPath, {
  3. responses: {
  4. [STATUS_CODE.OK]: {
  5. description: 'Role model instance',
  6. content: {
  7. [CONTENT_TYPE.JSON]: {schema: getModelSchemaRef(Role)}},
  8. },
  9. },
  10. },
  11. })
  12. async create(@requestBody() role: Role): Promise<Role> {
  13. return this.roleRepository.create(role);
  14. }

This endpoint will only be accessible if logged in user has permission‘CreateRoles’.