I'm running into some issues with the authentication pipeline in ASP.NET Core. My scenario is that I want to issue a challenge to a user who is already authenticated using OpenID Connect and Azure AD. There are multiple scenarios where you'd want to do that, for example when requesting additional scopes in a AAD v2 endpoint scenario.
This works like a charm in ASP.NET MVC, but in ASP.NET Core MVC the user is being redirected to the Access Denied-page as configured in the cookie authentication middleware. (When the user is not logged in, issuing a challenge works as expected.)
After a couple of hours searching the web and trying different parameters for my middleware options, I'm beginning to suspect that either I'm missing something obvious, or this behavior is by design and I need to solve my requirement some other way. Anyone any ideas on this?
EDIT: the relevant parts of my Startup.cs look like this:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthentication(
SharedOptions => SharedOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme);
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
// <snip...>
app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme });
var options = new OpenIdConnectOptions
{
AuthenticationScheme = OpenIdConnectDefaults.AuthenticationScheme,
ClientId = ClientId,
Authority = Authority,
CallbackPath = Configuration["Authentication:AzureAd:CallbackPath"],
ResponseType = OpenIdConnectResponseType.CodeIdToken,
PostLogoutRedirectUri = "https://localhost:44374/",
TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = false
}
};
options.Scope.Add("email");
options.Scope.Add("offline_access");
app.UseOpenIdConnectAuthentication(options);
}
And the Action looks like this:
public void RefreshSession()
{
HttpContext.Authentication.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/" });
}
I found a hint and the solution here: https://github.com/aspnet/Security/issues/912.
ChallengeBehavior.Unauthorized is the "key".
This post gives the current (november 2016 - ASPNet 1.0.1) workaround: https://joonasw.net/view/azure-ad-b2c-with-aspnet-core
You'll need a new ActionResult to be able to call the AuthauticationManager.ChallengeAsync with the ChallengeBehavior.Unauthorized behavior.
Once the issue https://github.com/aspnet/Mvc/issues/5187 will be sucessfully closed, this should be integrated.
I tested it and it worked perfectly well (my goal was simply to extend Google scopes on a per user basis).
Try to sign out:
public void RefreshSession()
{
HttpContext.Authentication.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
HttpContext.Authentication.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
HttpContext.Authentication.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/" });
}
Related
I have a .NET Core 2.2 MVC web-application. And I've added two authentication types/providers there:
Login/password with local users database (custom thing, without .NET Core Identity)
Azure AD
My goal is to have a login page at /account/login where users can choose between these two authentications and log-in with either of those. So every time an unauthenticated user would open any page (from a controller with [Authorize] attrubite), he would get redirected to /account/login page, which has a login/password web-form with its own submit button, and additionally a Office 365 login link/button.
Just to make it clear - I don't want a custom Microsoft sign-in / Azure AD page. I only want unauthenticated users to get my login page first, from where they can either log-in using my web-form or click on Office 365 login and get to Microsoft sign-in page.
Now, the authentication part is done and seems to work fine, I can log-in with either of authentications, but my plan with redirecting unauthenticated user to /account/login failed. What happens instead is that user is being redirected to Microsoft sign-in page right away. So it looks like Azure AD authentication has a higher priority somehow.
Here's my implementation.
Startup.cs:
// ...
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.MinimumSameSitePolicy = SameSiteMode.None;
});
// the presence of CookieAuthenticationDefaults.AuthenticationScheme doesn't seem to influence anything
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
// makes no difference either
//services.AddAuthentication(
// options =>
// {
// options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// }
//)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, // and it also can be omitted here
options =>
{
options.LoginPath = "/Account/Login";
options.ExpireTimeSpan = TimeSpan.FromDays(45);
})
.AddAzureAD(options => _configuration.Bind("AzureAD", options));
services.AddAuthorization(options =>
{
// as the default policy, it applies to all [Authorize] controllers
options.DefaultPolicy = new AuthorizationPolicyBuilder(
CookieAuthenticationDefaults.AuthenticationScheme,
AzureADDefaults.AuthenticationScheme
)
.RequireAuthenticatedUser() // a simple policy that only requires a user to be authenticated
.Build();
});
services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
{
options.Authority = options.Authority + "/v2.0/";
options.TokenValidationParameters.ValidateIssuer = false;
});
services.AddMvc(options =>
{
// it is my understanding that there is no need create a policy here
// and perform "options.Filters.Add(new AuthorizeFilter(policy))",
// because the default policy is already added and controllers have explicit [Authorize] attribute
// [...] well, actually I tried that too, but it didn't change anything
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
ILoggerFactory loggerFactory
)
{
// ...
app.UseCookiePolicy();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}"
);
});
}
AccountController.cs:
[Authorize]
[Route("account")]
public class AccountController : Controller
{
// ...
// that is where "Office 365 login" link leads
[HttpGet("login-ad")]
[AllowAnonymous]
public IActionResult LoginAD(string returnUrl = null)
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("Index", "Account");
}
else
{
if (string.IsNullOrEmpty(returnUrl)) { returnUrl = "/"; }
return Challenge(
new AuthenticationProperties { RedirectUri = returnUrl },
AzureADDefaults.AuthenticationScheme
);
}
}
[HttpGet("login")]
[AllowAnonymous]
public IActionResult Login(string returnUrl = null)
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("Index", "Account");
}
ViewData["ReturnUrl"] = returnUrl;
return View();
}
// that is where login/password web-form submits to
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
{
if (ModelState.IsValid)
{
await _usersManager.SignIn(
model.Login,
model.Password
);
// ...
return LocalRedirect(returnUrl);
}
ViewData["ReturnUrl"] = returnUrl;
return View(model);
}
// ...
}
HomeController.cs:
[Authorize]
public class HomeController : Controller
{
// ...
public IActionResult Index()
{
return View();
}
// ...
}
So, opening any page by an unauthenticated user results in immediate redirect to Microsoft sign-in page. And in order to get to /account/login (to have a chance to log-in using another authentication) users have to open that URL explicitly.
If I remove AzureADDefaults.AuthenticationScheme from default policy, then all unauthenticated requests will now get redirected to /account/login - exactly what I want - but naturally Azure AD authentication doesn't work anymore:
These redirects tell me that after successful authentication at Microsoft sign-in page it returns user back to /account/login, but user is still not authenticated on my website.
I can of course add [AllowAnonymous] to Index action of HomeController and return redirect to /account/login for unauthenticated users, but that obviously would only work for / route.
I have a feeling that I don't understand some things about AddAuthentication(), schemes and policies, thus apparently I did something wrong in Startup.cs. Can you please help me to understand what's wrong there? Or maybe there is some other way to achieve what I want?
Updated answer
I decided to clone the example project mentioned here in the quickstart-v2-aspnet-core-webapp documentation and see if I could reproduce your error.
After cloning the project I added two NuGet packages.
Microsoft.AspNetCore.Identity 2.2.0
Microsoft.AspNetCore.Identity.EntityFrameworkCore 2.2.0
Then added the database context that extends IdentityContext.
ApplicationDbContext.cs
In Startup.cs
Registered Identity
Registered the database context and provided connection string
In AppSettings.json
Configured TenantID and ClientID
Ran the application.
At this point, the app launches and redirects me to Account/Login, where I choose Sign in via Microsoft account.
Now, I can obviously see there is something wrong. It wouldn't authenticate the user.
Turns out:
The extension method .AddAzureAd() actually cannot be used in combination with other authentication methods. See this issue on github.
But luckily the workaround is fairly simple. Just switch out .AddAzureAd() for .AddOpenIdConnect() and change your AppSettings' AzureAd section to:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Authority": "https://login.microsoftonline.com/{tenantID}/v2.0/",
"TenantId": "{tenantID}",
"ClientId": "{clientID}",
"CallbackPath": "/signin-oidc"
},
Now I can log in perfectly fine with AzureAD and local user accounts as well.
For your convenience, I uploaded the complete example project to my GitHub page.
i'm using:
services.AddAuthentication().AddCookie("custom", options=> {
options.LoginPath = "/admin/in";
options.LogoutPath = "/admin/out";
});
in ConfigureServices to register a custom authentication scheme.
when i try to use this scheme in my controller:
[Authorize(Roles = "admin", AuthenticationSchemes = "custom")]
public ActionResult Index()
{
return View();
}
it simply redirects me to the login page even if the user is logged in and is in the correct role.
now if i change the AuthenticationScheme to be:
[Authorize(Roles = "admin", AuthenticationSchemes = "Identity.Application")]
public ActionResult Index()
{
return View();
}
everything works correctly and the user is authorized.
is there something i'm missing in the setup of the custom authentication scheme? how do i get my custom authentication scheme to work the same way Identity.Application does?
thanks
ps: i'm using asp.net core 3.0 preview 9
it appears that setting options.ForwardAuthenticate = "Identity.Application"; solves this problem:
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication().AddCookie("custom", options=> {
options.LoginPath = "/admin/in";
options.LogoutPath = "/admin/out";
options.AccessDeniedPath= "/admin/in";
options.ForwardAuthenticate = "Identity.Application";
});
}
this should really be documented
It is necessary to give the system the ability to authorize users using local accounts (like a regular identity from the box), and using Active Directory accounts (via an LDAP provider any). How can this be done in the ASP.Net Core project, and how can I register such an authorization method in the system (meaning Startup class)? In previous versions, as far as I know, it could be solved using FormAuthentication, but before logging on, check the user in one of the providers (you can force the user to specify in advance the type of his account). I do not know how to do this in ASP.Net Core and I have not found similar examples on the network.
System.DirectoryServices is not available in ASP.NET Core yet as of today. You can read more here. However, we can use Novell.Directory.Ldap.NETStandard.
public bool ValidateUser(string domainName, string username, string password)
{
string userDn = $"{username}#{domainName}";
try
{
using (var connection = new LdapConnection {SecureSocketLayer = false})
{
connection.Connect(domainName, LdapConnection.DEFAULT_PORT);
connection.Bind(userDn, password);
if (connection.Bound)
return true;
}
}
catch (LdapException ex)
{
// Log exception
}
return false;
}
For authentication and authorization, we can use Cookie Authentication Middleware with claims.
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
ILoggerFactory loggerFactory)
{
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationScheme = "AuthenticationScheme",
LoginPath = new PathString("/Account/Login"),
AccessDeniedPath = new PathString("/Common/AccessDenied"),
AutomaticAuthenticate = true,
AutomaticChallenge = true
});
}
It has few moving pieces, so I created a working sample project at GitHub.
Based OpenidConnect specification the standard types for role claim and name claim is role and name. However in .net core System.Security.Claims.ClaimsIdentity.NameClaimType is set to "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" and System.Security.Claims.ClaimsIdentity.RoleClaimType is set to "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
My issue here is with role.
My ASP.NET core application is using OpenIdConnect for authentication. After successful authentication the OpenIdConnect provider sends back the role as a part of claims collection with Cliam.Type is set to role which is correct as per the OpenId specs.
However since .Net Core has its own type for role, IsInRole() method always returns false. Because I think IsInRole() method uses microsoft's role type for comparison.
Why .net is using differ types for claims instead of using standard convention? and how do I solve IsInRole() issue
Update 1
Well I tried configuring claim types during startup but it didn't work.
startup.cs
public class Startup
{
public Startup(IHostingEnvironment env)
{
// some stuff here that is not related to Identity like building configuration
}
public IConfigurationRoot Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddApplicationInsightsTelemetry(Configuration);
services.AddAuthorization();
services.AddMvc(config =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
});
// Add Kendo UI services to the services container
services.AddKendo();
// Transform Microsoft cliam types to my claim type
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.ClaimsIdentity.RoleClaimType = "role";
options.ClaimsIdentity.UserNameClaimType = "name";
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, IApplicationLifetime appLifetime)
{
loggerFactory.AddSerilog();
appLifetime.ApplicationStopped.Register(Log.CloseAndFlush);
app.UseExceptionHandler("/Home/Error");
app.UseApplicationInsightsRequestTelemetry();
app.UseApplicationInsightsExceptionTelemetry();
app.UseStaticFiles();
app.UseIdentityServer(Configuration["Identity:Authority"], Configuration["Identity:ClientId"], Configuration["Identity:PostLogoutRedirectUri"]);
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
// Configure Kendo UI
app.UseKendo(env);
}
}
UseIdentityServer extension method
public static void UseIdentityServer(this IApplicationBuilder app, string authority, string clientId, string postlogoutRedirectUri)
{
app.UseCookieAuthentication(new CookieAuthenticationOptions()
{
AutomaticAuthenticate = true,
AutomaticChallenge = true,
AuthenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme,
LoginPath = IdentityConstant.CallbackPath,
AccessDeniedPath = new PathString(IdentityConstant.AccessDeniedPath),
CookieName = IdentityConstant.AuthenticationCookieName,
});
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap = new Dictionary<string, string>();
var connectOptions = new OpenIdConnectOptions()
{
AutomaticChallenge = true,
Authority = authority,
ClientId = clientId,
ResponseType = IdentityConstant.ResponseType,
AuthenticationScheme = IdentityConstant.OpenIdAuthenticationScheme,
SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme,
PostLogoutRedirectUri = postlogoutRedirectUri,
CallbackPath = IdentityConstant.CallbackPath,
Events = new OpenIdConnectEvents()
{
OnTokenValidated = async context =>
{
var userInfoClient = new UserInfoClient(context.Options.Authority + IdentityConstant.UserInfoEndpoint);
var response = await userInfoClient.GetAsync(context.ProtocolMessage.AccessToken);
var claims = response.Claims;
//We will create new identity to store only required claims.
var newIdentity = new ClaimsIdentity(context.Ticket.Principal.Identity.AuthenticationType);
// keep the id_token for logout
newIdentity.AddClaim(new Claim(IdentityConstant.IdTokenClaim, context.ProtocolMessage.IdToken));
// add userinfo claims
newIdentity.AddClaims(claims);
// overwrite existing authentication ticket
context.Ticket = new AuthenticationTicket(
new ClaimsPrincipal(newIdentity),
context.Ticket.Properties,
context.Ticket.AuthenticationScheme);
await Task.FromResult(0);
}
}
};
connectOptions.Scope.Add(IdentityConstant.OpenIdScope);
connectOptions.Scope.Add(IdentityConstant.ProfileScope);
connectOptions.Scope.Add("roles");
app.UseOpenIdConnectAuthentication(connectOptions);
}
Update 2
I use IdentityServer3 for authentication for all our applications. If the client application is developed using classic ASP.NET MVC then ASP.Net's JWT handler will transform incoming role claim type to http://schemas.microsoft.com/ws/2008/06/identity/claims/role (More details can be found here under Claims Transformation section)
However same is not true when client application is developed using ASP.NET Core. The .net core WILL NOT transform claimtypes to .Net claim type, and that is correct. However .Net Core internally uses .Net claim type to find is user's role claims.
That means I need to Transform .Net claim types to desired claim type, but not sure where?
What are the standard conventions? You're only thinking of it from the context of the OpenId Connect specification which is not the only identity standard out there. Microsoft have made it generic enough to support all identity systems.
The fault here seems to be in the OpenId Connect authentication implementation for not providing a ClaimsPrincipal that uses the correct claim type for role.
Having said that you can fix it by implementing your own ClaimsPrincipal and override the IsInRole() method to use the correct claim type.
Alternatively you might consider putting in a place some middleware to apply the appropriate role claims based on the OpenId claims coming back?
You can configure the claim types during application startup.
services.AddIdentity<ApplicationUser, IdentityRole>(options => {
options.ClaimsIdentity.RoleClaimType = "http://yourdesiredclaimtype";
options.ClaimsIdentity.UserNameClaimType = "http://yourdesiredclaimtype";
});
You can see the claim options on GitHub.
I'm trying to implement authentication and authorization in an MVC 5.1 app. The authentication takes place via Facebook that is custom implemented. (I can post that code if needed.) Once FB authenticates and sends back the code and the Authenticate method of the auth service is called to sign the user into the application. There is no auth code in the application itself (thus not using Identity or other membership services).
public async Task<ActionResult> Connect(string code)
{
if (code == null)
{
return RedirectToAction("Index", "Home");
}
else
{
// get access token
var accessToken = await nApplication.FacebookClient.AccessTokenAsync(code);
// get user info from facebook
var meResult = await nApplication.FacebookClient.MeResultAsync(accessToken);
nApplication.NRepository.SaveChanges();
nAuthorization.Authenticate(member);
return RedirectToAction("Index");
}
}
nAuthorization.Authenticate(member); creates a list of claims and executes OWIN SignIn,
claims.Add(new Claim(ClaimTypes.Name, member.Name));
claims.Add(new Claim(ClaimTypes.Role, "Member"));
var claimIdentity = new ClaimsIdentity(claims, DefaultAuthenticationTypes.ApplicationCookie);
owinContext.Authentication.SignIn(new AuthenticationProperties { IsPersistent = true }, claimIdentity);
I'm using the Authorize attribute from Mvc namespace. But at this point /Profile/Authenticate/ which is my Owin LoginPath get's called again and again to redirect the user to FB and return to the Connect method above.
[Authorize(Roles = "Member")]
public async Task<ActionResult> Index(int? id)
I've checked the User property in the controller and it is not authenticated. I could set that to a new ClaimsPrincipal but I'd like the auth code to be independent of the HttpContext. And it doesn't seem to be right solution.
My Startup class contains:
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
LoginPath = new PathString("/Profile/Authenticate/"),
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
CookieSecure = CookieSecureOption.Always,
ReturnUrlParameter = "next"
});
Maybe I am missing something completely fundamental? Any pointers would help, I've looked through articles such as the following but to no avail:
http://brockallen.com/2013/10/24/a-primer-on-owin-cookie-authentication-middleware-for-the-asp-net-developer/
http://www.khalidabuhakmeh.com/asp-net-mvc-5-authentication-breakdown-part-deux
I think this will solve your problem...it worked for me:
Add an empty method in your global.asax.cs file:
protected void Session_Start()
{
}
for some reason, the asp.net session cookie does not get set at the proper time without this. The Thinktecture devs think this might be happening if your webapp uses http and your identity provider uses https but I have not verified that yet.