I'm trying to configure an authentication in some application that will consume the login validation from another system. Basically, I get a token with the users information, and I have to map it to my database, because the user it was already authenticated. We're doing it with using jwt tokens.
So here's the problem:
The token I'm getting doesn't have "iat" field, and AspNetCore seems to reject the token without that field.
Is there a way to configure the authentication to ignore that field?
Here is the token structure:
TOKEN - HEADER
{
"alg": "HS512"
}
TOKEN - BODY
{
"sub": [mysub],
"user": { ... },
"exp": [timestamp]
}
And here's the configuration:
...
services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(x =>
{
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuerSigningKey = false,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false
};
});
...
Edit: An earlier version of this answer mixed up iat and nbf. The answer has been rewritten accordingly.
I tried reproducing your issue in an empty project, and was successfully able to validate a token with the structure you outlined in your question (so without the iat claim).
Setup I used to get this to work:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = false,
ValidateIssuer = false,
ValidateAudience = false,
SignatureValidator = (t, p) => new JwtSecurityToken(t)
};
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}
[Authorize]
[Route("debug")]
public class DebugAuthController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(User.Claims.Single(c => c.Type == "user").Value);
}
I used the following token for testing: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJteXN1YmplY3QiLCJ1c2VyIjp7Im5hbWUiOiJTdGFja092ZXJmbG93In0sImV4cCI6MTU5MDk5NzMyNn0.FVFl6gDYOrmzj7_6OqHPTxU3mfQWs864u7fBLM5ThuM
It's worth double-checking to see if you're calling both UseAuthentication and UseAuthorization in ConfigureServices, and that you're calling them in that order.
Note: Sample code provided disables virtually all validation checks on the JWT for testing purposes. Do not use this code as-is unless you know what you're doing!
Related
I'm trying to add auth to my .NET Core 3.1 application, however each attempt to use the returned JWT results in 401 Unauthorized. I've gone through a few steps to debug:
I've confirmed that the JWT that my authenticate endpoint returns is valid, using this online validator.
I've re-ordered my UseRouting(), UseAuthentication(), UseAuthorization(), and UseEndpoints() to every possible order.
I've disabled both audience and issuer validation entirely for the time being.
Can someone identify where I've gone wrong, or at the very least, provide some method of properly debugging so I can track the issue down? Thanks. Code below.
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services
.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
var key = Encoding.UTF8.GetBytes("thisismycustomSecretkeyforauthentication");
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "issuer",
ValidAudience = "audience",
IssuerSigningKey = new SymmetricSecurityKey(key)
};
});
services
.AddControllers();
.AddMvc(options => { options.EnableEndpointRouting = false; });
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app
.UseStaticFiles()
.UseHsts()
.UseHttpsRedirection()
.UseMvc(routes => routes.MapRoute(name: "default", template: "{controller=App}/{action=Index}/{id?}"))
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseEndpoints(endpoints => { endpoints.MapControllers(); });
if (env.IsDevelopment())
app.UseSpa(spa => spa.UseProxyToSpaDevelopmentServer("https://localhost:22010"));
}
AuthController.cs
[ApiController]
[Authorize]
[Route("[controller]")]
public class AuthController : Controller
{
[AllowAnonymous]
[HttpPost("authenticate")]
public async Task<IActionResult> Authenticate(AuthenticationRequest request)
{
if (request.Username != "test" || request.Password != "admin")
return Unauthorized();
var tokenHandler = new JwtSecurityTokenHandler();
var tokenKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("thisismycustomSecretkeyforauthentication"));
var token = new JwtSecurityToken
(
issuer: null,
audience: null,
claims: new Claim[] { new Claim(ClaimTypes.Name, request.Username) },
expires: DateTime.Now.AddDays(30),
signingCredentials: new SigningCredentials(tokenKey, SecurityAlgorithms.HmacSha256)
);
var tokenResponse = tokenHandler.WriteToken(token);
return Ok(tokenResponse);
}
[HttpGet]
public IActionResult Do()
{
return Ok("Done!");
}
}
Authentication setup looks fine, and your middleware order is per Microsoft Documentation.
Based on the code provided you seem to be missing the actual Authorization options. This is similar to the issue solved in this question.
I have a problem , jwt authentication return 401 Error.
Token was created but always return 401 error.
I used layered architecture. I tried many things on startup.cs. JWT and startup.cs code in below. Will be updated if you want to look at another code.
JwtHelper Code:
public class JwtHelper : ITokenHelper
{
public IConfiguration Configuration { get; }
private TokenOptions _tokenOptions;
private DateTime _accessTokenExpiration;
public JwtHelper(IConfiguration configuration)
{
Configuration = configuration;
_tokenOptions = Configuration.GetSection("TokenOptions").Get<TokenOptions>();
}
public AccessToken CreateToken(User user, List<OperationClaim> operationClaims)
{
_accessTokenExpiration = DateTime.Now.AddMinutes(_tokenOptions.AccessTokenExpiration);
var securityKey = SecurityKeyHelper.CreateSecurityKey(_tokenOptions.SecurityKey);
var signingCredentials = SigningCredentialsHelper.CreateSigningCredentials(securityKey);
var jwt = CreateJwtSecurityToken(_tokenOptions, user, signingCredentials, operationClaims);
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
var token = jwtSecurityTokenHandler.WriteToken(jwt);
return new AccessToken
{
Token = token,
Expiration = _accessTokenExpiration
};
}
public JwtSecurityToken CreateJwtSecurityToken(TokenOptions tokenOptions, User user,
SigningCredentials signingCredentials, List<OperationClaim> operationClaims)
{
var jwt = new JwtSecurityToken(
issuer: tokenOptions.Issuer,
audience: tokenOptions.Audience,
expires: _accessTokenExpiration,
notBefore: DateTime.Now,
claims: SetClaims(user, operationClaims),
signingCredentials: signingCredentials
);
return jwt;
}
private IEnumerable<Claim> SetClaims(User user, List<OperationClaim> operationClaims)
{
var claims = new List<Claim>();
claims.AddNameIdentifier(user.id.ToString());
claims.AddEmail(user.Email);
claims.AddName($"{user.FirstName} {user.LastName}");
claims.AddRoles(operationClaims.Select(c => c.name).ToArray());
return claims;
}
}
Statup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddCors();
services.AddAntiforgery(o => o.HeaderName = "XSRF-TOKEN");
services.AddControllersWithViews();
services.AddRazorPages();
var tokenOptions = Configuration.GetSection("TokenOptions").Get<TokenOptions>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer = tokenOptions.Issuer,
ValidAudience = tokenOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = SecurityKeyHelper.CreateSecurityKey(tokenOptions.SecurityKey)
};
});
services.AddControllersWithViews();
services.AddDependencyResolvers(new ICoreModule[] {
new CoreModule()
});
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(o => {
o.LoginPath = "/Auth/Login";
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseCors(builder => builder.WithOrigins("https://localhost:44378").AllowAnyHeader());
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.UseAuthentication();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Customer}/{action=Index}/{id?}");
});
app.UseStatusCodePages();
}
I have been searching for 1 week but I did not found.
After when you register JwtToken handler, this one
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(o => {
o.LoginPath = "/Auth/Login";
Make your default Authentication Scheme be came CookieAuthenticationDefaults.AuthenticationScheme, which use cookie as validation.
The code should be
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme, opts =>
{
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer = tokenOptions.Issuer,
ValidAudience = tokenOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = SecurityKeyHelper.CreateSecurityKey(tokenOptions.SecurityKey)
};
})
.AddCookie(o => {
o.LoginPath = "/Auth/Login";
});;
And as #Camilo Terevinto mention, app.UseAuthentication must come before app.UseAuthorization.
I am having some difficulty getting multiple authenticaion schemes to work with my .NET Core API.
Each works perfectly when set as the Default Scheme to be used, but I cannot get them to chain authenticaion, so if one fails authentication it moves onto the next one in the chain to try and authenticate using that scheme.
I'm using
JWT Bearer Token Authentication
API Key Authentication
As can be seen in the code below I've setup an AuthorizeFilter policy with the multiple schemes, but obviously I'm missing something somewhere along the way and I'm hoping you may be able to spot what I am missing.
Below is the complete start up code I am using.
Kind Regards
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(authOptions =>
{
authOptions.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
authOptions.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(bearerOptions =>
{
bearerOptions.RequireHttpsMetadata = true;
bearerOptions.SaveToken = true;
bearerOptions.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = JWTProvider.CLAIM_ISSUER,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String(this.Configuration[JWTSecurityKeyProvider.DEFAULT_JWT_SECURITY_KEYNAME])) { KeyId = "416" },
ValidAudience = JWTProvider.API_AUDIENCE,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
})
.AddApiKeySupport(options =>
{
});
services.AddControllers(options =>
{
var defaultSchemes = new[] { JwtBearerDefaults.AuthenticationScheme, ApiKeyAuthenticationOptions.DefaultScheme };
var defaultPolicy = new AuthorizationPolicyBuilder(defaultSchemes)
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(defaultPolicy));
});
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Auth API", Version = "v1" });
});
services.AddSingleton<JWTSecurityKeyProvider>();
services.AddSingleton<JWTProvider>();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1"));
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
According to documentation:
Only one JWT bearer authentication is registered with the default authentication scheme JwtBearerDefaults.AuthenticationScheme. Additional authentication has to be registered with a unique authentication scheme.
Also it rewrites default policy in services.AddAuthorization() insted of services.AddControllers()
In case anyone else experiences this issue, to sort out the problem you need to include the Authorized Schemes as an attribute on each controller. Then everything is works tickety-boo.
E.g.
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme + "," + ApiKeyAuthenticationOptions.DefaultScheme)]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : BaseAuthenticatedController
{
I have a asp.net core 2.1 API that I connect to using and Angular 4 app and authenticate thru a JWT token. I also have 2 SignalR hubs there as well.
The authentication works nice and I am sure it works because after login I have access to the methods and classes I have set [Authorize] on.
The problem is the injected _accessor.HttpContext.Session.Id changes every time with every request. So the real issues is I cannot use session variables.
I am at a loss and it seems to me I am missing something here.
Can someone please help me with some ideas? Any help is much appreciated.
This is my startup.cs so far:
public class Startup
{
public Startup(IConfiguration configuration, IHostingEnvironment env)
{
Configuration = configuration;
if (env.IsDevelopment())
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.RollingFile(Path.Combine(env.ContentRootPath+"/logs/", "log-{Date}.txt"))
//, outputTemplate: "{MachineName} {EnvironmentUserName}: {Message:lj}{NewLine}{Exception}"
.WriteTo.Seq("http://192.168.1.164:5341")
.Enrich.WithMachineName()
.Enrich.WithEnvironmentUserName()
.CreateLogger();
}
else
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.RollingFile(Path.Combine(env.ContentRootPath + "/logs/", "log-{Date}.txt"))
.Enrich.WithMachineName()
.Enrich.WithEnvironmentUserName()
.CreateLogger();
}
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
var key = Encoding.ASCII.GetBytes(Configuration.GetSection("AppSettings:Token").Value);
services.AddDbContext<PaymentServicesContext>(options => options.UseSqlServer(Configuration.GetConnectionString("PaymentDatabase")));
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(10);
options.Cookie.HttpOnly = true;
});
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1).AddSessionStateTempDataProvider();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters =
new TokenValidationParameters
{
LifetimeValidator = (before, expires, token, param) =>
{
return expires > DateTime.UtcNow;
},
ValidateAudience = false,
ValidateIssuerSigningKey = true,
ValidateIssuer = false,
ValidateActor = false,
ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(key)
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
services.AddAutoMapper();
services.AddCors();
services.AddSignalR(options => options.EnableDetailedErrors = true);
///services
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddTransient<IBrainzService, BrainzService>();
services.AddTransient<ISecurityService, SecurityService>();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
loggerFactory.AddDebug();
loggerFactory.AddSerilog();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler(builder =>
{
builder.Run(async context =>
{
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var error = context.Features.Get<IExceptionHandlerFeature>();
if (error != null)
{
await context.Response.WriteAsync(error.Error.Message);
}
});
});
}
app.UseCors(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod().AllowCredentials())
.UseStaticFiles()
.UseWebSockets();
app.UseAuthentication();
if (!env.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseWebSockets();
app.UseSignalR(
routes =>
{
routes.MapHub<MessagingHub>("/messagingHub");
routes.MapHub<UpdatesHub>("/updatesHub");
});
app.UseSession();
app.UseMvc();
}
}
The configuration works.
The problem was that Angular's HttpClient does not store/get/send cookies by default as I have found out the hard way.
All I had to do was add {withCredentials: true } option to the login request and the session cookie popped into the browser!
To get the session back you have to add this option to all requests and the API will know the session!
One solution would be to create an Interceptor to intercept all requests before it leaves the client application. This tutorial explains it well. You can also do this on a per-request level by setting the withCredentials to true.
const requestOptions = {
headers: new HttpHeaders({
'Authorization': "my-request-token"
}),
withCredentials: true
};
I am trying to get started with authentication on an ASP.NET Core 2.0 web app.
My company is using Ping Federate and I am trying to authenticate my users using the company login page and in return validating the returned token using my signing key (X509SecurityKey down here).
The login page link looks like:
https://companyname.com/authorization.oauth2?response_type=code&redirect_uri=https%3a%2f%2fJWTAuthExample%2fAccount%2fLogin&client_id=CompanyName.Web.JWTAuthExample&scope=&state=<...state...>
Out of the box, I configured the Startup.cs to be able to log in and challenge against this site.
I decorated my HomeController with a [Authorize(Policy="Mvc")] but when I access one of the pages, I just get a blank page.
Debug is not hitting the OnChallenge or OnAuthenticationFailed methods when I add it to options.Events (I think because user needs to be authenticated first).
So, what am I missing in order for a redirect to my authentication website to happen? Is it built in or do I have to do some manual configuration?
(Note: In other web apps, using asp net framework, I use a redirect in an Authorize attribute when authentication fails)
Related post: Authorize attribute does not redirect to Login page when using .NET Core 2's AddJwtBearer
- From this post, does it mean I am not using the right authentication method? I am building a web app, not an API.
namespace JWTAuthExample
{
public class Startup
{
public Startup(IConfiguration configuration, IHostingEnvironment hostingEnvironment)
{
Configuration = configuration;
HostingEnvironment = hostingEnvironment;
string certificatepath = Path.Combine(HostingEnvironment.ContentRootPath, $"App_Data\\key.cer");
KEY = new X509SecurityKey(new X509Certificate2(certificatepath));
}
public IConfiguration Configuration { get; }
public IHostingEnvironment HostingEnvironment { get; }
private string AUTH_LOGINPATH { get; } = Configuration["DefaultAuth:AuthorizationEndpoint"];
private X509SecurityKey KEY { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.IncludeErrorDetails = true;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
// Ensure token expiry
RequireExpirationTime = true,
ValidateLifetime = true,
// Ensure token audience matches site audience value
ValidateAudience = false,
ValidAudience = AUTH_LOGINPATH,
// Ensure token was issued by a trusted authorization server
ValidateIssuer = true,
ValidIssuer = AUTH_LOGINPATH,
// Specify key used by token
RequireSignedTokens = true,
IssuerSigningKey = KEY
};
});
services.AddAuthorization(options =>
{
options.AddPolicy("Mvc", policy =>
{
policy.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme);
policy.RequireAuthenticatedUser();
});
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseBrowserLink();
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
}
Following Brad's suggestion,
Here is a sample of code to perform an OpenId Connect confirguration on ASP NET 2.0
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = Configuration["AuthoritySite"];
options.ClientId = Configuration["ClientId"];
options.ClientSecret = Configuration["ClientSecret"];
options.Scope.Clear();
// options.Scope.Add("Any:Scope");
options.ResponseType = OpenIdConnectResponseType.CodeIdTokenToken;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
// Compensate server drift
ClockSkew = TimeSpan.FromHours(12),
// Ensure key
IssuerSigningKey = CERTIFICATE,
// Ensure expiry
RequireExpirationTime = true,
ValidateLifetime = true,
// Save token
SaveSigninToken = true
};
});
services.AddAuthorization(options =>
{
options.AddPolicy("Mvc", policy =>
{
policy.AuthenticationSchemes.Add(OpenIdConnectDefaults.AuthenticationScheme);
policy.RequireAuthenticatedUser();
});
});
}
More details here: https://learn.microsoft.com/en-us/aspnet/core/migration/1x-to-2x/identity-2x?view=aspnetcore-2.1