I have a web API that I want to migrate to Asp.Net Core 2.0. The API is secured and I want to migrate it to 2.0, because we finished first circle. I tried something, but when I protect my controller with the [Authenticate] attribute, the controller at the given endpoint never get called, because the user is not authenticated.
public partial class Startup
{
public IConfigurationRoot Configuration { get; set; }
private static JwtOptions _jwtOptions;
private readonly IUserService _userService = new UserService();
public Startup(IHostingEnvironment env)
{
var builder = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables();
Configuration = builder.Build();
}
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
//Add DI and other services
SetServices(services);
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
});
services.AddAuthentication(scheme =>
{
scheme.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "aaa",
ValidAudience = "bbb",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("secret key"))
};
});
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromDays(365);
options.Events = new CustomCookieAuthenticationEvents();
options.Cookie.Name = "access_token";
});
services.Configure<CookieAuthenticationOptions>(options =>
{
options.Events = new Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationEvents()
{
OnRedirectToLogin = ctx =>
{
if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
{
ctx.Response.StatusCode = (int)System.Net.HttpStatusCode.Unauthorized;
return Task.FromResult<object>(null);
}
else
{
ctx.Response.Redirect(ctx.RedirectUri);
return Task.FromResult<object>(null);
}
}
};
});
//Logger
services.AddMvc(options =>
{
options.Filters.Add(new Loging.ApiExceptionFilter());
});
services.AddAuthentication(options =>
{
options.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;
});
// Add framework services.
MvcOptions mvcOptions = new MvcOptions();
mvcOptions.Filters.Add(new RequireHttpsAttribute());
MvcJsonOptions jsonOptions = new MvcJsonOptions();
jsonOptions.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
jsonOptions.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
services.AddMvc(options => options = mvcOptions).AddJsonOptions(options => options = jsonOptions);
services.AddSession(options =>
{
// Set a short timeout for easy testing.
options.IdleTimeout = TimeSpan.FromDays(365);
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory, IOptions<JwtOptions> jwtOptions)
{
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
loggerFactory.AddDebug();
app.UseDeveloperExceptionPage();
_jwtOptions = jwtOptions.Value;
app.UseAuthentication();
ConfigureAuth(app);
app.Map(new PathString("/api/images"), x => x.UseBlobFileViewHandler());
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseSession();
app.UseMvc(routes =>
{
routes.MapRoute
(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}"
);
routes.MapSpaFallbackRoute("spa-fallback", new { controller = "Home", action = "Index" });
});
}
As you can see I tried to do some modification on the startup.cs class, but still can't figure out how it works. In the documentation everywhere is EF. What about us, who don't want to use the EF implementation.
public partial class Startup
{
private void ConfigureAuth(IApplicationBuilder app)
{
var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_jwtOptions.SecretKey));
TokenProviderOptions tokenProviderOptions = new TokenProviderOptions
{
Path = "/api/token",
Audience = _jwtOptions.Audience,
Issuer = _jwtOptions.Issuer,
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256),
IdentityResolver = GetIdentity
};
TokenValidationParameters tokenValidationParameters = new TokenValidationParameters
{
// The signing key must match!
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
// Validate the JWT Issuer (iss) claim
ValidateIssuer = true,
ValidIssuer = _jwtOptions.Issuer,
// Validate the JWT Audience (aud) claim
ValidateAudience = true,
ValidAudience = _jwtOptions.Audience,
// Validate the token expiry
ValidateLifetime = true,
LifetimeValidator = LifetimeValidator,
// If you want to allow a certain amount of clock drift, set that here:
ClockSkew = TimeSpan.Zero
};
app.UseSimpleTokenProvider(tokenProviderOptions, tokenValidationParameters);
}
private Task<ClaimsIdentity> GetIdentity(string email)
{
ServiceMessage<UserEntity> request = _userService.FindByEmailAsync(email).Result;
if (request != null && request.Success && request.ResultObject != null)
{
return Task.FromResult(CreateClaimsIdentity(request.ResultObject, "Token"));
}
// Credentials are invalid, or account doesn't exist
return Task.FromResult<ClaimsIdentity>(null);
}
private ClaimsIdentity CreateClaimsIdentity(UserEntity user, string authenticationType)
{
List<Claim> claimCollection = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Email, ClaimValueTypes.String),
new Claim(ClaimTypes.Role, user.Role, ClaimValueTypes.String),
new Claim(ClaimTypes.Name, user.Email.Split('@')[0], ClaimValueTypes.String),
new Claim(ClaimTypes.Expiration, DateTime.UtcNow.AddDays(365).Second.ToString(), ClaimValueTypes.DaytimeDuration)
};
ClaimsIdentity claimsIdentity = new ClaimsIdentity(claimCollection, authenticationType);
return claimsIdentity;
}
private bool LifetimeValidator(DateTime? notBefore, DateTime? expires, SecurityToken token, TokenValidationParameters @params)
{
if (expires != null)
{
return expires > DateTime.UtcNow;
}
return false;
}
public static string FromHex()
{
string hex = Guid.NewGuid().ToString();
hex = hex.Replace("-", "");
byte[] raw = new byte[hex.Length / 2];
for (int i = 0; i < raw.Length; i++)
{
raw[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}
return Encoding.ASCII.GetString(raw);
}
}
public class CustomCookieAuthenticationEvents : CookieAuthenticationEvents
{
public override Task RedirectToLogin(RedirectContext<CookieAuthenticationOptions> context)
{
if (context.HttpContext.Request.Path.StartsWithSegments("/api") && context.HttpContext.Response.StatusCode == 200)
context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
//return base.RedirectToLogin(context);
return Task.FromResult((int)HttpStatusCode.Unauthorized);
}
}
public class CustomJwtDataFormat : ISecureDataFormat<AuthenticationTicket>
{
private readonly string algorithm;
private readonly TokenValidationParameters validationParameters;
public CustomJwtDataFormat(string algorithm, TokenValidationParameters validationParameters)
{
this.algorithm = algorithm;
this.validationParameters = validationParameters;
}
public AuthenticationTicket Unprotect(string protectedText) => Unprotect(protectedText, null);
public AuthenticationTicket Unprotect(string protectedText, string purpose)
{
var handler = new JwtSecurityTokenHandler();
ClaimsPrincipal principal = null;
SecurityToken validToken = null;
try
{
principal = handler.ValidateToken(protectedText, this.validationParameters, out validToken);
var validJwt = validToken as JwtSecurityToken;
if (validJwt == null)
{
throw new ArgumentException("Invalid JWT");
}
if (!validJwt.Header.Alg.Equals(algorithm, StringComparison.Ordinal))
{
throw new ArgumentException($"Algorithm must be '{algorithm}'");
}
// Additional custom validation of JWT claims here (if any)
}
catch (SecurityTokenValidationException)
{
return null;
}
catch (ArgumentException)
{
return null;
}
// Validation passed. Return a valid AuthenticationTicket:
return new AuthenticationTicket(principal, new Microsoft.AspNetCore.Authentication.AuthenticationProperties(), "Cookie");
}
// This ISecureDataFormat implementation is decode-only
public string Protect(AuthenticationTicket data)
{
throw new NotImplementedException();
}
public string Protect(AuthenticationTicket data, string purpose)
{
throw new NotImplementedException();
}
}
}
public class TokenProviderMiddleware
{
private readonly RequestDelegate _next;
private readonly TokenProviderOptions _options;
private readonly ILogger _logger;
private readonly JsonSerializerSettings _serializerSettings;
private readonly TokenValidationParameters _tokenValidationParameters;
private readonly ISocialAuthentificationServices _socialAuthentificationServices;
public TokenProviderMiddleware(RequestDelegate next, IOptions<TokenProviderOptions> options, ILoggerFactory loggerFactory, ISocialAuthentificationServices socialAuthentificationServices, IOptions<TokenValidationParameters> tokenValidationParameters)
{
_socialAuthentificationServices = socialAuthentificationServices;
_next = next;
_logger = loggerFactory.CreateLogger<TokenProviderMiddleware>();
_options = options.Value;
_tokenValidationParameters = tokenValidationParameters.Value;
ThrowIfInvalidOptions(_options);
_serializerSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented
};
}
public Task Invoke(HttpContext context)
{
//Add CORS to every response
context.Response.Headers.Add("Access-Control-Allow-Headers", new string[] { "Authorization", "Content-Type" });
context.Response.Headers.Add("Access-Control-Allow-Methods", new string[] { "OPTIONS", "POST", "GET", "DELETE", "PUT" });
context.Response.Headers.Add("Access-Control-Allow-Origin", "*");
if (context.Request.Method.Equals("OPTIONS", StringComparison.Ordinal))
{
context.Response.StatusCode = 204;
return _next(context);
}
// If the request path doesn't match, skip
if (!context.Request.Path.Equals(_options.Path, StringComparison.Ordinal))
{
return _next(context);
}
// Request must be POST with Content-Type: application/x-www-form-urlencoded
if (!context.Request.Method.Equals("POST") || !context.Request.HasFormContentType)
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
return context.Response.WriteAsync("Bad request.");
}
_logger.LogInformation("Handling request: " + context.Request.Path);
return GetToken(context);
}
private async Task GetToken(HttpContext context)
{
TokenData headers = GetHeaderContext(context);
if (headers == null)
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync("Invalid encrypted token.");
return;
}
if (string.IsNullOrEmpty(headers.Provider))
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync("Provider not definied.");
return;
}
else if (string.IsNullOrEmpty(headers.Token))
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync("Invalid token.");
return;
}
else
{
var providers = (Mapurija.Models.Enum.Providers[])Enum.GetValues(typeof(Mapurija.Models.Enum.Providers));
if (!headers.Provider.Contains(headers.Provider))
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync("Invalid token.");
return;
}
}
ServiceMessage<UserEntity> validation = null;
int enumProvider = 0;
int.TryParse(headers.Provider, out enumProvider);
try
{
switch (enumProvider)
{
case (int)Mapurija.Models.Enum.Providers.Mapporia:
validation = await _socialAuthentificationServices.VerifyMapurijaTokenAsync(headers.Token);
break;
case (int)Mapurija.Models.Enum.Providers.Facebook:
validation = await _socialAuthentificationServices.VerifyFacebookTokenAsync(headers.Token);
break;
case (int)Mapurija.Models.Enum.Providers.Google:
validation = await _socialAuthentificationServices.VerifyFacebookTokenAsync(headers.Token);
break;
default:
validation = null;
break;
}
}
catch
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync("Invalid request token!");
return;
}
if (validation == null || !validation.Success || validation.ResultObject == null)
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync(validation.ErrorMessage);
return;
}
ClaimsIdentity identity = await _options.IdentityResolver(validation.ResultObject.Email);
if (identity == null)
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync("Invalid token.");
return;
}
DateTime now = DateTime.UtcNow;
// Specifically add the jti (nonce), iat (issued timestamp), and sub (subject/user) claims.
// You can add other claims here, if you want:
Claim[] claims = new Claim[]
{
new Claim(ClaimTypes.Name,validation.ResultObject.Email,ClaimValueTypes.String),
new Claim(JwtRegisteredClaimNames.Sub,validation.ResultObject.Email,ClaimValueTypes.String),
new Claim(JwtRegisteredClaimNames.Typ, validation.ResultObject.Role),
new Claim(JwtRegisteredClaimNames.Jti, await _options.NonceGenerator()),
new Claim(JwtRegisteredClaimNames.Iat, ToUnixEpochDate(now).ToString(), ClaimValueTypes.Integer64),
new Claim(JwtRegisteredClaimNames.Iss, _options.Issuer),
new Claim(JwtRegisteredClaimNames.Aud, _options.Audience)
};
// Create the JWT and write it to a string
JwtSecurityToken jwt = new JwtSecurityToken
(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: now,
expires: now.Add(_options.Expiration),
signingCredentials: _options.SigningCredentials
);
SecurityToken token;
JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler();
string encodedJwt = handler.WriteToken(jwt);
ClaimsPrincipal principal = handler.ValidateToken(encodedJwt, _tokenValidationParameters, out token);
if (token == null)
{
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await context.Response.WriteAsync("Invalid token generated!");
return;
}
var response = new
{
access_token = encodedJwt,
expires_in = (int)_options.Expiration.TotalSeconds
};
// Serialize and return the response
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(JsonConvert.SerializeObject(response, _serializerSettings));
}
private static void ThrowIfInvalidOptions(TokenProviderOptions options)
{
if (string.IsNullOrEmpty(options.Path))
{
throw new ArgumentNullException(nameof(TokenProviderOptions.Path));
}
if (string.IsNullOrEmpty(options.Issuer))
{
throw new ArgumentNullException(nameof(TokenProviderOptions.Issuer));
}
if (string.IsNullOrEmpty(options.Audience))
{
throw new ArgumentNullException(nameof(TokenProviderOptions.Audience));
}
if (options.Expiration == TimeSpan.Zero)
{
throw new ArgumentException("Must be a non-zero TimeSpan.", nameof(TokenProviderOptions.Expiration));
}
if (options.IdentityResolver == null)
{
throw new ArgumentNullException(nameof(TokenProviderOptions.IdentityResolver));
}
if (options.SigningCredentials == null)
{
throw new ArgumentNullException(nameof(TokenProviderOptions.SigningCredentials));
}
if (options.NonceGenerator == null)
{
throw new ArgumentNullException(nameof(TokenProviderOptions.NonceGenerator));
}
}
private TokenData GetHeaderContext(HttpContext context)
{
string token = new StreamReader(context.Request.Body).ReadToEnd();
if (string.IsNullOrEmpty(token))
{
return null;
}
var encrypted = Convert.FromBase64String(token);
var decriptedFromJavascript = Mapurija.Services.Common.TokenDecrypter.DecryptStringFromBytes(encrypted, Mapurija.Services.Common.TokenDecrypter.KeyBytes, Mapurija.Services.Common.TokenDecrypter.Vi);
TokenData result = JsonConvert.DeserializeObject< TokenData>(decriptedFromJavascript);
return result;
}
/// <summary>
/// Get this datetime as a Unix epoch timestamp (seconds since Jan 1, 1970, midnight UTC).
/// </summary>
/// <param name="date">The date to convert.</param>
/// <returns>Seconds since Unix epoch.</returns>
public static long ToUnixEpochDate(DateTime date) => (long)Math.Round((date.ToUniversalTime() - new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalSeconds);
}
public class TokenProviderOptions
{
/// <summary>
/// The relative request path to listen on.
/// </summary>
/// <remarks>The default path is <c>/token</c>.</remarks>
public string Path { get; set; } = "api/token";
/// <summary>
/// The Issuer (iss) claim for generated tokens.
/// </summary>
public string Issuer { get; set; }
/// <summary>
/// The Audience (aud) claim for the generated tokens.
/// </summary>
public string Audience { get; set; }
/// <summary>
/// The expiration time for the generated tokens.
/// </summary>
/// <remarks>The default is five minutes (300 seconds).</remarks>
public TimeSpan Expiration { get; set; } = TimeSpan.FromDays(365);
/// <summary>
/// The signing key to use when generating tokens.
/// </summary>
public SigningCredentials SigningCredentials { get; set; }
/// <summary>
/// Resolves a user identity given a username and password.
/// </summary>
public Func<string, Task<ClaimsIdentity>> IdentityResolver { get; set; }
/// <summary>
/// Generates a random value (nonce) for each generated token.
/// </summary>
/// <remarks>The default nonce is a random GUID.</remarks>
public Func<Task<string>> NonceGenerator { get; set; } = new Func<Task<string>>(() => Task.FromResult(Guid.NewGuid().ToString()));