-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Conversation
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.
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?
// 1. Try to find current user | ||
// 2. If found, return it | ||
// 3. If not found, throw 401: | ||
verify(request) { |
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.
Missing complete signature
return tokenAndUser.userProfile; | ||
}; | ||
|
||
ensureLoggedIn(request) { |
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.
Missing complete signature
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:
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(); |
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.
Maybe we don't need to differentiate between login
and verify
actions. Please note Passport
just uses different strategies, for example:
- Local strategy for username/password based login
- BearTokenStrategy for token verification
Both strategies implement authenticate
method.
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.
I see, I was coupling them into one strategy.
class UserController { | ||
@post('/loginWithFB', ...APISpec) | ||
@authenticate('oath2.fb', {action: 'login', session: false}) | ||
loginWithFB() {} |
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.
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.
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.
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.
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.
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" /> |
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.
👍
@jannyHou Please run |
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.
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> |
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.
Since the login service is transport agnostic now, I think it should be accepting credentials: Credentials
instead of request: Request
?
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.
👍 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>; |
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.
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);
}
}
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.
@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
- consistent with endpoints other than
- cons
- lose the convenience provided by REST decorator(e.g. describing parameters using
maxLength
)
- lose the convenience provided by REST decorator(e.g. describing parameters using
-
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); | ||
} |
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.
Shouldn't we throw for the else
condition as well?
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.
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
I wish the mechanics for enabling authentication was simpler. Like flip a switch, and |
@hacksparrow Good feedback
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.
|
|
||
async action(request: Request): Promise<UserProfile | undefined> { | ||
const authStrategy = await this.getAuthStrategy(); | ||
if (!authStrategy) { |
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?
- No authentication is required
- No strategy is found to handle the authentication
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
// The resolver will read the options object from metadata, call `strategy.setOptions` | ||
options: object; | ||
authenticate(request: Request): Promise<UserProfile | undefined>; | ||
setOptions(options: object); |
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.
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.
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.
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>})` |
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.
Is action
still needed?
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.
good catch, not really
|
||
// Describe the response using OpenAPI spec | ||
@post('/loginOAI/local', RESPONSE_SPEC_FOR_JWT_LOGIN) | ||
@authenticate('basicAuth') |
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.
Do we allow a method to be authenticated by one of the strategies?
@authenticate(['basicAuth', 'localAuth']) // We allow either basic or local auth
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.
I thought "local" and "basic" auth are same, which is wrong. So the endpoint here meant to be decorated with only one strategy.
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.
We leave it for future decisions. It would be nice to support one of the many auths on the same method.
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.
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, |
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.
Maybe AUTHENTICATION_BINDINGS.USER_SERVICE
? Let's constrain the namespace to be at most one level.
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.
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)` |
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.
In the future, we can allow class-level decoration of @authenticate
.
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.
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) |
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.
Do we want to clarify that the login
method implementation can have two choices:
@authenticate + user code
- plain
user code
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.
Sounds good, would like to add #2576 (comment) here.
Co-authored-by: emonddr <[email protected]>
rebased and squashed commits |
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
loginuser serviceWhen 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 machinepackages/cli
were updatedexamples/*
were updated