Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: design auth system with user scenario #2576

Merged
merged 1 commit into from
Mar 28, 2019
Merged

feat: design auth system with user scenario #2576

merged 1 commit into from
Mar 28, 2019

Conversation

jannyHou
Copy link
Contributor

@jannyHou jannyHou commented Mar 12, 2019

Description

connect to #2491

(The markdown files should be part of task #2435 while we wrote them when creating the interface for login service, so connect this PR to #2491 instead)

This PR

  • explains/proposed a new design of auth module which could potentially support multiple auth strategies.
  • adds abstractions for login user service

When review PR, please start from the file authentication-system.md in the root dir of @loopback/authentication, the content on that page will mention about each new created .md file and take you there.

Checklist

👉 Read and sign the CLA (Contributor License Agreement) 👈

  • npm test passes on your machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • API Documentation in code was updated
  • Documentation in /docs/site was updated
  • Affected artifact templates in packages/cli were updated
  • Affected example projects in examples/* were updated

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text seems to be based in the original ideas described in GH issues and does not seem to take into account my comments and concerns about shortcomings of that original proposal. Is that intentional?

packages/authentication/authentication-system.md Outdated Show resolved Hide resolved
// 1. Try to find current user
// 2. If found, return it
// 3. If not found, throw 401:
verify(request) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing complete signature

return tokenAndUser.userProfile;
};

ensureLoggedIn(request) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing complete signature

@jannyHou
Copy link
Contributor Author

@bajtos

The text seems to be based in the original ideas described in GH issues and does not seem to take into account my comments and concerns about shortcomings of that original proposal. Is that intentional?

do you mean split the abstractions into "transport related" and "transport agnostic" ones?

If you look at the services folder, we split the login service into two:

  • login service: agnostic of the transport layer.
  • client service: related to the transport layer. (The service name may not be a proper one, we can change)

Or did I miss anything?

// read the action metadata from endpoint's auth metadata like
// `@authenticate('strategy_name', {actions: 'authenticate'})`
// type ActionType = 'authenticate' | 'ensureLoggedIn'
const action = await this.getAction();
Copy link
Contributor

@raymondfeng raymondfeng Mar 13, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we don't need to differentiate between login and verify actions. Please note Passport just uses different strategies, for example:

Both strategies implement authenticate method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I was coupling them into one strategy.

class UserController {
@post('/loginWithFB', ...APISpec)
@authenticate('oath2.fb', {action: 'login', session: false})
loginWithFB() {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we use login with facebook, the browser will be redirected to facebook login page to perform the oAuth 2.0 implicit or authorization code flow. Both of them require a callback URL. When the controller method is decorated as shown, what logic should be in loginWithFB method? Is it the callback function or just a place holder route to excise the FB login flow? I imagine that /loginWithFB will initiate the FB login.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it the callback function or just a place holder route to excise the FB login flow?

The second one "a place holder route to excise the FB login flow".

We find it pretty hard to illustrate the oauth+session strategies without implementing an example, so I created a story to explorer it then revisit the design here and update accordingly.

#2624

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My proposal would be defining a separate callback endpoint, e.g. login/facebook and login/facebook/callback


The diagram below illustrates the high level abstraction of such an extensible authentication system.

<img src="./docs/imgs/multiple-auth-strategies-login.png" width="1000px" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@raymondfeng
Copy link
Contributor

@jannyHou Please run npm lint:fix to fix formatting issues reported by https://travis-ci.org/strongloop/loopback-next/jobs/505426718

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you mean split the abstractions into "transport related" and "transport agnostic" ones?

If you look at the services folder, we split the login service into two:

  • login service: agnostic of the transport layer.
  • client service: related to the transport layer. (The service name may not be a proper one, we can change)

Thank you for the pointers! My comment was based on reading the first Markdown file only, I didn't read the TypeScript definitions.

I think this aspect is resolved now 👍

Or did I miss anything?

See the second comment below.

I think that by now, you have much better picture about the authentication architecture than I have. I am happy to leave this in your hands going forward.

* @param request The incoming Request
*/
// This function should probably moved to session service?
invalidateCredentials?(request: Request): Promise<boolean>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the login service is transport agnostic now, I think it should be accepting credentials: Credentials instead of request: Request?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 yep, we redesigned all the transport related logic into the strategy class or the controller method. Now the services are agnostic of the client.

// Here I hardcoded the request type to be HTTP request,
// while a generic type R should be used here to support
// other clients like SOAP, GRPC
extractCredentials(request: Request): Promise<C>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts on the following (re-posted from #2491 (comment))?

I find it problematic to implement transport specific behavior in a service.

How do we envision that such service would be used from a controller? It's important to describe the login endpoint via OpenAPI and include specification of input parameters. Are we expecting the controller method to be decorated with @param decorators and then pass the entire request to the login service? That would make it too easy to introduce bugs where the controller is describing different parameters than the service extracts from the request.

IMO, extract & invalidate credentials should not be provided by any service, but should be implemented as controller actions leveraging the services described in #2435 under the hood.

class UserController {
  constructor (
    // inject response, AccessTokenService, TokenTransportStrategy and LoginService
  ) {}

  @post('/login')
  async login(
    @param() email: string,
    @param() password: string,
  ) {
    const user = await this.loginService.verifyCredentials({email, password});
    const token = await this.tokenService.generateAccessToken(user);
    await this.transportStrategy.serializeAccessToken(this.response);
   // uh oh, what is the return value of this method?
   // if the token is returned in response body, how are we going to describe response schema?
  }
    
  @post('/logout')
  async logout(
    @inject('current access token') token: string
  ) {
    await this.tokenService.invalidateAccessToken(token);
  }
}

Copy link
Contributor Author

@jannyHou jannyHou Mar 21, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bajtos Yeah that's the twisted part...

How do we envision that such service would be used from a controller?

We decide to not create service for request/response related operations, like extracting credentials from request, writing into response, etc...

The login endpoint is verify special, in the shopping example, login doesn't even invokes the authentication action...

In the new design we come up with a new proposal which moves the credential verification back into the auth strategy. The controller function only generates the access token.

Because the auth strategy's responsibility is taking in a request and returning an authenticated user profile, I suggest let developers decide handling the authentication in the auth action OR in the controller:

  • Do authentication in strategy:

    • the strategy will extract and validate the user credentials from request
    • it also verifies the credentials by calling userService.verifyCredentials()
    • controller function generates the access token
    • pros
      • consistent with endpoints other than /login
    • cons
      • lose the convenience provided by REST decorator(e.g. describing parameters using maxLength)
  • Do authentication in controller:

    • describe the credentials from request using OpenAPI spec
    • action parseParam will parse+validate credentials
    • controller function verify credentials(call userService.verifyCredentials()) and generates the access token.
    • pros
      • describe the response and request parameters in a clear way.
    • cons
      • repeating the auth strategy logic(extract+verify credentials) using OpenAPI spec+controller code
      • usually the login endpoints is countable and less than 5 for most of applications, treat them specially wouldn't be a huge effort.

// convert it to http error
if (err.code == '401') {
throw new HttpErrors.Unauthorized(err.message);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we throw for the else condition as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point 👍 The code in the .md file is pseudo code so I didn't refine too much for the error catch, will handle all the errors in story #2467

@hacksparrow
Copy link
Contributor

I wish the mechanics for enabling authentication was simpler. Like flip a switch, and @authenticate() just works, without having to create a provider and a custom sequence.

@jannyHou
Copy link
Contributor Author

@hacksparrow Good feedback

I wish the mechanics for enabling authentication was simpler. Like flip a switch, and @authenticate() just works, without having to create a provider and a custom sequence.

The design in this PR covers all the details of the auth mechanics, to give extension providers/app developers the max flexibility to plugin/replace their implementation. That's the reason why "making a complete auth system" looks complicated. While we could provide a default auth system that saves people's effort.

E.g.

  • Add an option to enable authenticate in the sequence when scaffold the app. People don't need to customize the sequence.

  • Implement the default auth strategy extension point in Extension point for registering the authentication strategies #2312, then people can authenticate an endpoint by ONLY 2 steps: 1. register their auth strategy as an extension-->2.decorate the endpoint with strategy name. Then everything automatically works!


async action(request: Request): Promise<UserProfile | undefined> {
const authStrategy = await this.getAuthStrategy();
if (!authStrategy) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we differentiate between the following two cases?

  1. No authentication is required
  2. No strategy is found to handle the authentication

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raymondfeng I would expect the resolver getAuthStrategy() to throw an error if the strategy is not found.

We haven't added the resolver, I plan to implement it in the extension story in #2312

// The resolver will read the options object from metadata, call `strategy.setOptions`
options: object;
authenticate(request: Request): Promise<UserProfile | undefined>;
setOptions(options: object);
Copy link
Contributor

@raymondfeng raymondfeng Mar 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to pass in the metadata via authenticate method, such as:

authenticate(request: Request, options?: ...): Promise<UserProfile | undefined>;

Your current proposal will mandate a strategy implementation to be bound in TRANSIENT scope.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your current proposal will mandate a strategy implementation to be bound in TRANSIENT scope.

True....

[authentication-system](./authentication-system.md).

Please note how they are decorated with `@authenticate()`, the syntax is:
`@authenticate(<strategy_name>, {action: <action_name>, session: <enabled_or_not>})`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is action still needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, not really


// Describe the response using OpenAPI spec
@post('/loginOAI/local', RESPONSE_SPEC_FOR_JWT_LOGIN)
@authenticate('basicAuth')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we allow a method to be authenticated by one of the strategies?

@authenticate(['basicAuth', 'localAuth']) // We allow either basic or local auth

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought "local" and "basic" auth are same, which is wrong. So the endpoint here meant to be decorated with only one strategy.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We leave it for future decisions. It would be nice to support one of the many auths on the same method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We leave it for future decisions. It would be nice to support one of the many auths on the same method.

My understanding is:

  • For "login" endpoints:
    • the number of endpoints will be small, e.g. one system supports 5 login ways
    • usually there is only one strategy applied per endpoint
  • For "verify" endpoints:
    • the number of endpoints will be large
    • it makes sense to specify a default auth strategy for them instead of repeating the decoration thousands of times, OR, to be more practical, resolve the auth strategy based on the login approach.

There is an issue discussing about resolving the strategy after login:
Shall we continue the discussion in #2139?

class BasicAuthenticationStrategy implements AuthenticationStrategy {
options: object;
constructor(
@inject(AUTHENTICATION_BINDINGS.SERVICES.USER) userService: UserService,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe AUTHENTICATION_BINDINGS.USER_SERVICE? Let's constrain the namespace to be at most one level.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense

@@ -5,7 +5,7 @@ the beginning of markdown file
[authentication-system](./authentication-system.md).

Please note how they are decorated with `@authenticate()`, the syntax is:
`@authenticate(<strategy_name>, {action: <action_name>, session: <enabled_or_not>})`
`@authenticate(strategy_name, options)`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future, we can allow class-level decoration of @authenticate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. sounds like an improvement could be applied with #2576 (comment)

// Describe the response using OpenAPI spec
@post('/loginOAI/local', RESPONSE_SPEC_FOR_JWT_LOGIN)
@post('/loginOAI/basicAuth', RESPONSE_SPEC_FOR_JWT_LOGIN)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to clarify that the login method implementation can have two choices:

  1. @authenticate + user code
  2. plain user code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, would like to add #2576 (comment) here.

@emonddr
Copy link
Contributor

emonddr commented Mar 27, 2019

rebased and squashed commits

@jannyHou jannyHou merged commit 124c078 into master Mar 28, 2019
@bajtos bajtos deleted the login-service branch March 28, 2019 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants