Skip to content

Commit

Permalink
feat(authentication): allow defaultMetadata for methods not decorated…
Browse files Browse the repository at this point in the history
… with @authenticate
  • Loading branch information
raymondfeng committed Sep 19, 2019
1 parent a59e58d commit 2bd9225
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 40 deletions.
20 changes: 19 additions & 1 deletion docs/site/Loopback-component-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export class AuthenticationComponent implements Component {
```

As you can see, there are a few [providers](Creating-components.md#providers)
which make up the bulk of the authenticaton component.
which make up the bulk of the authentication component.

Essentially

Expand Down Expand Up @@ -125,6 +125,12 @@ The decorator's syntax is:
@authenticate(strategyName: string, options?: object)
```

or

```ts
@authenticate(metadata: AuthenticationMetadata)
```

The **strategyName** is the **unique** name of the authentication strategy.

When the **options** object is specified, it must be relevant to that particular
Expand Down Expand Up @@ -187,6 +193,18 @@ data of type `AuthenticationMetadata` provided by `AuthMetadataProvider`. The
`AuthenticationMetadata` to figure out what **name** you specified as a
parameter in the `@authenticate` decorator of a specific controller endpoint.

## Default authentication metadata

In some cases, it's desirable to have a default authentication enforcement for
methods that are not explicitly decorated with `@authenticate`. To do so, we can
simply configure the authentication component with `defaultMetadata` as follows:

```ts
app
.configure(AuthenticationBindings.COMPONENT)
.to({defaultMetadata: {strategy: 'xyz'}});
```

## Adding an Authentication Action to a Custom Sequence

In a LoopBack 4 application with REST API endpoints, each request passes through
Expand Down
13 changes: 8 additions & 5 deletions docs/site/decorators/Decorators_authenticate.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ permalink: /doc/en/lb4/Decorators_authenticate.html

## Authentication Decorator

Syntax: `@authenticate(strategyName: string, options?: object)`
Syntax: `@authenticate(strategyName: string, options?: object)` or
`@authenticate(metadata: AuthenticationMetadata)`

Marks a controller method as needing an authenticated user. This decorator
requires a strategy name as a parameter.
Expand Down Expand Up @@ -40,10 +41,12 @@ export class WhoAmIController {
}
```

Please note that `@authenticate` can also be applied at class level for all
methods within the class. In the code below, `whoAmI` is protected with
`BasicStrategy` (inherited from the class level) while `hello` does not require
authentication (skipped by `@authenticate.skip`).
To configure a default authentication for all methods within a class,
`@authenticate` can also be applied at the class level. In the code below,
`whoAmI` is protected with `BasicStrategy` even though there is no
`@authenticate` is present for the method itself. The configuration is inherited
from the class. The `hello` method does not require authentication as it's
skipped by `@authenticate.skip`.

```ts
@authenticate('BasicStrategy')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ describe('Authentication', () => {
});
});

it('can add authenticate metadata to target method with an object', () => {
class TestClass {
@authenticate({
strategy: 'my-strategy',
options: {option1: 'value1', option2: 'value2'},
})
whoAmI() {}
}

const metaData = getAuthenticateMetadata(TestClass, 'whoAmI');
expect(metaData).to.eql({
strategy: 'my-strategy',
options: {option1: 'value1', option2: 'value2'},
});
});

it('can add authenticate metadata to target method without options', () => {
class TestClass {
@authenticate('my-strategy')
Expand Down Expand Up @@ -70,6 +86,16 @@ describe('Authentication', () => {
}

const metaData = getAuthenticateMetadata(TestClass, 'whoAmI');
expect(metaData).to.be.undefined();
expect(metaData).to.containEql({skip: true});
});

it('can skip authentication at class level', () => {
@authenticate.skip()
class TestClass {
whoAmI() {}
}

const metaData = getAuthenticateMetadata(TestClass, 'whoAmI');
expect(metaData).to.containEql({skip: true});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {expect} from '@loopback/testlab';
import {CoreBindings} from '@loopback/core';
import {Context, Provider} from '@loopback/context';
import {AuthenticationMetadata, authenticate} from '../../..';
import {CoreBindings} from '@loopback/core';
import {expect} from '@loopback/testlab';
import {authenticate, AuthenticationMetadata} from '../../..';
import {AuthenticationBindings} from '../../../keys';
import {AuthMetadataProvider} from '../../../providers';

describe('AuthMetadataProvider', () => {
Expand All @@ -15,6 +16,9 @@ describe('AuthMetadataProvider', () => {
class TestController {
@authenticate('my-strategy', {option1: 'value1', option2: 'value2'})
whoAmI() {}

@authenticate.skip()
hello() {}
}

class ControllerWithNoMetadata {
Expand Down Expand Up @@ -51,6 +55,35 @@ describe('AuthMetadataProvider', () => {
});
});

it('returns undefined for a method decorated with @authenticate.skip', async () => {
const context: Context = new Context();
context.bind(CoreBindings.CONTROLLER_CLASS).to(TestController);
context.bind(CoreBindings.CONTROLLER_METHOD_NAME).to('hello');
context
.bind(CoreBindings.CONTROLLER_METHOD_META)
.toProvider(AuthMetadataProvider);
const authMetadata = await context.get(
CoreBindings.CONTROLLER_METHOD_META,
);
expect(authMetadata).to.be.undefined();
});

it('returns undefined for a method decorated with @authenticate.skip even with default metadata', async () => {
const context: Context = new Context();
context.bind(CoreBindings.CONTROLLER_CLASS).to(TestController);
context.bind(CoreBindings.CONTROLLER_METHOD_NAME).to('hello');
context
.bind(CoreBindings.CONTROLLER_METHOD_META)
.toProvider(AuthMetadataProvider);
context
.configure(AuthenticationBindings.COMPONENT)
.to({defaultMetadata: {strategy: 'xyz'}});
const authMetadata = await context.get(
CoreBindings.CONTROLLER_METHOD_META,
);
expect(authMetadata).to.be.undefined();
});

it('returns undefined if no auth metadata is defined', async () => {
const context: Context = new Context();
context
Expand All @@ -66,6 +99,24 @@ describe('AuthMetadataProvider', () => {
expect(authMetadata).to.be.undefined();
});

it('returns default metadata if no auth metadata is defined', async () => {
const context: Context = new Context();
context
.bind(CoreBindings.CONTROLLER_CLASS)
.to(ControllerWithNoMetadata);
context.bind(CoreBindings.CONTROLLER_METHOD_NAME).to('whoAmI');
context
.configure(AuthenticationBindings.COMPONENT)
.to({defaultMetadata: {strategy: 'xyz'}});
context
.bind(CoreBindings.CONTROLLER_METHOD_META)
.toProvider(AuthMetadataProvider);
const authMetadata = await context.get(
CoreBindings.CONTROLLER_METHOD_META,
);
expect(authMetadata).to.be.eql({strategy: 'xyz'});
});

it('returns undefined when the class or method is missing', async () => {
const context: Context = new Context();
context
Expand Down
3 changes: 2 additions & 1 deletion packages/authentication/src/authentication.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Component, ProviderMap} from '@loopback/core';
import {bind, Component, ContextTags, ProviderMap} from '@loopback/core';
import {AuthenticationBindings} from './keys';
import {
AuthenticateActionProvider,
AuthenticationStrategyProvider,
AuthMetadataProvider,
} from './providers';

@bind({tags: {[ContextTags.KEY]: AuthenticationBindings.COMPONENT}})
export class AuthenticationComponent implements Component {
providers?: ProviderMap;

Expand Down
38 changes: 16 additions & 22 deletions packages/authentication/src/decorators/authenticate.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,7 @@ import {
AUTHENTICATION_METADATA_KEY,
AUTHENTICATION_METADATA_METHOD_KEY,
} from '../keys';

/**
* Authentication metadata stored via Reflection API
*/
export interface AuthenticationMetadata {
strategy: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: {[name: string]: any};
}
import {AuthenticationMetadata} from '../types';

class AuthenticateClassDecoratorFactory extends ClassDecoratorFactory<
AuthenticationMetadata
Expand All @@ -32,10 +24,14 @@ class AuthenticateClassDecoratorFactory extends ClassDecoratorFactory<
/**
* Mark a controller method as requiring authenticated user.
*
* @param strategyName - The name of the authentication strategy to use.
* @param strategyNameOrMetadata - The name of the authentication strategy to use
* or the authentication metadata object.
* @param options - Additional options to configure the authentication.
*/
export function authenticate(strategyName: string, options?: object) {
export function authenticate(
strategyNameOrMetadata: string | AuthenticationMetadata,
options?: object,
) {
return function authenticateDecoratorForClassOrMethod(
// Class or a prototype
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -46,25 +42,25 @@ export function authenticate(strategyName: string, options?: object) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
methodDescriptor?: TypedPropertyDescriptor<any>,
) {
let spec: AuthenticationMetadata;
if (typeof strategyNameOrMetadata === 'object') {
spec = strategyNameOrMetadata;
} else {
spec = {strategy: strategyNameOrMetadata, options: options || {}};
}
if (method && methodDescriptor) {
// Method
return MethodDecoratorFactory.createDecorator<AuthenticationMetadata>(
AUTHENTICATION_METADATA_KEY,
{
strategy: strategyName,
options: options || {},
},
spec,
{decoratorName: '@authenticate'},
)(target, method, methodDescriptor);
}
if (typeof target === 'function' && !method && !methodDescriptor) {
// Class
return AuthenticateClassDecoratorFactory.createDecorator(
AUTHENTICATION_METADATA_CLASS_KEY,
{
strategy: strategyName,
options: options || {},
},
spec,
{decoratorName: '@authorize'},
)(target);
}
Expand All @@ -80,7 +76,7 @@ export namespace authenticate {
/**
* `@authenticate.skip()` - a sugar decorator to skip authentication
*/
export const skip = () => authenticate('', {skip: true});
export const skip = () => authenticate({strategy: '', skip: true});
}

/**
Expand All @@ -99,13 +95,11 @@ export function getAuthenticateMetadata(
targetClass.prototype,
methodName,
);
if (metadata && metadata.options && metadata.options.skip) return undefined;
if (metadata) return metadata;
// Check if the class level has `@authenticate`
metadata = MetadataInspector.getClassMetadata<AuthenticationMetadata>(
AUTHENTICATION_METADATA_CLASS_KEY,
targetClass,
);
if (metadata && metadata.options && metadata.options.skip) return undefined;
return metadata;
}
12 changes: 10 additions & 2 deletions packages/authentication/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@
import {BindingKey} from '@loopback/context';
import {MetadataAccessor} from '@loopback/metadata';
import {SecurityBindings} from '@loopback/security';
import {AuthenticationMetadata} from './decorators';
import {AuthenticateFn, AuthenticationStrategy} from './types';
import {AuthenticationComponent} from './authentication.component';
import {
AuthenticateFn,
AuthenticationMetadata,
AuthenticationStrategy,
} from './types';

/**
* Binding keys used by this component.
*/
export namespace AuthenticationBindings {
export const COMPONENT = BindingKey.create<AuthenticationComponent>(
'components.AuthenticationComponent',
);

/**
* Key used to bind an authentication strategy to the context for the
* authentication function to use.
Expand Down
18 changes: 15 additions & 3 deletions packages/authentication/src/providers/auth-metadata.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Constructor, inject, Provider} from '@loopback/context';
import {config, Constructor, inject, Provider} from '@loopback/context';
import {CoreBindings} from '@loopback/core';
import {AuthenticationMetadata, getAuthenticateMetadata} from '../decorators';
import {getAuthenticateMetadata} from '../decorators';
import {AuthenticationBindings} from '../keys';
import {AuthenticationMetadata, AuthenticationOptions} from '../types';

/**
* Provides authentication metadata of a controller method
Expand All @@ -18,13 +20,23 @@ export class AuthMetadataProvider
private readonly controllerClass: Constructor<{}>,
@inject(CoreBindings.CONTROLLER_METHOD_NAME, {optional: true})
private readonly methodName: string,
@config({fromBinding: AuthenticationBindings.COMPONENT})
private readonly options: AuthenticationOptions = {},
) {}

/**
* @returns AuthenticationMetadata
*/
value(): AuthenticationMetadata | undefined {
if (!this.controllerClass || !this.methodName) return;
return getAuthenticateMetadata(this.controllerClass, this.methodName);
const metadata = getAuthenticateMetadata(
this.controllerClass,
this.methodName,
);
// Skip authentication if `skip` is `true`
if (metadata && metadata.skip) return undefined;
if (metadata) return metadata;
// Fall back to default metadata
return this.options.defaultMetadata;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

import {BindingScope, Getter, inject} from '@loopback/context';
import {extensionPoint, extensions, Provider} from '@loopback/core';
import {AuthenticationMetadata} from '../decorators/authenticate.decorator';
import {AuthenticationBindings} from '../keys';
import {
AuthenticationMetadata,
AuthenticationStrategy,
AUTHENTICATION_STRATEGY_NOT_FOUND,
} from '../types';
Expand Down

0 comments on commit 2bd9225

Please sign in to comment.