I have a method to auth the user and create a token with expiration time, but if the token expired, the user cannot use the data. How to handle this?
This is my method:
[AllowAnonymous]
[HttpPost]
[Route("api/token")]
public IActionResult Post([FromBody]Personal personal)
{
string funcionID = "";
if (ModelState.IsValid)
{
var userId = GetUser(personal);
if (!userId.HasValue)
{
return Unauthorized();
}
else if (userId.Equals(2)) {
return StatusCode(404, "Vuelve a ingresar tu contraseƱa");
}
List<Claim> claims = new List<Claim>();
foreach (var funcion in Funcion) {
claims.Add(new Claim(ClaimTypes.Role, funcion.FuncionID.ToString()));
}
claims.Add(new Claim(JwtRegisteredClaimNames.Email, personal.CorreoE));
claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()));
var sesionExpira = new DatabaseConfig();
_configuration.GetSection("Database").Bind(sesionExpira);
var token = new JwtSecurityToken
(
issuer: _configuration["Issuer"],
audience: _configuration["Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(sesionExpira.Sesion),
notBefore: DateTime.UtcNow,
signingCredentials: new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["SigningKey"])),
SecurityAlgorithms.HmacSha256)
);
var token_email = token.Claims.Where(w => w.Type == "email").Select(s => s.Value).FirstOrDefault();
var token_rol = claims.Where(x => x.Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role").Select(s => s.Value).FirstOrDefault();
var nombre = _context.Personal.Where(x => x.CorreoE == personal.CorreoE).Select(x => x.Nombre).FirstOrDefault();
return Ok(new { email = personal.CorreoE, token = new JwtSecurityTokenHandler().WriteToken(token), nombre = nombre, funcion = Funcion});
}
return BadRequest();
}
First, in the GetUser(Personal personal) method, that returns int, i return a number that i use to create a new token. Everything works fine, but i need some information to refresh the token if the time has expired
You can create middleware that will update the token. If you move your token creation logic to a separate service then you can do it like:
public class JwtTokenSlidingExpirationMiddleware
{
private readonly RequestDelegate next;
private readonly ITokenCreationService tokenCreationService;
public JwtTokenSlidingExpirationMiddleware(RequestDelegate next, ITokenCreationService tokenCreationService)
{
this.next = next;
this.tokenCreationService= tokenCreationService;
}
public Task Invoke(HttpContext context)
{
// Preflight check 1: did the request come with a token?
var authorization = context.Request.Headers["Authorization"].FirstOrDefault();
if (authorization == null || !authorization.ToLower().StartsWith("bearer") || string.IsNullOrWhiteSpace(authorization.Substring(6)))
{
// No token on the request
return next(context);
}
// Preflight check 2: did that token pass authentication?
var claimsPrincipal = context.Request.HttpContext.User;
if (claimsPrincipal == null || !claimsPrincipal.Identity.IsAuthenticated)
{
// Not an authorized request
return next(context);
}
// Extract the claims and put them into a new JWT
context.Response.Headers.Add("Set-Authorization", tokenCreationService.CreateToken(claimsPrincipal.Claims));
// Call the next delegate/middleware in the pipeline
return next(context);
}
}
And register it in Startup.cs:
public void Configure(IApplicationBuilder app)
{
...
app.UseMiddleware<JwtTokenSlidingExpirationMiddleware>();
...
}
I did something similiar to an old application using the method RefreshTokenAsync from IdentityModel.
You could try something like this when the User gets an unauthorized:
var identityService = await DiscoveryClient.GetAsync("http://localhost:5000");
// request token
var tokenClient = new TokenClient(identityService.TokenEndpoint, "client", "secret");
var tokenResponse = await tokenClient.RequestRefreshTokenAsync(refreshToken);
return Ok(new { success = true, tokenResponse = tokenResponse });
Source: https://github.com/IdentityModel/IdentityModel.OidcClient.Samples/issues/4
EDIT: I have edited my original answer to provide a more clear and better answer according to the rules.
Related
I have been trying to get this working now for quite a some time but can't figure out how to do it properly. I am able to implement Rememeber Me with LocalStorage. However I want to implement Remember Me functionality with JWT using cookie where I would be able to set expiration time. I think I have messed up the login logic? Can somebody point out what is wrong here?
I can also add other parts from my application if necessary.
AuthorizeController.cs:
[HttpPost]
public async Task<IActionResult> Login([FromBody] LoginModel login)
{
ApplicationUser user = await this.SignInManager.UserManager.FindByEmailAsync(login.Email);
if (user == null)
{
List<string> errors = new List<string>();
errors.Add("No such user has been found.");
return BadRequest(new LoginResult
{
Successful = false,
Errors = errors,
});
}
bool emailConfirmed = await this.UserManager.IsEmailConfirmedAsync(user);
if (!emailConfirmed)
{
List<string> errors = new List<string>();
errors.Add("Email not confirmed.");
return BadRequest(new LoginResult
{
Successful = false,
Errors = errors,
});
}
Microsoft.AspNetCore.Identity.SignInResult result =
await this.SignInManager.PasswordSignInAsync(login.Email, login.Password, login.RememberMe, false);
if (!result.Succeeded)
{
List<string> errors = new List<string>();
errors.Add("Email and password are invalid.");
return BadRequest(new LoginResult
{
Successful = false,
Errors = errors,
});
}
IList<string> roles = await this.SignInManager.UserManager.GetRolesAsync(user);
List<Claim> claims = new List<Claim>
{
new Claim(ClaimTypes.Name, login.Email)
};
ClaimsIdentity identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
AuthenticationProperties props = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTime.UtcNow.AddMonths(1)
};
// to register the cookie to the browser
this.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, props).Wait();
foreach (string role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.Configuration["JwtSecurityKey"]));
SigningCredentials creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
DateTime expiry = DateTime.Now.AddDays(Convert.ToInt32(this.Configuration["JwtExpiryInDays"]));
JwtSecurityToken token = new JwtSecurityToken(
this.Configuration["JwtIssuer"],
this.Configuration["JwtAudience"],
claims,
expires: expiry,
signingCredentials: creds
);
return Ok(new LoginResult
{
Successful = true,
Token = new JwtSecurityTokenHandler().WriteToken(token),
});
}
Startup.cs:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = Configuration["JwtIssuer"],
ValidAudience = Configuration["JwtAudience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtSecurityKey"]))
};
})
.AddCookie(options =>
{
options.Cookie.Name = "MySpecialCookie";
options.LoginPath = "/login";
//options.LogoutPath = "/Home/Index";
//options.AccessDeniedPath = "AccessDenied";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true; // the cookie would be re-issued on any request half way through the ExpireTimeSpan
//options.Cookie.Expiration = TimeSpan.FromDays(5);
options.EventsType = typeof(CookieAuthEvent);
});
services.AddScoped<CookieAuthEvent>();
services.AddAuthorization(config =>
{
config.AddPolicy(Policies.IsAdmin, Policies.IsAdminPolicy());
config.AddPolicy(Policies.IsUser, Policies.IsUserPolicy());
});
services.ConfigureApplicationCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
});
On Client side I am currently using AuthorizeApi with LocalStorage. This is working but I want to move this to Cookie.
AuthorizeApi.cs:
public async Task<LoginResult> Login(LoginModel loginModel)
{
//var stringContent = new StringContent(JsonSerializer.Serialize(LoginModel), Encoding.UTF8, "application/json");
HttpResponseMessage responseMessage = await this.HttpClient.PostAsJsonAsync("Authorize/Login", loginModel);
LoginResult result = await responseMessage.Content.ReadFromJsonAsync<LoginResult>();
if (result.Successful)
{
if (loginModel.RememberMe)
{
await this.LocalStorage.SetItemAsync("MySpecialToken", result.Token);
}
((ApiAuthenticationStateProvider)this.AuthenticationStateProvider).MarkUserAsAuthenticated(result.Token);
this.HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", result.Token);
return result;
}
return result;
}
ApiAuthenticationStateProvider.cs:
public void MarkUserAsAuthenticated(string token)
{
ClaimsPrincipal authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt"));
Task<AuthenticationState> authState = Task.FromResult(new AuthenticationState(authenticatedUser));
NotifyAuthenticationStateChanged(authState);
}
On an older project I created custom authentication that used cookies to store the session identifier.
The process was simple. Start by adding a service to house the tokens for the current user session, that will probably be sent to your API, or alternatively update your injected HttpClient right after authentication or on your MainLayout once you've retrieved the value from the cookie:
if (!httpClient.DefaultRequestHeaders.Contains("SessionID"))
httpClient.DefaultRequestHeaders.Add("SessionID", await JSRuntime.InvokeAsync<string>("MyJs.Cookies.Get", "SessionID"));
Then on your MainLayout check if the cookie has a value before you navigate to authentication. To do this, I used the following JavaScript:
window.MyJs = {
Cookies: {
Set: function (name, value, date) {
var d = new Date(date);
var expires = "expires=" + d.toUTCString();
document.cookie = name + "=" + value + ";" + expires + ";path=/";
},
Get: function (name) {
name = name + "=";
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
},
Remove: function (name) {
RadixTrie.Cookies.Set(name, "", "01 Jan 1970 00:00:00 UTC");
}
}
}
Do the check in MainLayout like this:
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (firstRender)
{
...
var sessionID = await JSRuntime.InvokeAsync<string>("MyJs.Cookies.Get", "SessionID");
if (string.IsNullOrWhiteSpace(sessionID))
NavigationManager.NavigateTo("Auth/Login", true);
...
}
}
Once you've authenticated, you can just set the cookie:
internal async Task Login() {
...
await JSRuntime.InvokeVoidAsync("MyJs.Cookies.Set", "SessionID", loginResponse.Token, loginResponse.Expires);
//{loginResponse.Token:string} {loginResponse.Expires:datetime}
...
}
EDIT:
I wasn't satisfied with this answer and took some time to consider alternatives. I don't have a fully coded solution, but a better way of implementing a token for authentication, and in your case to keep a session live, would be using Set-Cookie header in your responses from your API.
I suggest creating middleware to handle the reading and resetting of the token.
But let's start with the login. Once a user authenticated, you can update the response in your endpoint like:
[HttpPost]
public async Task<IActionResult> Login([FromBody] LoginModel login)
{
...
Response.Headers.Add("Set-Cookie", $"SessionID={Guid.NewGuid()}; Expires={DateTime.Now.AddMonths(1).ToString("dd MMM yyyy hh:mm:ss") + " UTC"}; HttpOnly"); //Valid for 1 month, HttpOnly
...
return Ok();
}
It would be a good idea to make the token and cookie string generation reusable at this point. Consider encryption too.
Thereafter, add middleware to your API and on each request read the cookie to get the token:
public async Task Invoke(HttpContext context)
{
...
context.Request.Cookies.TryGetValue("SessionID", out string sessionID);
...
await _next(context);
...
//Reset the token after each request for improved security
context.Response.Headers.Add("Set-Cookie", $"SessionID={Guid.NewGuid()}; Expires={DateTime.Now.AddMonths(1).ToString("dd MMM yyyy hh:mm:ss") + " UTC"}; HttpOnly"); //Valid for 1 month, HttpOnly
}
EDIT 2:
If a cookie is reset and there are asynchronous work being done using the API, some requests might fail if a request is made just after the cookie change before being updated. This is a very small window and should be low in chance. I will take some time in the future to test this theory and update this answer.
I want to know how I can return my generated JWT token to user. I mean I would like this token to be saved e.g. somewhere in the user's browser and sent to my Controller in every query after successfully login.
The token generating functions are already written. Everything works, unfortunately, I do not know how to pass it to the user and then pick it up in other functions / controllers
This is the login controller:
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Index(LoginDto dto)
{
if (ModelState.IsValid)
{
string token = _accountService.GenerateJwt(dto); //working
if (token != null)
{
// how can I return token there?
return RedirectToAction("LoginSuccess");
}
else
{
ViewBag.error = "Login failed";
return RedirectToAction("LoginFailed");
}
}
}
This is the function that generates the token:
public string GenerateJwt(LoginDto dto)
{
var user = _context.dnozasg2lp_vip_users.FirstOrDefault(u => u.Email == dto.Email);
if (user is null)
{
throw new BadRequestException("Invalid username or password!");
}
var result = _passwordHasher.VerifyHashedPassword(user, user.Password, dto.Password);
if (result == PasswordVerificationResult.Failed)
{
throw new BadRequestException("Invalid username or password!");
}
var claims = new List<Claim>()
{
new Claim(ClaimTypes.NameIdentifier, user.ID.ToString()),
new Claim(ClaimTypes.Name, $"{user.FirstName} {user.LastName}"),
new Claim(ClaimTypes.Email, $"{user.Email}")
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_authenticationSettings.JwtKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var expiration = DateTime.Now.AddDays(_authenticationSettings.JwtExpireDays);
var token = new JwtSecurityToken(_authenticationSettings.JwtIssuer,
_authenticationSettings.JwtIssuer,
claims,
expires: expiration,
signingCredentials: credentials
);
var tokenHandler = new JwtSecurityTokenHandler();
return tokenHandler.WriteToken(token);
}
And my startup config file:
// JWT Token config below
var authenticationSettings = new AuthenticationSettings();
services.AddSingleton(authenticationSettings);
Configuration.GetSection("Authentication").Bind(authenticationSettings);
services.AddAuthentication(option =>
{
option.DefaultAuthenticateScheme = "Bearer";
option.DefaultScheme = "Bearer";
option.DefaultChallengeScheme = "Bearer";
}).AddJwtBearer(cfg =>{
cfg.RequireHttpsMetadata = false;
cfg.SaveToken = true;
cfg.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidIssuer = authenticationSettings.JwtIssuer,
ValidAudience = authenticationSettings.JwtIssuer,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authenticationSettings.JwtKey)),
};
});
Like I said, the JWT token is successfully generated, but how can I send it to user and then catch it (for example in a "LoginSuccess" action) ?
I have this client flow:
1.client1 calling api1
2.api1 calling api2
I need the identity from the client 1 will pass to api2
I implemented DelegationTokenGrantValidator using IExtensionGrantValidator
I sent the original client1 token using
Parameters = new Dictionary { { "token", token } }
I followed this guide:
http://docs.identityserver.io/en/latest/topics/extension_grants.html#example-simple-delegation-using-an-extension-grant
I abling to generate token in api1 and call api2 but the identity is api1 and not client1
You need to pass the identity data in your ValidateAsync method of your custom grant:
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var userToken = context.Request.Raw.Get("token");
if (string.IsNullOrEmpty(userToken))
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var result = await _validator.ValidateAccessTokenAsync(userToken);
if (result.IsError)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var sub = result.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Subject)?.Value;
var username = result.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Name)?.Value;
var other = result.Claims.FirstOrDefault(c => c.Type == "other")?.Value;
// ...
var claims = new List<Claim>();
claims.Add(new Claim(JwtClaimTypes.Subject, sub));
claims.Add(new Claim(JwtClaimTypes.Subject, username));
claims.Add(new Claim(JwtClaimTypes.Subject, other));
// ...
context.Result = new GrantValidationResult(subject: sub, authenticationMethod: GrantType, claims: claims);
return;
}
I have a custom Authorize class that I use to invalidate the token when user requests data or anything from the server
but whenever the token expires the principle still returns IsAuthenticated as true and still calls the controllers and get data.
What I want it to do is to invalidate the token and explicitly logout the user out of the system. I couldn't find anything helpful. I can provide code of the JWT attribute/filters if needed
Update 1: Token Generation
public static string GenerateToken(User user)
{
int expireMinutes;
try
{
expireMinutes = string.IsNullOrEmpty(ConfigurationManager.AppSettings["SessionTimeInMinutes"])
? 30
: int.Parse(ConfigurationManager.AppSettings["SessionTimeInMinutes"]);
}
catch (Exception)
{
expireMinutes = 30;
}
var symmetricKey = Convert.FromBase64String(Secret);
var tokenHandler = new JwtSecurityTokenHandler();
var now = DateTime.Now;
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Email, user.Email)
,new Claim(ClaimTypes.Name, user.FirstName + " " + user.LastName)
,new Claim("uid", user.Id.ToString())
,new Claim("cid", user.ClientId.ToString())
,new Claim("rid", string.Join(",", user.Roles.Select(r => r.RoleId).ToList()))
}),
Expires = now.AddMinutes(Convert.ToInt32(expireMinutes)),
IssuedAt = now,
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(symmetricKey), SecurityAlgorithms.HmacSha256Signature)
};
var stoken = tokenHandler.CreateToken(tokenDescriptor);
var token = tokenHandler.WriteToken(stoken);
return token;
}
Server side Authorization Token
public async Task AuthenticateAsync(
HttpAuthenticationContext context, CancellationToken cancellationToken)
{
var excludedList = new List<string>();
excludedList.Add("/api/Security/IsMustChangePassword");
excludedList.Add("/api/Security/IsTwoFactorEnabled");
if (!excludedList.Contains(context.ActionContext.Request.RequestUri.LocalPath))
{
var request = context.Request;
var authorization = request.Headers.Authorization;
if (authorization == null || authorization.Scheme != "Token")
{
return;
}
if (string.IsNullOrEmpty(authorization.Parameter))
{
context.ErrorResult = new AuthenticationFailureResult("Missing Jwt Token", request);
return;
}
//{
// context.ErrorResult = new AuthenticationFailureResult("Invalid token", request);
// return;
//}
var token = authorization.Parameter;
var principal = await AuthenticateJwtToken(token).ConfigureAwait(true);
var userId = int.Parse(new JwtManager().GetUserIdFromToken(token));
var accountManager = new AccountManager();
var user = accountManager.GetUserDetails(userId);
var newToken = JwtManager.GenerateToken(user);
if (principal == null)
context.ErrorResult = new AuthenticationFailureResult("Invalid token", request);
else
context.Principal = principal;
if (principal.Identity.IsAuthenticated)
{
var expiryDate = JwtManager.GetSecurityToken(token).ValidTo.ToLocalTime();
if ((DateTime.Now - expiryDate).TotalSeconds > 0)
{
context.Request.Headers.Authorization = null;
context.Request.RequestUri = null;
}
else
{
var authorize = new AuthenticationHeaderValue("token", newToken);
context.Request.Headers.Authorization = authorize;
context.ActionContext.Request.Headers.Authorization = authorization;
}
}
}
}
private static bool ValidateToken(string token, out string username, out
string passwordHash)
{
username = null;
passwordHash = null;
try
{
var principle = JwtManager.GetPrincipal(token);
var identity = principle.Identity as ClaimsIdentity;
if (identity == null)
return false;
if (!identity.IsAuthenticated)
return false;
var usernameClaim = identity.FindFirst(ClaimTypes.Name);
var passwordClaim = identity.FindFirst(ClaimTypes.Hash);
username = usernameClaim?.Value;
passwordHash = passwordClaim?.Value;
return !string.IsNullOrEmpty(username);
var user = identity.FindFirst(username);
return (user != null);
//return (user != null && user.PasswordHash == passwordHash);
}
catch (NullReferenceException)
{
return false;
}
}
protected Task<IPrincipal> AuthenticateJwtToken(string token)
{
string username;
string passwordHash;
if (!ValidateToken(token, out username, out passwordHash))
return Task.FromResult<IPrincipal>(null);
// based on username to get more information from database in order to build local identity
var claims = new List<Claim> { new Claim(ClaimTypes.Name, username) };
//claims.Add(new Claim(ClaimTypes.Hash, passwordHash));
// Add more claims if needed: Roles, ...
var identity = new ClaimsIdentity(claims, "Jwt");
IPrincipal user = new ClaimsPrincipal(identity);
return Task.FromResult(user);
}
public Task ChallengeAsync(HttpAuthenticationChallengeContext context,
CancellationToken cancellationToken)
{
var authorization = context.Request.Headers.Authorization;
var excludedList =
new List<string> {
"/api/Security/IsMustChangePassword",
"/api/Security/IsTwoFactorEnabled" };
if (context.Request.Headers.Authorization != null)
{
if (!excludedList.Contains(context.ActionContext.Request.RequestUri.LocalPath))
{
var token = context.Request.Headers.Authorization.Parameter;
var userId = int.Parse(new JwtManager().GetUserIdFromToken(token));
var accountManager = new AccountManager();
var user = accountManager.GetUserDetails(userId);
var newToken = JwtManager.GenerateToken(user);
var expiryDate = JwtManager.GetSecurityToken(token).ValidTo.ToLocalTime();
if ((DateTime.Now - expiryDate).TotalSeconds > 0)
{
context.Request.Headers.Authorization = null;
context.Request.RequestUri = null;
context.Request.RequestUri = new Uri("/Login");
}
else
{
var authorize = new AuthenticationHeaderValue("token", newToken);
context.Request.Headers.Authorization = authorize;
context.ActionContext.Request.Headers.Authorization = authorization;
}
Challenge(context);
}
}
else
{
var req = context.Request.RequestUri;
var url = context.Request.RequestUri = new Uri($"http://{req.Host}:{req.Port}/api/Security/login");
context.Request.RequestUri = url;
context.Request.Headers.Authorization = null;
context.Result= new AuthenticationFailureResult(string.Empty, new HttpRequestMessage());
}
return Task.FromResult(0);
}
First of all: A JWT is just a client-token the client can manipulate. There is no absolute security here. The JWT is secured by the symmetirc key but cannot be invalidated itself. A valid token remains valid until it expires. It's a flaw of JWT (as #TimBiegeleisen pointed out in the comments) that a token itself cannot be easily invalidated.
If a user works too long and gets logged out automatically, your JWT has expired and everthing's good. There's no hassle as it runs out naturally and you have no need to act.
To invalidate a token you need to create a new token with Expires = now.AddMinutes(-1). That way the next time you check the JWT next time you see it has expired.
The case that a user may save a JWT and use it even after being logged out can only be come by with blacklisting JWTs or maintaining some other kind of serverside session aditionally (which doesn't work i.e. with a stateless webservice).
EDIT: Removed wrong information and added a additional way to invalidate a JWT(blacklisting)
You can simply cash a the token you want to revoke
and then make your authentication part compare request that has the revoked
with the one in the cash and refuse the request based on that
until the token expires , the cash should know the remining time for the token and cash it for that long
In ASP.NET MVC app, I am trying to implement authentication against external OIDC service. For my testing I am using IdentityServer3 (https://identityserver.github.io/Documentation/) and public OIDC demo server: https://mitreid.org/
I cloned this sample from GitHub: https://github.com/IdentityServer/IdentityServer3.Samples/tree/master/source/MVC%20Authentication
Then added the following code to register the public OIDC server as external login provider:
private void ConfigureIdentityProviders(IAppBuilder app, string signInAsType)
{
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
AuthenticationType = "<AuthTypeName>",
Authority = "https://mitreid.org/",
Caption = "MIT Test Server",
ClientId = "<Client Id>",
ClientSecret = "<Client Secret>",
RedirectUri = "https://localhost:44319/", //NOT SURE WHAT TO PUT HERE
ResponseType = "code",
Scope = "openid email profile",
SignInAsAuthenticationType = signInAsType
});
}
The code works, i get the option to login via external OIDC server. The browser redirects to the external server login page and when login and password is entered, the consent page is shown. However, after the browser navigates back to https://localhost:44319/ the user is not authenticated - User.Identity.IsAuthenticated is false.
Question: What should be correct value of RedirectUri property? Does OpenIdConnect middleware have capability to parse the authantication info passed in from external server or it must be coded manually? Is there any sample code how to do this?
I was studying the code and debugging quite a few hours (I am new to this) and I learned that:
This problem is related to OpenIdConnect OWIN Middleware implemented by Microsoft (https://github.com/aspnet/AspNetKatana/tree/dev/src/Microsoft.Owin.Security.OpenIdConnect).
The middleware from Microsoft expect that the OIDC server sends the message using HTTP POST, but the MIT server does HTTP GET
The middleware from Microsoft expect that there is id token along with code in the message obtained from OIDC server, but the MIT server sends only the code.
Looks like the RedirectUri can be any path under /identity because the middleware method AuthenticateCoreAsync() is hit on every request and it does compare request path to configured Options.CallbackPath (which is set from RedirectURI)
So I just had to implement the standard authorization code flow - exchange the code for id token, get claims, create authentication ticket and redirect to IdentityServer /identity/callback endpoint. When I've done this, everything started working. IdentityServer is awesome!
I inherited new set of classes from OpenIdConnect middleware and did override some methods. The key method is async Task<AuthenticationTicket> AuthenticateCoreAsync() in OpenIdConnectAuthenticationHandler. I pasted the code below in case it would help to someone.
public class CustomOidcHandler : OpenIdConnectAuthenticationHandler
{
private const string HandledResponse = "HandledResponse";
private readonly ILogger _logger;
private OpenIdConnectConfiguration _configuration;
public CustomOidcHandler(ILogger logger) : base(logger)
{
_logger = logger;
}
/// <summary>
/// Invoked to process incoming authentication messages.
/// </summary>
/// <returns>An <see cref="AuthenticationTicket"/> if successful.</returns>
protected override async Task<AuthenticationTicket> AuthenticateCoreAsync()
{
// Allow login to be constrained to a specific path. Need to make this runtime configurable.
if (Options.CallbackPath.HasValue && Options.CallbackPath != (Request.PathBase + Request.Path))
return null;
OpenIdConnectMessage openIdConnectMessage = null;
if (string.Equals(Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
openIdConnectMessage = new OpenIdConnectMessage(Request.Query);
if (openIdConnectMessage == null)
return null;
ExceptionDispatchInfo authFailedEx = null;
try
{
return await CreateAuthenticationTicket(openIdConnectMessage).ConfigureAwait(false);
}
catch (Exception exception)
{
// We can't await inside a catch block, capture and handle outside.
authFailedEx = ExceptionDispatchInfo.Capture(exception);
}
if (authFailedEx != null)
{
_logger.WriteError("Exception occurred while processing message: ", authFailedEx.SourceException);
// Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the notification.
if (Options.RefreshOnIssuerKeyNotFound && authFailedEx.SourceException.GetType() == typeof(SecurityTokenSignatureKeyNotFoundException))
Options.ConfigurationManager.RequestRefresh();
var authenticationFailedNotification = new AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context, Options)
{
ProtocolMessage = openIdConnectMessage,
Exception = authFailedEx.SourceException
};
await Options.Notifications.AuthenticationFailed(authenticationFailedNotification).ConfigureAwait(false);
if (authenticationFailedNotification.HandledResponse)
return GetHandledResponseTicket();
if (authenticationFailedNotification.Skipped)
return null;
authFailedEx.Throw();
}
return null;
}
private async Task<AuthenticationTicket> CreateAuthenticationTicket(OpenIdConnectMessage openIdConnectMessage)
{
var messageReceivedNotification =
new MessageReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context, Options)
{
ProtocolMessage = openIdConnectMessage
};
await Options.Notifications.MessageReceived(messageReceivedNotification).ConfigureAwait(false);
if (messageReceivedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (messageReceivedNotification.Skipped)
{
return null;
}
// runtime always adds state, if we don't find it OR we failed to 'unprotect' it this is not a message we
// should process.
AuthenticationProperties properties = GetPropertiesFromState(openIdConnectMessage.State);
if (properties == null)
{
_logger.WriteWarning("The state field is missing or invalid.");
return null;
}
// devs will need to hook AuthenticationFailedNotification to avoid having 'raw' runtime errors displayed to users.
if (!string.IsNullOrWhiteSpace(openIdConnectMessage.Error))
{
throw new OpenIdConnectProtocolException(
string.Format(CultureInfo.InvariantCulture,
openIdConnectMessage.Error,
"Exception_OpenIdConnectMessageError", openIdConnectMessage.ErrorDescription ?? string.Empty,
openIdConnectMessage.ErrorUri ?? string.Empty));
}
// tokens.Item1 contains id token
// tokens.Item2 contains access token
Tuple<string, string> tokens = await GetTokens(openIdConnectMessage.Code, Options)
.ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(openIdConnectMessage.IdToken))
openIdConnectMessage.IdToken = tokens.Item1;
var securityTokenReceivedNotification =
new SecurityTokenReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context,
Options)
{
ProtocolMessage = openIdConnectMessage,
};
await Options.Notifications.SecurityTokenReceived(securityTokenReceivedNotification).ConfigureAwait(false);
if (securityTokenReceivedNotification.HandledResponse)
return GetHandledResponseTicket();
if (securityTokenReceivedNotification.Skipped)
return null;
if (_configuration == null)
_configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.Request.CallCancelled)
.ConfigureAwait(false);
// Copy and augment to avoid cross request race conditions for updated configurations.
TokenValidationParameters tvp = Options.TokenValidationParameters.Clone();
IEnumerable<string> issuers = new[] {_configuration.Issuer};
tvp.ValidIssuers = tvp.ValidIssuers?.Concat(issuers) ?? issuers;
tvp.IssuerSigningTokens = tvp.IssuerSigningTokens?.Concat(_configuration.SigningTokens) ?? _configuration.SigningTokens;
SecurityToken validatedToken;
ClaimsPrincipal principal =
Options.SecurityTokenHandlers.ValidateToken(openIdConnectMessage.IdToken, tvp, out validatedToken);
ClaimsIdentity claimsIdentity = principal.Identity as ClaimsIdentity;
var claims = await GetClaims(tokens.Item2).ConfigureAwait(false);
AddClaim(claims, claimsIdentity, "sub", ClaimTypes.NameIdentifier, Options.AuthenticationType);
AddClaim(claims, claimsIdentity, "given_name", ClaimTypes.GivenName);
AddClaim(claims, claimsIdentity, "family_name", ClaimTypes.Surname);
AddClaim(claims, claimsIdentity, "preferred_username", ClaimTypes.Name);
AddClaim(claims, claimsIdentity, "email", ClaimTypes.Email);
// claims principal could have changed claim values, use bits received on wire for validation.
JwtSecurityToken jwt = validatedToken as JwtSecurityToken;
AuthenticationTicket ticket = new AuthenticationTicket(claimsIdentity, properties);
if (Options.ProtocolValidator.RequireNonce)
{
if (String.IsNullOrWhiteSpace(openIdConnectMessage.Nonce))
openIdConnectMessage.Nonce = jwt.Payload.Nonce;
// deletes the nonce cookie
RetrieveNonce(openIdConnectMessage);
}
// remember 'session_state' and 'check_session_iframe'
if (!string.IsNullOrWhiteSpace(openIdConnectMessage.SessionState))
ticket.Properties.Dictionary[OpenIdConnectSessionProperties.SessionState] = openIdConnectMessage.SessionState;
if (!string.IsNullOrWhiteSpace(_configuration.CheckSessionIframe))
ticket.Properties.Dictionary[OpenIdConnectSessionProperties.CheckSessionIFrame] =
_configuration.CheckSessionIframe;
if (Options.UseTokenLifetime)
{
// Override any session persistence to match the token lifetime.
DateTime issued = jwt.ValidFrom;
if (issued != DateTime.MinValue)
{
ticket.Properties.IssuedUtc = issued.ToUniversalTime();
}
DateTime expires = jwt.ValidTo;
if (expires != DateTime.MinValue)
{
ticket.Properties.ExpiresUtc = expires.ToUniversalTime();
}
ticket.Properties.AllowRefresh = false;
}
var securityTokenValidatedNotification =
new SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context,
Options)
{
AuthenticationTicket = ticket,
ProtocolMessage = openIdConnectMessage,
};
await Options.Notifications.SecurityTokenValidated(securityTokenValidatedNotification).ConfigureAwait(false);
if (securityTokenValidatedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (securityTokenValidatedNotification.Skipped)
{
return null;
}
// Flow possible changes
ticket = securityTokenValidatedNotification.AuthenticationTicket;
// there is no hash of the code (c_hash) in the jwt obtained from the server
// I don't know how to perform the validation using ProtocolValidator without the hash
// that is why the code below is commented
//var protocolValidationContext = new OpenIdConnectProtocolValidationContext
//{
// AuthorizationCode = openIdConnectMessage.Code,
// Nonce = nonce
//};
//Options.ProtocolValidator.Validate(jwt, protocolValidationContext);
if (openIdConnectMessage.Code != null)
{
var authorizationCodeReceivedNotification = new AuthorizationCodeReceivedNotification(Context, Options)
{
AuthenticationTicket = ticket,
Code = openIdConnectMessage.Code,
JwtSecurityToken = jwt,
ProtocolMessage = openIdConnectMessage,
RedirectUri =
ticket.Properties.Dictionary.ContainsKey(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey)
? ticket.Properties.Dictionary[OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey]
: string.Empty,
};
await Options.Notifications.AuthorizationCodeReceived(authorizationCodeReceivedNotification)
.ConfigureAwait(false);
if (authorizationCodeReceivedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (authorizationCodeReceivedNotification.Skipped)
{
return null;
}
// Flow possible changes
ticket = authorizationCodeReceivedNotification.AuthenticationTicket;
}
return ticket;
}
private static void AddClaim(IEnumerable<Tuple<string, string>> claims, ClaimsIdentity claimsIdentity, string key, string claimType, string issuer = null)
{
string subject = claims
.Where(it => it.Item1 == key)
.Select(x => x.Item2).SingleOrDefault();
if (!string.IsNullOrWhiteSpace(subject))
claimsIdentity.AddClaim(
new System.Security.Claims.Claim(claimType, subject, ClaimValueTypes.String, issuer));
}
private async Task<Tuple<string, string>> GetTokens(string authorizationCode, OpenIdConnectAuthenticationOptions options)
{
// exchange authorization code at authorization server for an access and refresh token
Dictionary<string, string> post = null;
post = new Dictionary<string, string>
{
{"client_id", options.ClientId},
{"client_secret", options.ClientSecret},
{"grant_type", "authorization_code"},
{"code", authorizationCode},
{"redirect_uri", options.RedirectUri}
};
string content;
using (var client = new HttpClient())
{
var postContent = new FormUrlEncodedContent(post);
var response = await client.PostAsync(options.Authority.TrimEnd('/') + "/token", postContent)
.ConfigureAwait(false);
content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
// received tokens from authorization server
var json = JObject.Parse(content);
var accessToken = json["access_token"].ToString();
string idToken = null;
if (json["id_token"] != null)
idToken = json["id_token"].ToString();
return new Tuple<string, string>(idToken, accessToken);
}
private async Task<IEnumerable<Tuple<string, string>>> GetClaims(string accessToken)
{
string userInfoEndpoint = Options.Authority.TrimEnd('/') + "/userinfo";
var userInfoClient = new UserInfoClient(new Uri(userInfoEndpoint), accessToken);
var userInfoResponse = await userInfoClient.GetAsync().ConfigureAwait(false);
var claims = userInfoResponse.Claims;
return claims;
}
private static AuthenticationTicket GetHandledResponseTicket()
{
return new AuthenticationTicket(null, new AuthenticationProperties(new Dictionary<string, string>() { { HandledResponse, "true" } }));
}
private AuthenticationProperties GetPropertiesFromState(string state)
{
// assume a well formed query string: <a=b&>OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey=kasjd;fljasldkjflksdj<&c=d>
int startIndex = 0;
if (string.IsNullOrWhiteSpace(state) || (startIndex = state.IndexOf("OpenIdConnect.AuthenticationProperties", StringComparison.Ordinal)) == -1)
{
return null;
}
int authenticationIndex = startIndex + "OpenIdConnect.AuthenticationProperties".Length;
if (authenticationIndex == -1 || authenticationIndex == state.Length || state[authenticationIndex] != '=')
{
return null;
}
// scan rest of string looking for '&'
authenticationIndex++;
int endIndex = state.Substring(authenticationIndex, state.Length - authenticationIndex).IndexOf("&", StringComparison.Ordinal);
// -1 => no other parameters are after the AuthenticationPropertiesKey
if (endIndex == -1)
{
return Options.StateDataFormat.Unprotect(Uri.UnescapeDataString(state.Substring(authenticationIndex).Replace('+', ' ')));
}
else
{
return Options.StateDataFormat.Unprotect(Uri.UnescapeDataString(state.Substring(authenticationIndex, endIndex).Replace('+', ' ')));
}
}
}
public static class CustomOidcAuthenticationExtensions
{
/// <summary>
/// Adds the <see cref="OpenIdConnectAuthenticationMiddleware"/> into the OWIN runtime.
/// </summary>
/// <param name="app">The <see cref="IAppBuilder"/> passed to the configuration method</param>
/// <param name="openIdConnectOptions">A <see cref="OpenIdConnectAuthenticationOptions"/> contains settings for obtaining identities using the OpenIdConnect protocol.</param>
/// <returns>The updated <see cref="IAppBuilder"/></returns>
public static IAppBuilder UseCustomOidcAuthentication(this IAppBuilder app, OpenIdConnectAuthenticationOptions openIdConnectOptions)
{
if (app == null)
throw new ArgumentNullException(nameof(app));
if (openIdConnectOptions == null)
throw new ArgumentNullException(nameof(openIdConnectOptions));
return app.Use(typeof(CustomOidcMiddleware), app, openIdConnectOptions);
}
}
and in Startup.cs
public class Startup
{
....
public void Configuration(IAppBuilder app)
{
....
private void ConfigureIdentityProviders(IAppBuilder app, string signInAsType)
{
app.UseCustomOidcAuthentication(
new OpenIdConnectAuthenticationOptions
{
AuthenticationType = "<name>",
Authority = "<OIDC server url>",
Caption = "<caption>",
ClientId = "<client id>",
ClientSecret = "<client secret>",
// might be https://localhost:44319/identity/<anything>
RedirectUri = "https://localhost:44319/identity/signin-customoidc",
ResponseType = "code",
Scope = "openid email profile address phone",
SignInAsAuthenticationType = signInAsType
}
);
}
....
}
....
}