I had the requirement to have my Identity-enabled MVC site with WebApi use cookies for authentication for both the site and web api. Anyone who has dealt with this probably knows that this could be vulnerable to XSS attacks as the regular login cookie could be sent to your webapi methods by visiting a malicious page.
The strange requirement to use cookies with web api is the root of the matter. Is there any way to do this safely?
I have a solution using Forms Authentication in an AuthorizationFilter (posted below) but I was hoping to leverage the features of the Identity framework such as claims and sign-out everywhere.
using System;
using System.Web.Http.Filters;
using System.Web.Http;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Security.Principal;
using System.Web;
using System.Web.Security;
using System.Collections.Generic;
namespace Filters
{
/// <summary>
/// An authentication filter that uses forms authentication cookies.
/// </summary>
/// <remarks>Use the *Cookie static methods to manipulate the cookie on the client</remarks>
public class FormsAuthenticationFilter : Attribute, IAuthenticationFilter
{
public static long Timeout { get; set; }
public static string CookieName { get; set; }
public FormsAuthenticationFilter()
{
// Default Values
FormsAuthenticationFilter.Timeout = FormsAuthentication.Timeout.Minutes;
FormsAuthenticationFilter.CookieName = "WebApi";
}
public FormsAuthenticationFilter(long Timeout, string CookieName)
{
FormsAuthenticationFilter.Timeout = Timeout;
FormsAuthenticationFilter.CookieName = CookieName;
}
public Task AuthenticateAsync(HttpAuthenticationContext context, CancellationToken cancellationToken)
{
HttpRequestMessage request = context.Request;
// Get cookie
HttpCookie cookie = HttpContext.Current.Request.Cookies[FormsAuthenticationFilter.CookieName];
//If no cookie then do nothing
if (cookie == null)
{
return Task.FromResult(0);
}
//If empty cookie then raise error
if (String.IsNullOrEmpty(cookie.Value))
{
context.ErrorResult = new AuthenticationFailureResult("Empty ticket", request);
return Task.FromResult(0);
}
//Decrypt ticket
FormsAuthenticationTicket authTicket = default(FormsAuthenticationTicket);
try
{
authTicket = FormsAuthentication.Decrypt(cookie.Value);
}
catch (Exception)
{
context.ErrorResult = new AuthenticationFailureResult("Invalid ticket", request);
return Task.FromResult(0);
}
//Check if expired
if (authTicket.Expired)
{
context.ErrorResult = new AuthenticationFailureResult("Ticket expired", request);
return Task.FromResult(0);
}
//If caching roles in userData field then extract
string[] roles = authTicket.UserData.Split(new char[] { '|' });
// Create the IIdentity instance
IIdentity id = new FormsIdentity(authTicket);
// Create the IPrinciple instance
IPrincipal principal = new GenericPrincipal(id, roles);
// Set the context user
context.Principal = principal;
// Update ticket if needed (sliding window expiration)
if ((authTicket.Expiration - DateTime.Now).TotalMinutes < (FormsAuthenticationFilter.Timeout / 2))
{
RenewCookie(authTicket);
}
return Task.FromResult(0);
}
public Task ChallengeAsync(HttpAuthenticationChallengeContext context, CancellationToken cancellationToken)
{
//Do nothing
return Task.FromResult(0);
}
public bool AllowMultiple
{
get { return false; }
}
/// <summary>
/// Renews the cookie on the client using the specified FormsAuthenticationTicket
/// </summary>
/// <param name="OldTicket">A still-valid but aging FormsAuthenticationTicket that should be renewed</param>
/// <remarks></remarks>
protected static void RenewCookie(FormsAuthenticationTicket OldTicket)
{
HttpContext.Current.Response.Cookies.Add(GetCookie(OldTicket.Name, OldTicket.UserData));
}
/// <summary>
/// Sets the authentication cookie on the client
/// </summary>
/// <param name="UserName">The username to set the cookie for</param>
/// <remarks></remarks>
public static void SetCookie(String user, IList<string> roles)
{
HttpContext.Current.Response.Cookies.Add(GetCookie(user, string.Join("|", roles)));
}
/// <summary>
/// Removes the authentication cookie on the client
/// </summary>
/// <remarks>Cookie is removed by setting the expires property to in the past, may not work on all clients</remarks>
public static void RemoveCookie()
{
if ((HttpContext.Current.Response.Cookies[FormsAuthenticationFilter.CookieName] != null))
{
HttpContext.Current.Response.Cookies[FormsAuthenticationFilter.CookieName].Expires = DateTime.Now.AddDays(-1);
}
}
private static HttpCookie GetCookie(string UserName, string UserData)
{
//Create forms auth ticket
FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(1, UserName, DateTime.Now, DateTime.Now.AddMinutes(FormsAuthenticationFilter.Timeout), false, UserData);
//Create cookie with encrypted contents
HttpCookie cookie = new HttpCookie(FormsAuthenticationFilter.CookieName, FormsAuthentication.Encrypt(ticket));
cookie.Expires = DateTime.Now.AddMinutes(FormsAuthenticationFilter.Timeout);
//Return it
return cookie;
}
protected class AuthenticationFailureResult : IHttpActionResult
{
public AuthenticationFailureResult(string reasonPhrase, HttpRequestMessage request)
{
this.ReasonPhrase = reasonPhrase;
this.Request = request;
}
public string ReasonPhrase { get; set; }
public HttpRequestMessage Request { get; set; }
public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
{
return Task.FromResult(Execute());
}
private HttpResponseMessage Execute()
{
HttpResponseMessage response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
response.RequestMessage = Request;
response.ReasonPhrase = ReasonPhrase;
return response;
}
}
}
}
Let me preface this answer by saying this is only a solution if WebApi methods are never called via javascript from your website. This should only be used if you plan to use WebApi from other clients such as a mobile app.
The solution lies in using a separate cookie for WebApi. WebApi will reject the normal site authorization cookie and only use its own.
In your WebApiConfig class add a constant and a static method to configure the cookie middle-ware:
public const string WebApiCookieAuthenticationType = "WebApiCookie";
public static CookieAuthenticationOptions CreateCookieAuthenticationOptions()
{
return new CookieAuthenticationOptions
{
AuthenticationType = WebApiConfig.WebApiCookieAuthenticationType,
AuthenticationMode = Microsoft.Owin.Security.AuthenticationMode.Passive,
CookieName = ".AspNet.WebApiCookie",
CookiePath = "/api",
ExpireTimeSpan = TimeSpan.FromDays(14),
SlidingExpiration = true,
LoginPath = new PathString("/api/auth"),
Provider = new CookieAuthenticationProvider
{
OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
validateInterval: TimeSpan.FromMinutes(60),
regenerateIdentity: (manager, user) => manager.CreateIdentityAsync(user, WebApiConfig.WebApiCookieAuthenticationType)
),
OnApplyRedirect = ctx => {}
}
};
}
Note the AuthenticationType and AuthenticationMode, these will prevent it from interfering with normal site cookies after we register it. We do this next in your Startup class placing it just below the existing app.UseCookieAuthentication() call:
// Setup WebApi Cookie Authentication
app.UseCookieAuthentication(WebApiConfig.CreateCookieAuthenticationOptions());
We also need to configure WebApi to ignore the normal site cookies and process our special cookies as well. Do this in your WebApiConfig.Register() method:
// Ignore website auth
config.SuppressHostPrincipal();
config.Filters.Add(new HostAuthenticationFilter(WebApiCookieAuthenticationType));
Now we are setup but how do you issue the auth cookie? Create an authorization controller method in your webapi that uses the standard Identity SignInManager but with an extra step:
[AllowAnonymous]
public async Task<IHttpActionResult> Authenticate(string userName, string password)
{
// Get signIn manager
var signInManager = HttpContext.Current.Request.GetOwinContext().Get<ApplicationSignInManager>();
if (signInManager == null)
{
return BadRequest();
}
// Important!
signInManager.AuthenticationType = WebApiConfig.WebApiCookieAuthenticationType;
// Sign In
var result = await signInManager.PasswordSignInAsync(userName, password, false, shouldLockout: true);
if (result == SignInStatus.Success)
{
// Return
return Ok();
}
// Failure
return BadRequest();
}
Pay particular attention to where we set the SignInManager's AuthenticationType, this will make it use our special WebApi cookie instead of the standard site cookie.
Gotchas
One gotcha is that the default site template overrides the SignInManager's CreateUserIdentityAsync method with a hard-coded reference to DefaultAuthenticationTypes.ApplicationCookie authentication type. This will interfere with this solution. To fix it you can change the override to this:
public override Task<ClaimsIdentity> CreateUserIdentityAsync(ApplicationUser user)
{
if (this.AuthenticationType == DefaultAuthenticationTypes.ApplicationCookie)
{
return user.GenerateUserIdentityAsync((ApplicationUserManager)UserManager);
}
else
{
return base.CreateUserIdentityAsync(user);
}
}
Another gotcha is compression. If you are using the great Microsoft.AspNet.WebApi.Extensions.Compression package you will need to disable compression for web api methods using the SignInManager to log in/out as it writes the response before Identity can add the Set-Cookie header to the response.
Related
My customer want to validate token from query param like this
http://localhost/api/v1/users?token=xxxx
I can do like this:
[CustomAuthorize(Authorities = new[] { Constants.RoleGuest })]
[HTTPGet]
public async Task<IActionResult> Get(string token){
//call validate token function with token provided
//do something
}
Is there a way to implement automatic token authentication that does this for all requests except login and register?
It sucks to have to call the authentication function on every http request. Is this implementable as a custom attribute ?
This question don't mention how to implement authen and authorization. Main popurse is check something when user request to any endpoint. In this situation, it is token. It isn't same access token and refresh token
Thanks for all the help!
You can use action filter and custom attribute to implement it.
public class MyAuth : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
var actionInfo = context.ActionDescriptor as ControllerActionDescriptor;
var token = context.HttpContext.Request.Query.ContainsKey("token")
? Convert.ToString(context.HttpContext.Request.Query["token"])
: string.Empty;
var shouldStop = !IsValidToken(token, actionInfo);
if (shouldStop)
{
context.Result = new UnauthorizedResult();
}
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
private bool IsValidToken(string token, ControllerActionDescriptor actionInfo)
{
var valid = false;
var controllerName = actionInfo?.ControllerName;
var actionName = actionInfo?.ActionName;
var roles =
(actionInfo?.MethodInfo.GetCustomAttributes(typeof(CustomAuthorize), true)?.FirstOrDefault() as
CustomAuthorize).Roles;
// your token validation logic goes here
return valid;
}
}
public class CustomAuthorize : Attribute
{
public string[] Roles { get; }
public CustomAuthorize(string[] roles)
{
Roles = roles;
}
}
And in the program.cs you can register the Action filter as below
builder.Services.AddControllers(_ =>
{
_.Filters.Add(typeof(MyAuth));
});
Finally, your action method would look like below -
[CustomAuthorize(new string[]{Constants.RoleGuest})]
[HTTPGet]
public async Task<IActionResult> Get(){
// do actual work.
// this method will be only called if your validation logic pass.
}
I am using ORY Kratos for identity and my frontend SPA (React App) is authenticating against the Kratos Login Server and gets a session cookie back.
Now I want to secure my ASP.NET Core Web Api in a way, that a user can only call certain methods protected with the [Authorize] attribute when attaching a valid cookie to the request. For this, I need to validate the cookie from every incoming request. So I am looking for a way to configure Authentication and add custom logic to validate the cookie (I need to make an API call to Kratos to validate it).
The cookie I want to validate has not been issued by the ASP.NET Core App that wants to validate it.
All the samples I found so far, are also issuing the cookie on the same server but I need to validate an external one.
This is what my cookie looks like:
In the Dev Tools, I can validate that the Cookie is attached to the requests header:
This is, what I've tried so far:
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Cookie.Name = "ory_kratos_session";
options.Cookie.Path = "/";
options.Cookie.Domain = "localhost";
options.Cookie.HttpOnly = true;
options.EventsType = typeof(CustomCookieAuthenticationEvents);
});
services.AddScoped<CustomCookieAuthenticationEvents>();
// ...
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ...
app.UseAuthentication();
app.UseAuthorization();
// ...
}
public class CustomCookieAuthenticationEvents : CookieAuthenticationEvents
{
public CustomCookieAuthenticationEvents() {}
public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
{
// Never gets called
}
}
Logs:
info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[7]
Cookies was not authenticated. Failure message: Unprotect ticket failed
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
Authorization failed. These requirements were not met:
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[12]
AuthenticationScheme: Cookies was challenged.
dbug: Microsoft.AspNetCore.Server.Kestrel[9]
Connection id "0HM6IBAO4PLLL" completed keep alive response.
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
Request finished HTTP/1.1 GET https://localhost:5001/weatherforecast - - - 302 0 - 75.3183ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/1.1 GET https://localhost:5001/Account/Login?ReturnUrl=%2Fweatherforecast - -
According to the cookie authentication handler's source codes, I found it will read the cookie before goes to the CustomCookieAuthenticationEvents .
Some part of codes as below:
private async Task<AuthenticateResult> ReadCookieTicket()
{
var cookie = Options.CookieManager.GetRequestCookie(Context, Options.Cookie.Name!);
if (string.IsNullOrEmpty(cookie))
{
return AuthenticateResult.NoResult();
}
var ticket = Options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding());
if (ticket == null)
{
return AuthenticateResult.Fail("Unprotect ticket failed");
}
if (Options.SessionStore != null)
{
var claim = ticket.Principal.Claims.FirstOrDefault(c => c.Type.Equals(SessionIdClaim));
if (claim == null)
{
return AuthenticateResult.Fail("SessionId missing");
}
// Only store _sessionKey if it matches an existing session. Otherwise we'll create a new one.
ticket = await Options.SessionStore.RetrieveAsync(claim.Value);
if (ticket == null)
{
return AuthenticateResult.Fail("Identity missing in session store");
}
_sessionKey = claim.Value;
}
var currentUtc = Clock.UtcNow;
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc != null && expiresUtc.Value < currentUtc)
{
if (Options.SessionStore != null)
{
await Options.SessionStore.RemoveAsync(_sessionKey!);
}
return AuthenticateResult.Fail("Ticket expired");
}
CheckForRefresh(ticket);
// Finally we have a valid ticket
return AuthenticateResult.Success(ticket);
}
If you still want to use cookie authentication, you need to rewrite the handler. So I suggest you could write a custom AuthenticationHandler and AuthenticationSchemeOptions class like below and register the class in the startup.cs directly. Then you could use [Authorize(AuthenticationSchemes = "Test")] to set the special AuthenticationSchemes.
Codes:
public class ValidateHashAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
}
public class ValidateHashAuthenticationHandler
: AuthenticationHandler<ValidateHashAuthenticationSchemeOptions>
{
public ValidateHashAuthenticationHandler(
IOptionsMonitor<ValidateHashAuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
//TokenModel model;
// validation comes in here
if (!Request.Headers.ContainsKey("X-Base-Token"))
{
return Task.FromResult(AuthenticateResult.Fail("Header Not Found."));
}
var token = Request.Headers["X-Base-Token"].ToString();
try
{
// convert the input string into byte stream
using (MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(token)))
{
// deserialize stream into token model object
//model = Serializer.Deserialize<TokenModel>(stream);
}
}
catch (System.Exception ex)
{
Console.WriteLine("Exception Occured while Deserializing: " + ex);
return Task.FromResult(AuthenticateResult.Fail("TokenParseException"));
}
//if (model != null)
//{
// // success case AuthenticationTicket generation
// // happens from here
// // create claims array from the model
// var claims = new[] {
// new Claim(ClaimTypes.NameIdentifier, model.UserId.ToString()),
// new Claim(ClaimTypes.Email, model.EmailAddress),
// new Claim(ClaimTypes.Name, model.Name) };
// // generate claimsIdentity on the name of the class
// var claimsIdentity = new ClaimsIdentity(claims,
// nameof(ValidateHashAuthenticationHandler));
// // generate AuthenticationTicket from the Identity
// // and current authentication scheme
// var ticket = new AuthenticationTicket(
// new ClaimsPrincipal(claimsIdentity), this.Scheme.Name);
// // pass on the ticket to the middleware
// return Task.FromResult(AuthenticateResult.Success(ticket));
//}
return Task.FromResult(AuthenticateResult.Fail("Model is Empty"));
}
}
public class TokenModel
{
public int UserId { get; set; }
public string Name { get; set; }
public string EmailAddress { get; set; }
}
Startup.cs add below codes into the ConfigureServices method:
services.AddAuthentication(options =>
{
options.DefaultScheme
= "Test";
})
.AddScheme<ValidateHashAuthenticationSchemeOptions, ValidateHashAuthenticationHandler>
("Test", null);
Usage:
Controller:
[Authorize(AuthenticationSchemes = "Test")]
So I now solved it for myself differently by creating a custom Authentication Handler, which takes care of checking the Cookie that gets send to the Web API.
Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddSingleton(new KratosService("http://localhost:4433"));
services
.AddAuthentication("Kratos")
.AddScheme<KratosAuthenticationOptions, KratosAuthenticationHandler>("Kratos", null);
// ...
}
If you are interested in the implementation that did the job for me, I have attached the additional files below. It is also worth mentioning, that Kratos supports two way of Authenticating: Cookies and Bearer Tokens, depending on if you talk to it via Web App or an API. My implementation supports both. You can find a working Sample with ASP.NET Core and React here: https://github.com/robinmanuelthiel/kratos-demo
KratosAuthenticationHandler.cs:
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace KratosDemo.Server.Kratos
{
public class KratosAuthenticationOptions : AuthenticationSchemeOptions
{
}
public class KratosAuthenticationHandler : AuthenticationHandler<KratosAuthenticationOptions>
{
readonly KratosService _kratosService;
readonly string _sessionCookieName = "ory_kratos_session";
public KratosAuthenticationHandler(
IOptionsMonitor<KratosAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
KratosService kratosService
)
: base(options, logger, encoder, clock)
{
_kratosService = kratosService;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// ORY Kratos can authenticate against an API through two different methods:
// Cookie Authentication is for Browser Clients and sends a Session Cookie with each request.
// Bearer Token Authentication is for Native Apps and other APIs and sends an Authentication header with each request.
// We are validating both ways here by sending a /whoami request to ORY Kratos passing the provided authentication
// methods on to Kratos.
try
{
// Check, if Cookie was set
if (Request.Cookies.ContainsKey(_sessionCookieName))
{
var cookie = Request.Cookies[_sessionCookieName];
var id = await _kratosService.GetUserIdByCookie(_sessionCookieName, cookie);
return ValidateToken(id);
}
// Check, if Authorization header was set
if (Request.Headers.ContainsKey("Authorization"))
{
var token = Request.Headers["Authorization"];
var id = await _kratosService.GetUserIdByToken(token);
return ValidateToken(id);
}
// If neither Cookie nor Authorization header was set, the request can't be authenticated.
return AuthenticateResult.NoResult();
}
catch (Exception ex)
{
// If an error occurs while trying to validate the token, the Authentication request fails.
return AuthenticateResult.Fail(ex.Message);
}
}
private AuthenticateResult ValidateToken(string userId)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, userId),
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new System.Security.Principal.GenericPrincipal(identity, null);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
}
KratosService.cs:
using System.Threading.Tasks;
using System.Net.Http;
using System.Text.Json;
using System;
namespace KratosDemo.Server.Kratos
{
public class KratosService
{
private readonly string _kratosUrl;
private readonly HttpClient _client;
public KratosService(string kratosUrl)
{
_client = new HttpClient();
_kratosUrl = kratosUrl;
}
public async Task<string> GetUserIdByToken(string token)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{_kratosUrl}/sessions/whoami");
request.Headers.Add("Authorization", token);
return await SendWhoamiRequestAsync(request);
}
public async Task<string> GetUserIdByCookie(string cookieName, string cookieContent)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{_kratosUrl}/sessions/whoami");
request.Headers.Add("Cookie", $"{cookieName}={cookieContent}");
return await SendWhoamiRequestAsync(request);
}
private async Task<string> SendWhoamiRequestAsync(HttpRequestMessage request)
{
var res = await _client.SendAsync(request);
res.EnsureSuccessStatusCode();
var json = await res.Content.ReadAsStringAsync();
var whoami = JsonSerializer.Deserialize<Whoami>(json);
if (!whoami.Active)
throw new InvalidOperationException("Session is not active.");
return whoami.Identity.Id;
}
}
}
Whoami.cs:
using System;
using System.Text.Json.Serialization;
namespace KratosDemo.Server.Kratos
{
public class Whoami
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("active")]
public bool Active { get; set; }
[JsonPropertyName("expires_at")]
public DateTime ExpiresAt { get; set; }
[JsonPropertyName("authenticated_at")]
public DateTime AuthenticatedAt { get; set; }
[JsonPropertyName("issued_at")]
public DateTime IssuedAt { get; set; }
[JsonPropertyName("identity")]
public Identity Identity { get; set; }
}
public class Identity
{
[JsonPropertyName("id")]
public string Id { get; set; }
}
}
I'm creating a web-api where I need to log people in using Facebook.
I'm following this guide.
Once I provide my credentials to Facebook, it should redirect to an Action but instead it says: "Too many redirects."
This is what I've got in my Startup.cs:
app.UseExternalSignInCookie(Microsoft.AspNet.Identity.DefaultAuthenticationTypes.ExternalCookie);
FacebookAuthenticationOptions facebookAuthOptions = new FacebookAuthenticationOptions()
{
AppId = "myAppId",
AppSecret = "myAppKey",
Provider = new FacebookAuthProvider()
};
app.UseFacebookAuthentication(facebookAuthOptions);
This is my FacebookAuthProvider: class:
public class FacebookAuthProvider : FacebookAuthenticationProvider
{
public override Task Authenticated(FacebookAuthenticatedContext context)
{
context.Identity.AddClaim(new System.Security.Claims.Claim("ExternalAccessToken", context.AccessToken));
return Task.FromResult<object>(null);
}
}
This is my ChallengeResult class:
public class ChallengeResult : IHttpActionResult
{
public string LoginProvider { get; set; }
public HttpRequestMessage Request { get; set; }
public ChallengeResult(string loginProvider, ApiController controller)
{
LoginProvider = loginProvider;
Request = controller.Request;
}
public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
{
Request.GetOwinContext().Authentication.Challenge(LoginProvider);
HttpResponseMessage response = new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized);
response.RequestMessage = Request;
return Task.FromResult<HttpResponseMessage>(response);
}
}
And this is the controller that I'm using to get the token from Facebook after user has logged in:
[HttpGet]
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalCookie)]
[AllowAnonymous]
//[Route("ExternalLogin", Name = "ExternalLogin")]
public IHttpActionResult GetExternalLogin(string provider)
{
string redirectUri = string.Empty;
AppUserManager manager = new AppUserManager(new AppUserStore(new AppContext()));
if (!User.Identity.IsAuthenticated)
{
return new ChallengeResult(provider, this);
}
ExternalLoginData externalLogin = ExternalLoginData.FromIdentity(User.Identity as ClaimsIdentity);
UserLoginInfo loginInfo = new UserLoginInfo(externalLogin.LoginProvider, externalLogin.ProviderKey);
IdentityUser user = manager.Find(loginInfo);
bool hasRegistered = user != null;
ValidateRedirectUri(this.Request, ref redirectUri);
redirectUri = String.Format("{0}#external_access_token={1}&provider={2}&haslocalaccount={3}&external_user_name={4}",
redirectUri,
externalLogin.AccessToken,
externalLogin.LoginProvider,
hasRegistered.ToString(),
externalLogin.UserName);
return Redirect(redirectUri);
}
One thing that I'm really curious about, is that, if I uncomment this line:
[Route("ExternalLogin", Name = "ExternalLogin")]
And try to access that controller with that new route, it says that User (The one in the GetExternalLogin's if) is null.
This is the link that I'm using to test:
http://localhost:62887/api/ExternalAuth/GetExternalLogin?provider=Facebook&redirect_uri=http://localhost:62887/api/ExternalAuth/LoggedIn
And after the user has successfully logged in, this is the action that he's supposed to be redirected:
[HttpGet]
public IHttpActionResult LoggedIn()
{
return Ok(new { Message = "You've been successfully logged in! :)" });
}
I'm gonna kill myself, I finally got it working. I just had to update the NuGet Package from 2.1 to 3.1... >:/
I thought I had a pretty simple goal in mind when I set out a day ago to implement a self-contained bearer auth webapi on .NET core 2.0, but I have yet to get anything remotely working. Here's a list of what I'm trying to do:
Implement a bearer token protected webapi
Issue tokens & refresh tokens from an endpoint in the same project
Use the [Authorize] attribute to control access to api surface
Not use ASP.Net Identity (I have much lighter weight user/membership reqs)
I'm totally fine with building identity/claims/principal in login and adding that to request context, but I've not seen a single example on how to issue and consume auth/refresh tokens in a Core 2.0 webapi without Identity. I've seen the 1.x MSDN example of cookies without Identity, but that didn't get me far enough in understanding to meet the requirements above.
I feel like this might be a common scenario and it shouldn't be this hard (maybe it's not, maybe just lack of documentation/examples?). As far as I can tell, IdentityServer4 is not compatible with Core 2.0 Auth, opendiddict seems to require Identity. I also don't want to host the token endpoint in a separate process, but within the same webapi instance.
Can anyone point me to a concrete example, or at least give some guidance as to what best steps/options are?
Did an edit to make it compatible with ASP.NET Core 2.0.
Firstly, some Nuget packages:
Microsoft.AspNetCore.Authentication.JwtBearer
Microsoft.AspNetCore.Identity
System.IdentityModel.Tokens.Jwt
System.Security.Cryptography.Csp
Then some basic data transfer objects.
// Presumably you will have an equivalent user account class with a user name.
public class User
{
public string UserName { get; set; }
}
public class JsonWebToken
{
public string access_token { get; set; }
public string token_type { get; set; } = "bearer";
public int expires_in { get; set; }
public string refresh_token { get; set; }
}
Getting into the proper functionality, you'll need a login/token web method to actually send the authorization token to the user.
[Route("api/token")]
public class TokenController : Controller
{
private ITokenProvider _tokenProvider;
public TokenController(ITokenProvider tokenProvider) // We'll create this later, don't worry.
{
_tokenProvider = tokenProvider;
}
public JsonWebToken Get([FromQuery] string grant_type, [FromQuery] string username, [FromQuery] string password, [FromQuery] string refresh_token)
{
// Authenticate depending on the grant type.
User user = grant_type == "refresh_token" ? GetUserByToken(refresh_token) : GetUserByCredentials(username, password);
if (user == null)
throw new UnauthorizedAccessException("No!");
int ageInMinutes = 20; // However long you want...
DateTime expiry = DateTime.UtcNow.AddMinutes(ageInMinutes);
var token = new JsonWebToken {
access_token = _tokenProvider.CreateToken(user, expiry),
expires_in = ageInMinutes * 60
};
if (grant_type != "refresh_token")
token.refresh_token = GenerateRefreshToken(user);
return token;
}
private User GetUserByToken(string refreshToken)
{
// TODO: Check token against your database.
if (refreshToken == "test")
return new User { UserName = "test" };
return null;
}
private User GetUserByCredentials(string username, string password)
{
// TODO: Check username/password against your database.
if (username == password)
return new User { UserName = username };
return null;
}
private string GenerateRefreshToken(User user)
{
// TODO: Create and persist a refresh token.
return "test";
}
}
You probably noticed the token creation is still just "magic" passed through by some imaginary ITokenProvider. Define the token provider interface.
public interface ITokenProvider
{
string CreateToken(User user, DateTime expiry);
// TokenValidationParameters is from Microsoft.IdentityModel.Tokens
TokenValidationParameters GetValidationParameters();
}
I implemented the token creation with an RSA security key on a JWT. So...
public class RsaJwtTokenProvider : ITokenProvider
{
private RsaSecurityKey _key;
private string _algorithm;
private string _issuer;
private string _audience;
public RsaJwtTokenProvider(string issuer, string audience, string keyName)
{
var parameters = new CspParameters { KeyContainerName = keyName };
var provider = new RSACryptoServiceProvider(2048, parameters);
_key = new RsaSecurityKey(provider);
_algorithm = SecurityAlgorithms.RsaSha256Signature;
_issuer = issuer;
_audience = audience;
}
public string CreateToken(User user, DateTime expiry)
{
JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler();
ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(user.UserName, "jwt"));
// TODO: Add whatever claims the user may have...
SecurityToken token = tokenHandler.CreateJwtSecurityToken(new SecurityTokenDescriptor
{
Audience = _audience,
Issuer = _issuer,
SigningCredentials = new SigningCredentials(_key, _algorithm),
Expires = expiry.ToUniversalTime(),
Subject = identity
});
return tokenHandler.WriteToken(token);
}
public TokenValidationParameters GetValidationParameters()
{
return new TokenValidationParameters
{
IssuerSigningKey = _key,
ValidAudience = _audience,
ValidIssuer = _issuer,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(0) // Identity and resource servers are the same.
};
}
}
So you're now generating tokens. Time to actually validate them and wire it up. Go to your Startup.cs.
In ConfigureServices()
var tokenProvider = new RsaJwtTokenProvider("issuer", "audience", "mykeyname");
services.AddSingleton<ITokenProvider>(tokenProvider);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = tokenProvider.GetValidationParameters();
});
// This is for the [Authorize] attributes.
services.AddAuthorization(auth => {
auth.DefaultPolicy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
});
Then Configure()
public void Configure(IApplicationBuilder app)
{
app.UseAuthentication();
// Whatever else you're putting in here...
app.UseMvc();
}
That should be about all you need. Hopefully I haven't missed anything.
The happy result is...
[Authorize] // Yay!
[Route("api/values")]
public class ValuesController : Controller
{
// ...
}
Following on #Mitch answer: Auth stack changed quite a bit moving to .NET Core 2.0. Answer below is just using the new implementation.
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace JwtWithoutIdentity
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(cfg =>
{
cfg.RequireHttpsMetadata = false;
cfg.SaveToken = true;
cfg.TokenValidationParameters = new TokenValidationParameters()
{
ValidIssuer = "me",
ValidAudience = "you",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("rlyaKithdrYVl6Z80ODU350md")) //Secret
};
});
services.AddMvc();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseAuthentication();
app.UseMvc();
}
}
}
Token Controller
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using JwtWithoutIdentity.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace JwtWithoutIdentity.Controllers
{
public class TokenController : Controller
{
[AllowAnonymous]
[Route("api/token")]
[HttpPost]
public async Task<IActionResult> Token(LoginViewModel model)
{
if (!ModelState.IsValid) return BadRequest("Token failed to generate");
var user = (model.Password == "password" && model.Username == "username");
if (!user) return Unauthorized();
//Add Claims
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.UniqueName, "data"),
new Claim(JwtRegisteredClaimNames.Sub, "data"),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("rlyaKithdrYVl6Z80ODU350md")); //Secret
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken("me",
"you",
claims,
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds);
return Ok(new JsonWebToken()
{
access_token = new JwtSecurityTokenHandler().WriteToken(token),
expires_in = 600000,
token_type = "bearer"
});
}
}
}
Values Controller
using System.Collections.Generic;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace JwtWithoutIdentity.Controllers
{
[Route("api/[controller]")]
public class ValuesController : Controller
{
// GET api/values
[Authorize]
[HttpGet]
public IEnumerable<string> Get()
{
var name = User.Identity.Name;
var claims = User.Claims;
return new string[] { "value1", "value2" };
}
}
}
Hope this helps!
Here is a use case of my login using a CustomMembershipProvider
User Logs in MembershipProvider validates user account
User property of Membership is set to user details coming from the database
An authentication ticket is created
Forms authentication cookie is added.
User is logged in
Here is a use case of my problem
Stop whe web development server
Start the web development server, and user is still logged in (due to cookie?)
User property Membership is set to null due to server restart/failure
Application throws exception due to null user value
The only solution I could think off is to clear all cookies on Application_Start() but I don't know how is that even possible as Request is out of context during application start.
Do you have any ideas how to solve this kind of problem?
Here is the code:
CustomMembershipProvider
public class CustomMembershipProvider : MembershipProvider
{
#region Unimplemented MembershipProvider Methods
//Other methods here
#endregion
//private IUserRepository _userRepository = new UserRepository();
//Ninject bindings
private IUserRepository _userRepository;
[Inject]
public IUserRepository UserRepository
{
set
{
_userRepository = value;
}
}
private IProfileRepository _profileRepository;
[Inject]
public IProfileRepository ProfileRepository
{
set
{
_profileRepository = value;
}
}
public User User
{
get;
private set;
}
public Profile Profile
{
get;
set;
}
public CustomMembershipProvider()
{
MvcApplication.Container.Inject(this);
}
public override bool ValidateUser(string username, string password)
{
if (string.IsNullOrEmpty(password.Trim())) return false;
User user = _userRepository.GetUserByUsername(username);
user.UserType = UserHelper.GetUserTypeById(user.UserTypeId);
if (user == null) return false;
string hash = PasswordHelper.ComputeHash(password, user.PasswordSalt);
if (user.Password == hash)
{
this.User = user;
Profile profile = _profileRepository.GetProfileByUserId(user.UserId);
this.Profile = profile;
return true;
}
return false;
}
}
Here is the login method of the Account Controller
[HttpPost]
public ActionResult Login(string username, string password)
{
if (!provider.ValidateUser(username, password))
{
TempData["LoginError"] = "Incorrect";
}
else
{
User user = provider.User;
if (!user.Verified)
{
TempData["LoginError"] = "Please verify your account";
return Redirect(Request.UrlReferrer.LocalPath);
}
//FormsAuthentication.SetAuthCookie(user.Username,false);
FormsAuthenticationTicket authTicket = new
FormsAuthenticationTicket(1, //version
username, //user name
DateTime.Now, //creation
DateTime.Now.AddMinutes(30), //Expiration
false, //Persistent
username); //since Classic logins don't have a "Friendly Name"
string encTicket = FormsAuthentication.Encrypt(authTicket);
Response.Cookies.Add(new HttpCookie(FormsAuthentication.FormsCookieName, encTicket));
WebsiteObjects.Profile profile = provider.Profile;
TempData["LoginError"] = String.Empty;
}
return Redirect("/");
}
Suggestions below are not doable because whenever I restart the server here is the case.
Request.IsAuthenticated is FALSE on Application_BeginRequest;
Request.IsAuthenticated is TRUE on my 'View'
why is this happening?
You should perform step 2 on each request or store the user details into the UserData part of the authentication cookie.
In the Application_AuthenticateRequest check if Request.IsAuthenticated=true but User object is null then re-populate it.