Create partial login cookie for External Authentication
Recently I've implemented 2FA for my WebAPI using IdentityServer3. Everything works as expected if the login is made locally (using the IUserService
).
Now, I want to be able to do this login by issuing a partial login cookie. This means that I have an API method (POST) that will let the users do a partial login without entering the main page.
To issue the authentication cookie this is what I do (Based on IdentityServer3 Extensions method):
_owinContext.Environment.IssueLoginCookie(new AuthenticatedLogin
{
IdentityProvider = Constants.ExternalAuthenticationType,
Subject = userId,
Name = identityId,
Claims = new[] { new Claim(ClaimTypes.NameIdentifier, identityId) },
AuthenticationMethod = Constants.AuthenticationMethods.TwoFactorAuthentication
});
After this, I redirect the user back to the login page and he fully logs into the application bypassing the 2FA step. I was hoping that this would make the user partially logged in but instead, it fully logged in the user.
Note: The way I have Two Factor implemented is based on the AuthenticateLocalAsync
method from the IUserService
. Here I update the AuthenticateResult
to use the constructor with the redirect path. The API method does not call the IUserService
. It simply issues the login cookie.
Edit: So after checking the IdentityServer3 internal implementation, I'm now able to make the user go through the 2FA screens. The problem now is when the partial login is successful (authentication codes match) I redirect the user to the resume URL and it leads to a 500 error page (no exception thrown or logs shown). If this happens with a normal page login everything works.
Handle the user post request to login:
var messageId = clientIdentifier;
var claims = new List<Claim>();
(...)
var authenticationContext = new ExternalAuthenticationContext
{
ExternalIdentity = new ExternalIdentity() { Provider = "API", Claims = claims },
};
await _userService.AuthenticateExternalAsync(authenticationContext);
var authResult = authenticationContext.AuthenticateResult;
var ctx = new PostAuthenticationContext
{
AuthenticateResult = authResult
};
var id = authResult.User.Identities.FirstOrDefault();
var props = new AuthenticationProperties();
var resumeId = CryptoRandom.CreateUniqueId();
var resumeLoginUrl = _owinContext.GetPartialLoginResumeUrl(resumeId);
var resumeLoginClaim = new Claim(Constants.ClaimTypes.PartialLoginReturnUrl, resumeLoginUrl);
id.AddClaim(resumeLoginClaim);
id.AddClaim(new Claim(GetClaimTypeForResumeId(resumeId), messageId));
// add url to start login process over again (which re-triggers preauthenticate)
var restartUrl = _owinContext.GetPartialLoginRestartUrl(messageId);
id.AddClaim(new Claim(Constants.ClaimTypes.PartialLoginRestartUrl, restartUrl));
_owinContext.Authentication.SignIn(props, id);
// Sends the user to the 2FA pages (where he needs to insert the validation code).
// At this point the user is successfuly partially logged in.
var redirectUrl = GetRedirectUrl(authResult);
return Redirect(redirectUrl);
After inserting the 2FA code the user should be fully loggge in after entering the resume URL:
if (isAuthCodeValid)
{
var resumeUrl = await owinContext.Environment.GetPartialLoginResumeUrlAsync();
// Redirects the user to resume URL. This is not working if authentication is done by API but is working with normal local authentication.
// With API it redirects to a page which eventually will have 500 error (no logs or exceptions being shown)
return Redirect(resumeUrl);
}
Did you guys ever try to do something like this or is this even possible?
Solution 1:
You can inject the OwinEnvironmentService into your user service's ctor and then wrap it with an OwinContext.
Please have a look act following link. https://identityserver.github.io/Documentation/docsv2/advanced/owin.html
Solution 2:
It seems you're getting authentication an Authorization confused. You're cookie is meant for authentication. e.g Is that user who they say they are. The roles are used to prevent authorization to a certain path.
It's unclear what 500 error you're receiving, but I think it might just be useful for everyone if I break down the MFA work flow.
In your api create a Required Claim. (Note: you can use a role instead) e.g
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthorization(options =>
{
options.AddPolicy("Default",
policy => policy.RequireClaim("API.Access"));
});
}
Now this will implicitly prevent people logging in without the role. Now during login you need to allow access to the users controller. So decorate the Authentication controller with the AllowAnonymous, you can do this on the specific methods or the controller, for simplicity, I'll put it on the controller
[AllowAnonymous]
public class AuthenticationController : ControllerBase {
}
If a user requires 2FA, then you can "Log them in", but don't give them a claim for API.Access (Ideally, you should create a policy for MFA and require that on the controller but for simplicity, I'll let you figure that out)
Upon successful MFA, give them the claim for Api.Access, and not they have access e.g
//Pseudo Code
public async Task<IIdentityUser> VerifyMFA(string challenge, string response)
{
var challenge = EncryptionTools.DecryptChallenge(challenge);
challenge.VerifyOrThrowAuthorizationError(response);
var user = this.GetUserFromService();
await user.AddClaimsAsync("API.Access");
return user;
}