-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: design auth system with user scenario #2576
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| ### Auth action | ||
|
|
||
| ```ts | ||
| import * as HttpErrors from 'http-errors'; | ||
|
|
||
| async action(request: Request): Promise<UserProfile | undefined> { | ||
| const authStrategy = await this.getAuthStrategy(); | ||
| if (!authStrategy) { | ||
| // The invoked operation does not require authentication. | ||
| return undefined; | ||
| } | ||
|
|
||
| try { | ||
| const userProfile: UserProfile = await authStrategy.authenticate(request); | ||
| this.setCurrentUser(userProfile); | ||
| // a convenient return for the next request handlers | ||
| return userProfile; | ||
| } catch (err) { | ||
| // interpret the raw error code/msg here and throw the corresponding HTTP error | ||
| // convert it to http error | ||
| if (err.code == '401') { | ||
| throw new HttpErrors.Unauthorized(err.message); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't we throw for the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| } | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| ### Auth strategy interface | ||
|
|
||
| ```ts | ||
| import {Request} from '@loopback/rest'; | ||
|
|
||
| interface AuthenticationStrategy { | ||
| // The resolver will read the `options` object from metadata, then invoke the | ||
| // `authenticate` with `options` if it exists. | ||
| authenticate( | ||
| request: Request, | ||
| options: object, | ||
| ): Promise<UserProfile | undefined>; | ||
|
|
||
| // This is a private function that extracts credential fields from a request, | ||
| // it is called in function `authenticate`. You could organize the extraction | ||
| // logic in this function or write them in `authenticate` directly without defining | ||
| // this extra utility. | ||
| private extractCredentials?(request: Request): Promise<Credentials>; | ||
| } | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| ## Multiple Authentication strategies | ||
|
|
||
| An authentication system in a LoopBack 4 application could potentially support | ||
| multiple popular strategies, including basic auth, oauth2, saml, openid-connect, | ||
| etc...And also allow programmers to use either a token based or a session based | ||
| approach to track the logged-in user. | ||
|
|
||
| The diagram below illustrates the high level abstraction of such an extensible | ||
| authentication system. | ||
|
|
||
| <img src="./imgs/multiple-auth-strategies-login.png" width="1000px" /> | ||
|
|
||
| Assume the app has a static login page with a list of available choices for | ||
| users to login: | ||
|
|
||
| - local: basic auth with email/username + password | ||
| - facebook account: oauth2 | ||
| - google account: oauth2 | ||
| - ibm intranet account: saml | ||
| - openid account: openid-connect | ||
| - ... | ||
|
|
||
| For the local login, we retrieve the user from a local database. | ||
|
|
||
| For the third-party service login, e.g. facebook account login, we retrieve the | ||
| user info from the facebook authorization server using oauth2, then find or | ||
| create the user in the local database. | ||
|
|
||
| By clicking any one of the links, you login with a particular account and your | ||
| status will be tracked in a session(with session-based auth), or your profile | ||
| will be encoded into a JWT token(with token-based auth). | ||
|
|
||
| A common flow for all the login strategies would be: the authentication action | ||
| verifies the credentials and returns the raw information of that logged-in user. | ||
|
|
||
| Here the raw information refers to the data returned from a third-party service | ||
| or a persistent database. Therefore you need another step to convert it to a | ||
| user profile instance which describes your application's user model. Finally the | ||
| user profile is either tracked by a generated token OR a session + cookie. | ||
|
|
||
| The next diagram illustrates the flow of verifying the client requests sent | ||
| after the user has logged in. | ||
|
|
||
| <img src="./imgs/multiple-auth-strategies-verify.png" width="1000px" /> | ||
|
|
||
| The request goes through the authentication action which invokes the | ||
| authentication strategy to decode/deserialize the user profile from the | ||
| token/session, binds it to the request context so that actions after | ||
| 'authenticate' could inject it using DI. | ||
|
|
||
| Next let's walk through the typical API flow of user login and user | ||
| verification. | ||
|
|
||
| ## API Flows (using BasicAuth + JWT as example) | ||
|
|
||
| Other than the LoopBack core and its authentication module, there are different | ||
| parts included and integrated together to perform the authentication. | ||
|
|
||
| The next diagram, using the BasicAuth + JWT authentication strategy as an | ||
| example, draws two API flows: | ||
|
|
||
| - Login: user login with email+password | ||
| - Verify: verify the logged-in user | ||
|
|
||
| along with the responsibilities divided among different parts: | ||
|
|
||
| - LoopBack core: resolve a strategy based on the endpoint's corresponding | ||
| authentication metadata, execute the authentication action which invokes the | ||
| strategy's `authenticate` method. | ||
|
|
||
| - Authentication strategy: | ||
|
|
||
| - (login flow) verify user credentials and return a user profile(it's up to | ||
| the programmer to create the JWT access token inside the controller | ||
| function). | ||
| - (verify flow) verify the token and decode user profile from it. | ||
|
|
||
| - Authentication services: some utility services that can be injected in the | ||
| strategy class. (Each service's functionalities will be covered in the next | ||
| section) | ||
|
|
||
| _Note: FixIt! the step 6 in the following diagram should be moved to LoopBack | ||
| side_ | ||
|
|
||
| <img src="./imgs/API-flow-(JWT).png" width="1000px" /> | ||
|
|
||
| _Note: Another section for session based auth TBD_ | ||
|
|
||
| ## Authentication framework architecture | ||
|
|
||
| The following diagram describes the architecture of the entire authentication | ||
| framework and the detailed responsibility of each part. | ||
|
|
||
| You can check the pseudo code in folder `docs` for: | ||
|
|
||
| - [authentication-action](./authentication-action.md) | ||
| - [authentication-strategy](./authentication-strategy.md) | ||
| - [basic auth strategy](./strategies/basic-auth.md) | ||
| - [jwt strategy](./strategies/jwt.md) | ||
| - [oauth2 strategy](./strategies/oauth2.md) | ||
| - [endpoints defined in controller](./controller-functions.md) | ||
|
|
||
| And the abstractions for: | ||
|
|
||
| - [user service](../src/services/user.service.ts) | ||
| - [token service](../src/services/token.service.ts) | ||
|
|
||
| <img src="./imgs/auth-framework-architecture.png" width="1000px" /> | ||
|
|
||
| ### Token based authentication | ||
|
|
||
| - Login flow | ||
|
|
||
| - authentication action: | ||
| - resolve metadata to get the strategy | ||
| - invoke strategy.authenticate() | ||
| - set the current user as the return of strategy.authenticate() | ||
| - strategy: | ||
| - extract credentials from | ||
| - transport layer(request) | ||
| - or local configuration file | ||
| - verify credentials and return the user profile (call user service) | ||
| - controller function: | ||
| - generate token (call token service) | ||
| - return token or serialize it into the response | ||
|
|
||
| - Verify flow | ||
| - authentication action: | ||
| - resolve metadata to get the strategy | ||
| - invoke strategy.authenticate() | ||
| - set the current user as the return of strategy.authenticate() | ||
| - strategy: | ||
| - extract access token from transport layer(request) | ||
| - verify access token(call token service) | ||
| - decode user from access token(call token service) | ||
| - return user | ||
| - controller: | ||
| - process the injected user | ||
|
|
||
| ### Session based authentication | ||
|
|
||
| - Login flow | ||
|
|
||
| - authentication action: | ||
| - resolve metadata to get the strategy | ||
| - invoke strategy.authenticate() | ||
| - strategy: | ||
| - extract credentials from | ||
| - transport layer (request) | ||
| - or local configuration file | ||
| - verify credentials (call user service) and return the user profile | ||
| - controller: | ||
| - serialize user info into the session | ||
|
|
||
| - Verify flow | ||
| - authentication action: | ||
| - resolve metadata to get the strategy | ||
| - invoke strategy.authenticate() | ||
| - set the current user as the return of strategy.authenticate() | ||
| - strategy: | ||
| - extract session info from cookie(call session service) | ||
| - deserialize user info from session(call session service) | ||
| - return user | ||
| - controller function: | ||
| - process the injected user |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| ## Endpoint definitions | ||
|
|
||
| The following decorated controller functions demos the endpoints described at | ||
| the beginning of markdown file | ||
| [authentication-system](./authentication-system.md). | ||
|
|
||
| Please note how they are decorated with `@authenticate()`, the syntax is: | ||
| `@authenticate(strategy_name, options)` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the future, we can allow class-level decoration of
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree. sounds like an improvement could be applied with #2576 (comment) |
||
|
|
||
| - /login | ||
|
|
||
| ```ts | ||
| class LoginController { | ||
| @post('/login', APISpec) | ||
| login() { | ||
| // static route | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| - /loginWithLocal | ||
|
|
||
| ```ts | ||
|
|
||
| const RESPONSE_SPEC_FOR_JWT_LOGIN = { | ||
| responses: { | ||
| '200': { | ||
| description: 'Token', | ||
| content: { | ||
| 'application/json': { | ||
| schema: { | ||
| type: 'object', | ||
| properties: { | ||
| token: { | ||
| type: 'string', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| class LoginController{ | ||
| constructor( | ||
| @inject(AuthenticationBindings.CURRENT_USER) userProfile: UserProfile, | ||
| @inject(AuthenticationBindings.SERVICES.JWT_TOKEN) JWTtokenService: TokenService, | ||
| ) {} | ||
|
|
||
| // I was about to create a local login example, while if the credentials are | ||
| // provided in the request body, all the authenticate logic will happen in the | ||
| // controller, the auth action isn't even involved. | ||
| // See the login endpoint in shopping example | ||
| // https://github.com/strongloop/loopback4-example-shopping/blob/master/src/controllers/user.controller.ts#L137 | ||
|
|
||
| // Describe the response using OpenAPI spec | ||
| @post('/loginOAI/basicAuth', RESPONSE_SPEC_FOR_JWT_LOGIN) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to clarify that the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good, would like to add #2576 (comment) here. |
||
| @authenticate('basicAuth') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
My understanding is:
There is an issue discussing about resolving the strategy after login: |
||
| basicAuthLoginReturningJWTToken() { | ||
| await token = JWTtokenService.generateToken(this.userProfile); | ||
| // Action `send` will serialize token into response according to the OpenAPI spec. | ||
| return token; | ||
| } | ||
|
|
||
| // OR | ||
| // Serialize the token into response in the controller directly without describing it | ||
| // with OpenAPI spec | ||
| @post('/loginWithoutOAI/basicAuth') | ||
| @authenticate('basicAuth') | ||
| basicAuthLoginReturningJWTToken() { | ||
| await token = JWTtokenService.generateToken(this.userProfile); | ||
| // It's on users to serialize the token into the response. | ||
| await writeTokenToResponse(); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ```ts | ||
| class UserOrdersController { | ||
| @get('Users/me/orders', ...APISpec) | ||
| @authenticate('jwt') | ||
| getOrders() { | ||
| // The `userProfile` is set in the authentication action | ||
| // and get injected in the controller constructor | ||
| const id = this.userProfile.id; | ||
| await this.userRepo(id).orders(); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Other auth strategies like oauth2 will be determined in another story. | ||
|
|
||
| - /loginWithFB | ||
|
|
||
| ```ts | ||
| class UserController { | ||
| @post('/loginWithFB', APISpec) | ||
| @authenticate('oath2.fb', {session: false}) | ||
| loginWithFB() {} | ||
| } | ||
| ``` | ||
|
|
||
| - /loginWithGoogle | ||
|
|
||
| ```ts | ||
| class UserController { | ||
| @post('/loginWithGoogle', APISpec) | ||
| @authenticate('oath2.google', {session: true}) | ||
| loginWithGoogle() {} | ||
| } | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| You could find the `AuthenticationStrategy` interface in file | ||
| [authentication-strategy.md](./docs/authentication-strategy.md) | ||
|
|
||
| ```ts | ||
| import {Request} from '@loopback/rest'; | ||
|
|
||
| interface BasicAuthOptions = { | ||
| // Define it as anyobject in the pseudo code | ||
| [property: string]: any; | ||
| }; | ||
|
|
||
| class BasicAuthenticationStrategy implements AuthenticationStrategy { | ||
| options: object; | ||
| constructor( | ||
| @inject(AUTHENTICATION_BINDINGS.USER_SERVICE) userService: UserService, | ||
| @inject(AUTHENTICATION_BINDINGS.BASIC_AUTH_OPTIONS) options?: BasicAuthOptions, | ||
| ) {} | ||
|
|
||
| authenticate(request: Request, options: BasicAuthOptions): Promise<UserProfile | undefined> { | ||
| // override the global set options with the one passed from the caller | ||
| options = options || this.options; | ||
| // extract the username and password from request | ||
| const credentials = await this.extractCredentials(request); | ||
| // `verifyCredentials` throws error accordingly: user doesn't exist OR invalid credentials | ||
| const user = await userService.verifyCredentials(credentials); | ||
| return await userService.convertToUserProfile(user); | ||
| } | ||
|
|
||
| extractCredentials(request): Promise<Credentials> { | ||
| // code to extract username and password from request header | ||
| } | ||
| } | ||
| ``` |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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