.net Core Authentication - asp.net-core

I wanted to implement forms authentication with membership in my asp.net MVC Core application.
We had forms authentication setup in our previous application as below and wanted to use the same in .net core.
[HttpPost]
public ActionResult Login(LoginModel model, string returnUrl)
{
if (!this.ModelState.IsValid)
{
return this.View(model);
}
//Authenticate
if (!Membership.ValidateUser(model.UserName, model.Password))
{
this.ModelState.AddModelError(string.Empty, "The user name or
password provided is incorrect.");
return this.View(model);
}
else
{
FormsAuthentication.SetAuthCookie(model.UserName, model.RememberMe);
return this.RedirectToAction("Index", "Home");
}
return this.View(model);
}
In my config:
<membership defaultProvider="ADMembership">
<providers>
<add name="ADMembership"
type="System.Web.Security.ActiveDirectoryMembershipProvider"
connectionStringName="ADConnectionString"
attributeMapUsername="sAMAccountName" />
</providers>
</membership>
So we are using active directory here in membership.
Is this still applicable in .net core.
If not what else is available in .net core for forms authentication and AD.
Would appreciate inputs.

Yes you can do that in Core MVC application. You enable form authentication and use LDAP as user store at the back-end.
Here is how I set things up, to give you start:
Startup.cs
public class Startup
{
...
public void ConfigureServices(IServiceCollection services)
{
...
// Read LDAP settings from appsettings
services.Configure<LdapConfig>(this.Configuration.GetSection("ldap"));
// Define an interface for authentication service,
// We used Novell.Directory.Ldap as implementation.
services.AddScoped<IAuthenticationService, LdapAuthenticationService>();
// Global filter is enabled to protect the whole site
services.AddMvc(config =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
...
});
// Form authentication and cookies settings
var cookiesConfig = this.Configuration.GetSection("cookies").Get<CookiesConfig>();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = cookiesConfig.CookieName;
options.LoginPath = cookiesConfig.LoginPath;
options.LogoutPath = cookiesConfig.LogoutPath;
options.AccessDeniedPath = cookiesConfig.AccessDeniedPath;
options.ReturnUrlParameter = cookiesConfig.ReturnUrlParameter;
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// Redirects all HTTP requests to HTTPS
if (env.IsProduction())
{
app.UseRewriter(new RewriteOptions()
.AddRedirectToHttpsPermanent());
}
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error");
}
app.UseStaticFiles();
app.UseStatusCodePagesWithReExecute("/error", "?code={0}");
app.UseAuthentication();
app.UseMvc(routes =>
{
...
});
}
}
appsettings.json
{
"connectionStrings": {
"appDbConnection": xxx
},
"ldap": {
"url": "xxx.loc",
"bindDn": "CN=Users,DC=xxx,DC=loc",
"username": "xxx",
"password": "xxx",
"searchBase": "DC=xxx,DC=loc",
"searchFilter": "(&(objectClass=user)(objectClass=person)(sAMAccountName={0}))"
},
"cookies": {
"cookieName": "xxx",
"loginPath": "/account/login",
"logoutPath": "/account/logout",
"accessDeniedPath": "/account/accessDenied",
"returnUrlParameter": "returnUrl"
}
}
IAuthenticationService.cs
namespace DL.SO.Services.Core
{
public interface IAuthenticationService
{
IAppUser Login(string username, string password);
}
}
LdapAuthenticationService.cs
Ldap implementation of authentication service, using Novell.Directory.Ldap library to talk to active directory. You can Nuget that library.
using Microsoft.Extensions.Options;
using Novell.Directory.Ldap;
...
using DL.SO.Services.Core;
namespace DL.SO.Services.Security.Ldap
{
public class LdapAuthenticationService : IAuthenticationService
{
private const string MemberOfAttribute = "memberOf";
private const string DisplayNameAttribute = "displayName";
private const string SAMAccountNameAttribute = "sAMAccountName";
private const string MailAttribute = "mail";
private readonly LdapConfig _config;
private readonly LdapConnection _connection;
public LdapAuthenticationService(IOptions<LdapConfig> configAccessor)
{
// Config from appsettings, injected through the pipeline
_config = configAccessor.Value;
_connection = new LdapConnection();
}
public IAppUser Login(string username, string password)
{
_connection.Connect(_config.Url, LdapConnection.DEFAULT_PORT);
_connection.Bind(_config.Username, _config.Password);
var searchFilter = String.Format(_config.SearchFilter, username);
var result = _connection.Search(_config.SearchBase, LdapConnection.SCOPE_SUB, searchFilter,
new[] { MemberOfAttribute, DisplayNameAttribute, SAMAccountNameAttribute, MailAttribute }, false);
try
{
var user = result.next();
if (user != null)
{
_connection.Bind(user.DN, password);
if (_connection.Bound)
{
var accountNameAttr = user.getAttribute(SAMAccountNameAttribute);
if (accountNameAttr == null)
{
throw new Exception("Your account is missing the account name.");
}
var displayNameAttr = user.getAttribute(DisplayNameAttribute);
if (displayNameAttr == null)
{
throw new Exception("Your account is missing the display name.");
}
var emailAttr = user.getAttribute(MailAttribute);
if (emailAttr == null)
{
throw new Exception("Your account is missing an email.");
}
var memberAttr = user.getAttribute(MemberOfAttribute);
if (memberAttr == null)
{
throw new Exception("Your account is missing roles.");
}
return new AppUser
{
DisplayName = displayNameAttr.StringValue,
Username = accountNameAttr.StringValue,
Email = emailAttr.StringValue,
Roles = memberAttr.StringValueArray
.Select(x => GetGroup(x))
.Where(x => x != null)
.Distinct()
.ToArray()
};
}
}
}
finally
{
_connection.Disconnect();
}
return null;
}
}
}
AccountController.cs
Then finally after the user is verified, you need to construct the principal from the user claims for sign in process, which would generate the cookie behind the scene.
public class AccountController : Controller
{
private readonly IAuthenticationService _authService;
public AccountController(IAuthenticationService authService)
{
_authService = authService;
}
...
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginViewModel model)
{
if (ModelState.Valid)
{
try
{
var user = _authService.Login(model.Username, model.Password);
if (user != null)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, user.Username),
new Claim(CustomClaimTypes.DisplayName, user.DisplayName),
new Claim(ClaimTypes.Email, user.Email)
}
// Roles
foreach (var role in user.Roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
// Construct Principal
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, _authService.GetType().Name));
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = model.RememberMe
}
);
return Redirect(Url.IsLocalUrl(model.ReturnUrl)
? model.ReturnUrl
: "/");
}
ModelState.AddModelError("", #"Your username or password is incorrect.");
}
catch(Exception ex)
{
ModelState.AddModelError("", ex.Message);
}
}
return View(model);
}
}

Would this post help you integrate with AD for Authentication and Authorization?
MVC Core How to force / set global authorization for all actions?
The idea is add authentication within ConfigureServices method in Startup.cs file:
services.AddMvc(config =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireRole([Your AD security group name in here without domain name]) // This line adds authorization to users in the AD group only
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
});

In Asp.Net Core the Authentication is controlled through project properties.
Open the solution. Right click on the Project and Click Properties.
Click the Debug tab. Check the Enable Windows Authentication checkbox. Ensure Anonymous Authentication is disabled.
Here is Microsoft's document, https://learn.microsoft.com/en-us/aspnet/core/security/authentication/windowsauth
Cheers!

Related

OpenIddict Roles/Policy returns 403 Forbidden

Good morning. I am having an issue with Authorize in controllers and using Roles and / or Policy set up. It is always returning a 403 forbidden, with the following in the logs:
info: OpenIddict.Server.OpenIddictServerDispatcher[0]
The request address matched a server endpoint: Token.
info: OpenIddict.Server.OpenIddictServerDispatcher[0]
The token request was successfully extracted: {
"grant_type": "password",
"username": "Administrator#MRM2Inc.com",
"password": "[redacted]"
}.
info: OpenIddict.Server.OpenIddictServerDispatcher[0]
The token request was successfully validated.
info: OpenIddict.Server.OpenIddictServerDispatcher[0]
The response was successfully returned as a JSON document: {
"access_token": "[redacted]",
"token_type": "Bearer",
"expires_in": 3600
}.
info: OpenIddict.Validation.OpenIddictValidationDispatcher[0]
The response was successfully returned as a challenge response: {
"error": "insufficient_access",
"error_description": "The user represented by the token is not allowed to perform the requested action.",
"error_uri": "https://documentation.openiddict.com/errors/ID2095"
}.
info: OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandler[13]
AuthenticationScheme: OpenIddict.Validation.AspNetCore was forbidden.
If I remove the Roles = or Policy = from the Authorize Tag it works. I have the project setup as follows:
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddDbContext<IdentDbContext>(options =>
{
options.UseSqlServer(
Configuration.GetConnectionString("IdentityDB"));
options.UseOpenIddict();
});
// Add the Identity Services we are going to be using the Application Users and the Application Roles
services.AddIdentity<ApplicationUsers, ApplicationRoles>(config =>
{
config.SignIn.RequireConfirmedEmail = true;
config.SignIn.RequireConfirmedAccount = true;
config.User.RequireUniqueEmail = true;
config.Lockout.MaxFailedAccessAttempts = 3;
}).AddEntityFrameworkStores<IdentDbContext>()
.AddUserStore<ApplicationUserStore>()
.AddRoleStore<ApplicationRoleStore>()
.AddRoleManager<ApplicationRoleManager>()
.AddUserManager<ApplicationUserManager>()
.AddErrorDescriber<ApplicationIdentityErrorDescriber>()
.AddDefaultTokenProviders()
.AddDefaultUI();
services.AddDataLibrary();
// Configure Identity to use the same JWT claims as OpenIddict instead
// of the legacy WS-Federation claims it uses by default (ClaimTypes),
// which saves you from doing the mapping in your authorization controller.
services.Configure<IdentityOptions>(options =>
{
options.ClaimsIdentity.UserNameClaimType = Claims.Name;
options.ClaimsIdentity.UserIdClaimType = Claims.Subject;
options.ClaimsIdentity.RoleClaimType = Claims.Role;
});
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
// Add in the email
var emailConfig = Configuration.GetSection("EmailConfiguration").Get<EmailConfiguration>();
services.AddSingleton(emailConfig);
services.AddEmailLibrary();
services.AddAuthorization(option =>
{
option.AddPolicy("SiteAdmin", policy => policy.RequireClaim("Site Administrator"));
});
services.AddOpenIddict()
// Register the OpenIddict core components.
.AddCore(options =>
{
// Configure OpenIddict to use the Entity Framework Core stores and models.
// Note: call ReplaceDefaultEntities() to replace the default entities.
options.UseEntityFrameworkCore()
.UseDbContext<IdentDbContext>()
/*.ReplaceDefaultEntities<Guid>()*/;
})
// Register the OpenIddict server components.
.AddServer(options =>
{
// Enable the token endpoint. What other endpoints?
options.SetLogoutEndpointUris("/api/LogoutPost")
.SetTokenEndpointUris("/Token");
// Enable the client credentials flow. Which flow do I need?
options.AllowPasswordFlow();
options.AcceptAnonymousClients();
options.DisableAccessTokenEncryption();
// Register the signing and encryption credentials.
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
// Register the ASP.NET Core host and configure the ASP.NET Core options.
options.UseAspNetCore()
.EnableLogoutEndpointPassthrough()
.EnableTokenEndpointPassthrough();
})
// Register the OpenIddict validation components.
.AddValidation(options =>
{
// Import the configuration from the local OpenIddict server instance.
options.UseLocalServer();
// Register the ASP.NET Core host.
options.UseAspNetCore();
});
// Register the Swagger generator, defining 1 or more Swagger documents
services.AddSwaggerGen(swagger =>
{
swagger.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme()
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "JWT Authorization header using the Bearer scheme. \r\n\r\n Enter 'Bearer'[space] and then your token in the text input below.\r\n\r\nExample: \"Bearer 12345abcdef\""
});
swagger.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new string[] {}
}
});
swagger.OperationFilter<SwaggerDefaultValues>();
swagger.OperationFilter<AuthenticationRequirementOperationFilter>();
// Set the comments path for the Swagger JSON and UI.
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
swagger.IncludeXmlComments(xmlPath);
});
services.AddApiVersioning();
services.AddVersionedApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVVV";
options.DefaultApiVersion = ApiVersion.Parse("0.6.alpha");
options.AssumeDefaultVersionWhenUnspecified = true;
});
services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVersionDescriptionProvider provider)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/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.UseHttpsRedirection();
app.UseStaticFiles();
// Enable middleware to serve generated Swagger as a JSON endpoint.
app.UseSwagger();
// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.),
// specifying the Swagger JSON endpoint.
app.UseSwaggerUI(c =>
{
c.DisplayOperationId();
var versionDescription = provider.ApiVersionDescriptions;
foreach (var description in provider.ApiVersionDescriptions.OrderByDescending(_ => _.ApiVersion))
{
c.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", $"MRM2 Identity API {description.GroupName}");
}
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
AuthorizationController.cs
/// <summary>
/// Controls the Authorization aspects of the API
/// </summary>
[Route("api/[controller]/[action]")]
[ApiController]
[ApiVersion("0.8.alpha")]
[Produces(MediaTypeNames.Application.Json)]
public class AuthorizationController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IdentDbContext _context;
private readonly ApplicationUserManager _userManager;
private readonly ApplicationRoleManager _roleManager;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IOpenIddictAuthorizationManager _authorizationManager;
private readonly IOpenIddictScopeManager _scopeManager;
private readonly SignInManager<ApplicationUsers> _signInManager;
private HttpClient _client;
public AuthorizationController(IConfiguration configuration, IdentDbContext context, ApplicationUserManager userManager,
ApplicationRoleManager roleManager, IOpenIddictApplicationManager applicationManager, IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager, SignInManager<ApplicationUsers> signInManager)
{
_configuration = configuration;
_context = context;
_userManager = userManager;
_roleManager = roleManager;
_applicationManager = applicationManager;
_authorizationManager = authorizationManager;
_scopeManager = scopeManager;
_signInManager = signInManager;
}
[HttpPost("/token"), Produces("application/json")]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
ClaimsPrincipal claimsPrincipal;
if (request.IsPasswordGrantType())
{
var user = await _userManager.FindByNameAsync(request.Username);
var roleList = await _userManager.GetRolesListAsync(user);
var databaseList = await _userManager.GetDatabasesAsync(user);
string symKey = _configuration["Jwt:Symmetrical:Key"];
string jwtSub = _configuration["Jwt:Subject"];
string issuer = _configuration["Jwt:Issuer"];
string audience = _configuration["Jwt:Audience"];
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, jwtSub, issuer),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(), issuer),
new Claim(ClaimTypes.Name, user.UserName, issuer)
};
foreach (var role in roleList)
{
claims.Add(new Claim(ClaimTypes.Role, role.Name));
}
foreach (var database in databaseList)
{
claims.Add(new Claim(type: "DatabaseName", database));
}
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
identity.AddClaim(OpenIddictConstants.Claims.Name, user.UserName, OpenIddictConstants.Destinations.AccessToken);
identity.AddClaim(OpenIddictConstants.Claims.Subject, jwtSub, OpenIddictConstants.Destinations.AccessToken);
identity.AddClaim(OpenIddictConstants.Claims.Audience, audience, OpenIddictConstants.Destinations.AccessToken);
foreach (var cl in claims)
{
identity.AddClaim(cl.Type, cl.Value);
}
claimsPrincipal = new ClaimsPrincipal(identity);
// Set the list of scopes granted to the client application.
claimsPrincipal.SetScopes(new[]
{
Scopes.OpenId,
Scopes.Email,
Scopes.Profile,
Scopes.Roles
}.Intersect(request.GetScopes()));
foreach (var claim in claimsPrincipal.Claims)
{
claim.SetDestinations(GetDestinations(claim, claimsPrincipal));
}
return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{
// Retrieve the claims principal stored in the authorization code/device code/refresh token.
var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
// Retrieve the user profile corresponding to the authorization code/refresh token.
// Note: if you want to automatically invalidate the authorization code/refresh token
// when the user password/roles change, use the following line instead:
// var user = _signInManager.ValidateSecurityStampAsync(info.Principal);
var user = await _userManager.GetUserAsync(principal);
if (user == null)
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid."
}));
}
// Ensure the user is still allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in."
}));
}
foreach (var claim in principal.Claims)
{
claim.SetDestinations(GetDestinations(claim, principal));
}
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
throw new InvalidOperationException("The specified grant type is not supported.");
}
private IEnumerable<string> GetDestinations(Claim claim, ClaimsPrincipal principal)
{
// Note: by default, claims are NOT automatically included in the access and identity tokens.
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
switch (claim.Type)
{
case Claims.Name:
yield return Destinations.AccessToken;
if (principal.HasScope(Scopes.Profile))
yield return Destinations.IdentityToken;
yield break;
case Claims.Email:
yield return Destinations.AccessToken;
if (principal.HasScope(Scopes.Email))
yield return Destinations.IdentityToken;
yield break;
case Claims.Role:
yield return Destinations.AccessToken;
if (principal.HasScope(Scopes.Roles))
yield return Destinations.IdentityToken;
yield break;
// Never include the security stamp in the access and identity tokens, as it's a secret value.
case "AspNet.Identity.SecurityStamp": yield break;
default:
yield return Destinations.AccessToken;
yield break;
}
}
}
RolesController.cs
/// <summary>
/// Controls the actions for roles within the API
/// </summary>
/// <response code="401">If the user did not login correctly or
/// does not have the correct permissions</response>
[Route("api/[controller]")]
[ApiController]
[ApiVersion("0.8.alpha")]
[Produces(MediaTypeNames.Application.Json)]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, Roles = "Site Administrator")] //If I change this to Policy = "SiteAdmin" still does not work. If I remove the Roles completely it works.
public class RolesController : ControllerBase
{
private readonly ApplicationRoleManager _roleManager;
private readonly ILogger<RolesController> _logger;
private readonly IApplicationDatabaseData _databaseData;
public RolesController(ApplicationRoleManager roleManager, ILogger<RolesController> logger, IApplicationDatabaseData databaseData)
{
_roleManager = roleManager;
_logger = logger;
_databaseData = databaseData;
}
/// <summary>
/// Gets a List of all the Roles
/// </summary>
/// <returns>A list of ApplicationRoles</returns>
/// <response code="200">Returns the list</response>
/// <response code="404">If the list is empty</response>
[HttpGet("ListRoles", Name = nameof(ListRolesAsync))]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme/*, Roles = "Site Administrator"*/)] //Currently commented out until Roles work.
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IList<ApplicationRoles>>> ListRolesAsync()
{
var roles = await _roleManager.GetAllRoles();
if (!roles.Any())
{
return NotFound();
}
else
{
var output = roles;
return Ok(output);
}
}
}
What I noticed that when stepping through this in debugging except for the Claims.Name when going through GetDestinations all claims hit the default switch case. So I am not sure where in the startup or authorizationcontoller I went wrong. But I am pretty sure my issue is within there.
What have I missed to get my Roles and / or Policy to work correctly within the Controllers?
Update to the AuthorizationController allowed this to work. New section of the AuthorizationContoller for the Exchange method is as follows (still a work in progress, but now a working work in progress):
[HttpPost("/token"), Produces("application/json")]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
ClaimsPrincipal claimsPrincipal;
if (request.IsClientCredentialsGrantType())
{
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
identity.AddClaim(OpenIddictConstants.Claims.Subject, request.ClientId ?? throw new InvalidOperationException());
identity.AddClaim("some-claim", "some-value", OpenIddictConstants.Destinations.AccessToken);
claimsPrincipal = new ClaimsPrincipal(identity);
claimsPrincipal.SetScopes(request.GetScopes());
}
if (request.IsPasswordGrantType())
{
var user = await _userManager.FindByNameAsync(request.Username);
var roleList = await _userManager.GetRolesListAsync(user);
var databaseList = await _userManager.GetDatabasesAsync(user);
string symKey = _configuration["Jwt:Symmetrical:Key"];
string jwtSub = _configuration["Jwt:Subject"] + " " + user.Id;
string issuer = _configuration["Jwt:Issuer"];
string audience = _configuration["Jwt:Audience"];
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, jwtSub, issuer),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(), issuer),
new Claim(ClaimTypes.Name, user.UserName, issuer)
};
foreach (var role in roleList)
{
claims.Add(new Claim(ClaimTypes.Role, role.Name, ClaimValueTypes.String, issuer));
}
foreach (var database in databaseList)
{
claims.Add(new Claim(type: "DatabaseName", database, ClaimValueTypes.String, issuer));
}
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
identity.AddClaim(OpenIddictConstants.Claims.Name, user.UserName, OpenIddictConstants.Destinations.AccessToken);
identity.AddClaim(OpenIddictConstants.Claims.Subject, jwtSub, OpenIddictConstants.Destinations.AccessToken);
identity.AddClaim(OpenIddictConstants.Claims.Audience, audience, OpenIddictConstants.Destinations.AccessToken);
foreach (var cl in claims)
{
if (cl.Type == ClaimTypes.Role)
{
identity.AddClaim(OpenIddictConstants.Claims.Role, cl.Value, OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken);
}
identity.AddClaim(cl.Type, cl.Value, OpenIddictConstants.Destinations.IdentityToken);
}
claimsPrincipal = new ClaimsPrincipal(identity);
// Set the list of scopes granted to the client application.
claimsPrincipal.SetScopes(new[]
{
Scopes.OpenId,
Scopes.Email,
Scopes.Profile,
Scopes.Roles
}.Intersect(request.GetScopes()));
foreach (var claim in claimsPrincipal.Claims)
{
claim.SetDestinations(GetDestinations(claim, claimsPrincipal));
}
return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{
// Retrieve the claims principal stored in the authorization code/device code/refresh token.
var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
// Retrieve the user profile corresponding to the authorization code/refresh token.
// Note: if you want to automatically invalidate the authorization code/refresh token
// when the user password/roles change, use the following line instead:
// var user = _signInManager.ValidateSecurityStampAsync(info.Principal);
var user = await _userManager.GetUserAsync(principal);
if (user == null)
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid."
}));
}
// Ensure the user is still allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in."
}));
}
foreach (var claim in principal.Claims)
{
claim.SetDestinations(GetDestinations(claim, principal));
}
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
throw new InvalidOperationException("The specified grant type is not supported.");
}

Custom Authorization attribute doesn't allow authorize in asp.net core 3

.Net core app authenticates with IdentityServer. I'm working without https.
I created custom authorization attribute, but it doesn't allow authorize. And I can't catch a problem.
Logs:
dbug: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[8]
AuthenticationScheme: Cookies was successfully authenticated.
Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: Cookies was successfully authenticated.
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
Authorization failed.
Microsoft.AspNetCore.Authorization.DefaultAuthorizationService: Information: Authorization failed.
info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[13]
AuthenticationScheme: Cookies was forbidden.
Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Information: AuthenticationScheme: Cookies was forbidden.
info: Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler[13]
AuthenticationScheme: oidc was forbidden.
Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler: Information: AuthenticationScheme: oidc was forbidden.
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
services.AddAntiforgery(options =>
{
options.Cookie.HttpOnly = false;
options.HeaderName = "XSRF-TOKEN";
});
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies", options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.Name = "app";
options.ExpireTimeSpan = TimeSpan.FromMinutes(20);
options.SlidingExpiration = true;
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = $"{OpenId["ServerUrl"]}";
options.RequireHttpsMetadata = false;
options.ClientId = OpenId["ClientId"];
options.ClientSecret = OpenId["ClientSecret"];
options.ResponseType = "code";
options.UsePkce = true;
options.SignInScheme = "Cookies";
options.CallbackPath = "/signin-callback";
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("offline_access");
options.Scope.Add("profile");
options.Scope.Add("role");
options.Scope.Add("myapp");
options.ClaimActions.MapJsonKey("website", "website");
options.ClaimActions.MapJsonKey(UserClaimTypes.Role, UserClaimTypes.Role);
options.ClaimActions.MapJsonKey(UserClaimTypes.UserId, UserClaimTypes.UserId);
options.SaveTokens = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = UserClaimTypes.Role
};
options.Events = new OpenIdConnectEvents
{
OnTicketReceived = (e) =>
{
e.Properties.IsPersistent = true;
e.Properties.ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(60);
return Task.CompletedTask;
}
};
});
services.AddSingleton<IAuthorizationPolicyProvider, AuthPolicyProvider>();
services.AddSingleton<IAuthorizationHandler, ArticlePermissionHandler>();
services.AddControllersWithViews(o =>
{
o.Filters.Add(typeof(ModelStateValidator));
o.InputFormatters.Insert(0, GetJsonPatchInputFormatter());
})
.AddNewtonsoftJson();
}
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IDistributedCache cache,
IHttpContextAccessor httpContextAccessor,
ILogger<Startup> logger,
IAntiforgery antiforgery)
{
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapControllers();
});
ArticlePermissionRequirement.cs
using System;
using Microsoft.AspNetCore.Authorization;
namespace App.Application.Services.Authorization.Policies.Requirements
{
public class ArticlePermissionRequirement : IAuthorizationRequirement
{
public string PermissionName { get; }
public ArticlePermissionRequirement(string permissionName)
{
PermissionName = permissionName ?? throw new ArgumentNullException(nameof(permissionName));
}
}
}
AuthPolicyProvider.cs
using System.Threading.Tasks;
using App.Application.Services.Authorization.Operations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace App.Application.Services.Authorization.Policies.Providers
{
public class AuthPolicyProvider : DefaultAuthorizationPolicyProvider
{
public AuthPolicyProvider(IOptions<AuthorizationOptions> options) : base(options)
{
}
public override async Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
{
var policy = await base.GetPolicyAsync(policyName);
if (policy == null)
{
policy = new AuthorizationPolicyBuilder()
.AddRequirements(RequirementOperations.Requirement(policyName))
.Build();
}
return policy;
}
}
}
ArticlePermissionHandler.cs
namespace App.Application.Services.Authorization.Policies.Handlers
{
public class ArticlePermissionHandler : AuthorizationHandler<ArticlePermissionRequirement, Owner>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ArticlePermissionRequirement requirement,
Owner resource)
{
// for test purpose (always true) I putted this
context.Succeed(requirement);
var user = context.User;
if (user == null)
{
return Task.CompletedTask;
}
var roleClaim = user.Claims.FirstOrDefault(
c => c.Type == ClaimTypes.Role
)?.Value;
if (roleClaim == null)
{
return Task.CompletedTask;
}
// Administrator and Manager can do all things
if (user.IsInRole(Roles.Administrator) || user.IsInRole(Roles.Manager))
{
context.Succeed(requirement);
}
// Moderator can read, create, update all articles
else if (Equals(requirement.PermissionName, Permissions.ReadArticle)
|| Equals(requirement.PermissionName, Permissions.CreateArticle)
|| Equals(requirement.PermissionName, Permissions.UpdateArticle))
{
if (user.IsInRole(Roles.Moderator))
{
context.Succeed(requirement);
}
}
// Writer can create new article
else if (Equals(requirement.PermissionName, Permissions.CreateArticle))
{
if (user.IsInRole(Roles.Writer))
{
context.Succeed(requirement);
}
}
// Onwer can read, update, delete self article
else if (Equals(requirement.PermissionName, Permissions.ReadArticle)
|| Equals(requirement.PermissionName, Permissions.UpdateArticle)
|| Equals(requirement.PermissionName, Permissions.DeleteArticle))
{
ClaimsPrincipal claimUser = context.User;
var userClaimExtractor = new UserClaimExtractor(claimUser);
var userId = userClaimExtractor.GetId();
if (resource.OwnerId == userId)
{
context.Succeed(requirement);
}
}
return Task.CompletedTask;
}
}
}
HasPermissionAttribute.cs
using Microsoft.AspNetCore.Authorization;
namespace App.Application.Services.Authorization.Policies.Attributes
{
public class HasPermissionAttribute : AuthorizeAttribute
{
public HasPermissionAttribute(string permissionName)
{
PermissionName = permissionName;
}
public string PermissionName
{
get
{
return Policy;
}
set
{
Policy = value;
}
}
}
}
Controller
public class DraftController : ApiController
{
public DraftController(IMediator mediator,
IHttpContextAccessor context) : base(mediator, context)
{
}
[HasPermission(Permissions.CreateArticle)]
[HttpPost("drafts")]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Drafts([FromBody] JsonPatchDocument<DraftDto> patchDoc)
{
...
}
But there is an interest thing - when I rewrite attribute with authorize:
[Authorize]
[HttpPost("drafts")]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Drafts(
Then app is authorized, and I can work with action Drafts. But with HasPermission attribute authorization always failed.
Where I can find a problem? Why the log writes: "Cookies was successfully authenticated" and then "Cookies was forbidden"?
Yes! I got! Problem was with resource Owner.
AuthorizationHandler<ArticlePermissionRequirement, Owner>
After remove this resource ArticlePermissionHandler started to work.
AuthorizationHandler<ArticlePermissionRequirement>
This info helped understand the problem.

Blazor authentication role based

I'm working on a client-side blazor application with the last webassembly version (3.2.0).
I started the project from the visual tool with enabling local authentications and I tried to add roles.
First, I added the roles in the ApplicationDbContext :
public class ApplicationDbContext : ApiAuthorizationDbContext<ApplicationUser>
{
public ApplicationDbContext(
DbContextOptions options,
IOptions<OperationalStoreOptions> operationalStoreOptions) : base(options, operationalStoreOptions)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<IdentityRole>()
.HasData(new IdentityRole { Name = "User", NormalizedName = "USER", Id = Guid.NewGuid().ToString(), ConcurrencyStamp = Guid.NewGuid().ToString() });
builder.Entity<IdentityRole>()
.HasData(new IdentityRole { Name = "Admin", NormalizedName = "ADMIN", Id = Guid.NewGuid().ToString(), ConcurrencyStamp = Guid.NewGuid().ToString() });
}
}
Then I added Roles to the IdentityBuilder in the startup class :
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
services.AddDefaultIdentity<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddIdentityServer()
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>();
services.AddAuthentication()
.AddIdentityServerJwt();
services.AddControllersWithViews();
services.AddRazorPages();
}
And then in my DbInitializer I created an Admin account with both roles :
private async Task SeedASPIdentityCoreAsync()
{
if (!await context.Users.AnyAsync())
{
var admin = new ApplicationUser()
{
UserName = "admin#admin.com",
Email = "admin#admin.com",
EmailConfirmed = true,
};
var result = await userManager.CreateAsync(admin, "aA&123");
if (!result.Succeeded)
{
throw new Exception(result.Errors.First().Description);
}
result = await userManager.AddClaimsAsync(admin, new Claim[]
{
new Claim(JwtClaimTypes.Email, "admin#admin.com"),
new Claim(JwtClaimTypes.Name, "admin#admin.com")
});
ApplicationUser user = await userManager.FindByNameAsync("admin#admin.com");
try
{
result = await userManager.AddToRoleAsync(user, "User");
result = await userManager.AddToRoleAsync(user, "Admin");
}
catch
{
await userManager.DeleteAsync(user);
throw;
}
if (!result.Succeeded)
{
await userManager.DeleteAsync(user);
throw new Exception(result.Errors.First().Description);
}
}
}
But the roles doen't appear in the JWT, and the client-side has no idea about the roles.
How can I add the roles in the JWT, as with the new version of blazor, there is no need of the LoginController ? (If i well understood the changes)
Ok I found what I needed :
1) Create a CustomUserFactory in your client App
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
public class CustomUserFactory
: AccountClaimsPrincipalFactory<RemoteUserAccount>
{
public CustomUserFactory(IAccessTokenProviderAccessor accessor)
: base(accessor)
{
}
public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
RemoteUserAccount account,
RemoteAuthenticationUserOptions options)
{
var user = await base.CreateUserAsync(account, options);
if (user.Identity.IsAuthenticated)
{
var identity = (ClaimsIdentity)user.Identity;
var roleClaims = identity.FindAll(identity.RoleClaimType);
if (roleClaims != null && roleClaims.Any())
{
foreach (var existingClaim in roleClaims)
{
identity.RemoveClaim(existingClaim);
}
var rolesElem = account.AdditionalProperties[identity.RoleClaimType];
if (rolesElem is JsonElement roles)
{
if (roles.ValueKind == JsonValueKind.Array)
{
foreach (var role in roles.EnumerateArray())
{
identity.AddClaim(new Claim(options.RoleClaim, role.GetString()));
}
}
else
{
identity.AddClaim(new Claim(options.RoleClaim, roles.GetString()));
}
}
}
}
return user;
}
}
2) Register the client factory
builder.Services.AddApiAuthorization()
.AddAccountClaimsPrincipalFactory<CustomUserFactory>();
3) In the Server App, call IdentityBuilder.AddRoles
services.AddDefaultIdentity<ApplicationUser>(options =>
options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
4) Configure Identity Server
services.AddIdentityServer()
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>(options => {
options.IdentityResources["openid"].UserClaims.Add("name");
options.ApiResources.Single().UserClaims.Add("name");
options.IdentityResources["openid"].UserClaims.Add("role");
options.ApiResources.Single().UserClaims.Add("role");
});
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");
There is an other way, by creating a ProfileService
5) User Authorization mechanisms :
<AuthorizeView Roles="admin">
Source : https://github.com/dotnet/AspNetCore.Docs/blob/master/aspnetcore/security/blazor/webassembly/hosted-with-identity-server.md#Name-and-role-claim-with-API-authorization

SignalR Core Custom Authentication - Context.User.Identity is null after user is authenticated in /negotiate

I wrote a custom authentication for SignalR Core. One of the feature is anonymous login. It will create new user if it's first time user connect. The code work but the problem is the authentication done after /myhub/negotiate is cleared and all the claims in Context.User.Identity is cleared again and IsAuthenticated change to false when the client request /myhub/. Only after that the claims in Context.User.Identity is not cleared. I tried to return Fail if it's request to /myhub/negotiate but then the client won't send request to /myhub/ if I do that.
Any idea on how to fix or work around this? Is my implement of custom authentication correct?
Here is the code of all the class I'm using:
public class CustomAuthRequirementHandler : AuthorizationHandler<CustomAuthRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, CustomAuthRequirement requirement)
{
string name = context.User.Claims.Where(p => p.Type == ClaimTypes.NameIdentifier).Select(p => p.Value).SingleOrDefault();
if (!context.User.Identity.IsAuthenticated)
context.Fail();
else
context.Succeed(requirement);
return Task.CompletedTask;
}
}
public class CustomAuthRequirement : IAuthorizationRequirement
{
}
public class MyAuthenticationHandler : AuthenticationHandler<MyOptions>
{
public MyAuthenticationHandler(IOptionsMonitor<MyOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock) { }
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (Context.User.Identity != null && Context.User.Identity.IsAuthenticated) return await Task.FromResult(
AuthenticateResult.Success(
new AuthenticationTicket(
new ClaimsPrincipal(Options.Identity),
new AuthenticationProperties(),
this.Scheme.Name)));
//if (Request.Path != "/myhub/") return await Task.FromResult(AuthenticateResult.Fail()); // only do authentication in /myhub/
var u = CreateNewUser(); // connect to db create new user
var claims = new List<Claim>() { };
claims.Add(new Claim(ClaimTypes.Name, u.Id.ToString()));
claims.Add(new Claim(ClaimTypes.NameIdentifier, u.Id.ToString()));
Options.Identity = new ClaimsIdentity(claims, "Custom");
var user = new ClaimsPrincipal(Options.Identity);
Context.User = user;
return await Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(user, new AuthenticationProperties(), this.Scheme.Name)));
}
}
public class MyOptions : AuthenticationSchemeOptions
{
public ClaimsIdentity Identity { get; set; }
public MyOptions()
{
}
}
The configuration code in ConfigureServices
services.AddSingleton<IAuthorizationHandler, CustomAuthRequirementHandler>();
services.AddAuthorization(p =>
{
p.AddPolicy("MainPolicy", builder =>
{
builder.Requirements.Add(new CustomAuthRequirement());
builder.AuthenticationSchemes = new List<string> { "MyScheme" };
});
});
services.AddAuthentication(o =>
{
o.DefaultScheme = "MyScheme";
}).AddScheme<MyOptions, MyAuthenticationHandler>("MyScheme", "MyScheme", p => { });
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
services.AddSignalR();
Configure code
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/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.UseAuthentication();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSignalR(routes =>
{
routes.MapHub<Hubs.MainHub>("/main");
});
app.UseMvc();
}
Edit: Added client side code
#page
#{
ViewData["Title"] = "Home page";
}
<input type="button" onclick="anonLogin()" value="AnonLogin" />
<script src="~/##aspnet/signalr/dist/browser/signalr.js"></script>
<script type="text/javascript">
var connection;
function anonLogin() {
var token = "anon";
connection = new signalR.HubConnectionBuilder().withUrl("/main?anon=" + token).build();
connection.start().then(function () {
console.log("Connection ok");
console.log("Sending message....");
connection.invoke("Test").catch(function (err) {
return console.error("Error sending message: " + err.toString());
});
}).catch(function (err) {
console.log("Connection error: " + err.toString());
return console.error(err.toString());
});
}
</script>
I ended up creating a fake claims for identity just for the call to /myhub/negotiate since this call is unimportant and it just needs the authentication to success so it can go to /myhub/.
var u = new DomainUser() { Id = -1 };
var claims = new List<Claim>() { };
claims.Add(new Claim(ClaimTypes.Name, u.Id.ToString()));
claims.Add(new Claim(ClaimTypes.NameIdentifier, u.Id.ToString()));
Options.Identity = new ClaimsIdentity(claims, "Custom");
var user = new ClaimsPrincipal(Options.Identity);
Context.User = user;
return await Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(Context.User, new AuthenticationProperties(), this.Scheme.Name)));

How to implement AD Group based authorization globally in asp.net core 2.x web application?

I am wondering if someone could point me a direction or an example which have the completed code for me to get an overall idea?
Thanks.
Update:
I only have following piece of code in Startup.cs and make sure windowsAutication is true in launchSettings.json.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddMvc(config =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
//.RequireRole(#"Departmental - Information Technology - Development") // Works
.RequireRole(#"*IT.Center of Excellence.Digital Workplace") // Error
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
});
}
I guess I have enabled Authentication and tries to authorize users who are within the specified AD group to have access to the application at global level.
If I use the commented RequireRole it works, but use the uncommented RequireRole it gives me this error:
Win32Exception: The trust relationship between the primary domain and the trusted domain failed.
The top line in the stack shows:
System.Security.Principal.NTAccount.TranslateToSids(IdentityReferenceCollection sourceAccounts, out bool someFailed)
Any idea why?
My understanding from update above
It seems the group name specified in RequireRole is an email distribution list not security group. If I use some other AD group it works but with this new error:
InvalidOperationException: No authenticationScheme was specified, and there was no DefaultForbidScheme found.
If I add IIS default authenticationScheme in ConfigureServices within Startup.cs
services.AddAuthentication(IISDefaults.AuthenticationScheme);
it gives me an HTTP 403 page: The website declined to show this webpage
So this is the final code:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthentication(IISDefaults.AuthenticationScheme);
services.AddMvc(config =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireRole(#"Departmental - Information Technology - Development") // AD security group
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
});
}
Correct me if I understand wrongly. Thank you.
Option 1: Windows Authentication
You can turn on Windows Authentication for intranet applications. Read the docs here. You can check whether a user is in a role/group by doing something like this.
Before you do, you can check the groups information your computer joined by doing gpresult /R in the command prompt. See this post for more information.
User.IsInRole("xxxx") // this should return True for any group listed up there
You don't need to convert current principal to Windows principal if you don't need to get any information related to Windows.
If you want to get a list of all groups, you still need to query your AD.
warning:
Sometimes I see some groups are not showing up in the result using gpresult /R on the computer, comparing to the option 2 method. That's why sometimes when you do User.IsInRole() and it returns false. I still don't know why this happens.
Option 2: Form Authentication with AD lookup
The Windows Authentication offers just a little information about the user and the AD groups. Sometimes that's enough but most of the time it's not.
You can also use regular Form Authentication and talk to the AD underneath and issue a cookie. That way although the user needs to login to your app using their windows credential and password, you have full control on the AD information.
You don't want to write everything by hand. Luckily there is a library Novell.Directory.Ldap.NETStandard to help. You can find it in NuGet.
Interfaces to define what you need from the AD, as well as the login protocol:
namespace DL.SO.Services.Core
{
public interface IAppUser
{
string Username { get; }
string DisplayName { get; }
string Email { get; }
string[] Roles { get; }
}
public interface IAuthenticationService
{
IAppUser Login(string username, string password);
}
}
AppUser implementation:
using DL.SO.Services.Core;
namespace DL.SO.Services.Security.Ldap.Entities
{
public class AppUser : IAppUser
{
public string Username { get; set; }
public string DisplayName { get; set; }
public string Email { get; set; }
public string[] Roles { get; set; }
}
}
Ldap configuration object for mapping values from appsettings.json:
namespace DL.SO.Services.Security.Ldap
{
public class LdapConfig
{
public string Url { get; set; }
public string BindDn { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string SearchBase { get; set; }
public string SearchFilter { get; set; }
}
}
LdapAuthenticationService implementation:
using Microsoft.Extensions.Options;
using Novell.Directory.Ldap;
using System;
using System.Linq;
using System.Text.RegularExpressions;
using DL.SO.Services.Core;
using DL.SO.Services.Security.Ldap.Entities;
namespace DL.SO.Services.Security.Ldap
{
public class LdapAuthenticationService : IAuthenticationService
{
private const string MemberOfAttribute = "memberOf";
private const string DisplayNameAttribute = "displayName";
private const string SAMAccountNameAttribute = "sAMAccountName";
private const string MailAttribute = "mail";
private readonly LdapConfig _config;
private readonly LdapConnection _connection;
public LdapAuthenticationService(IOptions<LdapConfig> configAccessor)
{
_config = configAccessor.Value;
_connection = new LdapConnection();
}
public IAppUser Login(string username, string password)
{
_connection.Connect(_config.Url, LdapConnection.DEFAULT_PORT);
_connection.Bind(_config.Username, _config.Password);
var searchFilter = String.Format(_config.SearchFilter, username);
var result = _connection.Search(
_config.SearchBase,
LdapConnection.SCOPE_SUB,
searchFilter,
new[] {
MemberOfAttribute,
DisplayNameAttribute,
SAMAccountNameAttribute,
MailAttribute
},
false
);
try
{
var user = result.next();
if (user != null)
{
_connection.Bind(user.DN, password);
if (_connection.Bound)
{
var accountNameAttr = user.getAttribute(SAMAccountNameAttribute);
if (accountNameAttr == null)
{
throw new Exception("Your account is missing the account name.");
}
var displayNameAttr = user.getAttribute(DisplayNameAttribute);
if (displayNameAttr == null)
{
throw new Exception("Your account is missing the display name.");
}
var emailAttr = user.getAttribute(MailAttribute);
if (emailAttr == null)
{
throw new Exception("Your account is missing an email.");
}
var memberAttr = user.getAttribute(MemberOfAttribute);
if (memberAttr == null)
{
throw new Exception("Your account is missing roles.");
}
return new AppUser
{
DisplayName = displayNameAttr.StringValue,
Username = accountNameAttr.StringValue,
Email = emailAttr.StringValue,
Roles = memberAttr.StringValueArray
.Select(x => GetGroup(x))
.Where(x => x != null)
.Distinct()
.ToArray()
};
}
}
}
finally
{
_connection.Disconnect();
}
return null;
}
private string GetGroup(string value)
{
Match match = Regex.Match(value, "^CN=([^,]*)");
if (!match.Success)
{
return null;
}
return match.Groups[1].Value;
}
}
}
Configuration in appsettings.json (just an example):
{
"ldap": {
"url": "[YOUR_COMPANY].loc",
"bindDn": "CN=Users,DC=[YOUR_COMPANY],DC=loc",
"username": "[YOUR_COMPANY_ADMIN]",
"password": "xxx",
"searchBase": "DC=[YOUR_COMPANY],DC=loc",
"searchFilter": "(&(objectClass=user)(objectClass=person)(sAMAccountName={0}))"
},
"cookies": {
"cookieName": "cookie-name-you-want-for-your-app",
"loginPath": "/account/login",
"logoutPath": "/account/logout",
"accessDeniedPath": "/account/accessDenied",
"returnUrlParameter": "returnUrl"
}
}
Setup Authentication (maybe Authorization as well) for the app:
namespace DL.SO.Web.UI
{
public class Startup
{
private readonly IHostingEnvironment _currentEnvironment;
public IConfiguration Configuration { get; private set; }
public Startup(IConfiguration configuration, IHostingEnvironment env)
{
_currentEnvironment = env;
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
// Authentication service
services.Configure<LdapConfig>(this.Configuration.GetSection("ldap"));
services.AddScoped<IAuthenticationService, LdapAuthenticationService>();
// MVC
services.AddMvc(config =>
{
// Requiring authenticated users on the site globally
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
// You can chain more requirements here
// .RequireRole(...) OR
// .RequireClaim(...) OR
// .Requirements.Add(...)
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
});
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
// Authentication
var cookiesConfig = this.Configuration.GetSection("cookies")
.Get<CookiesConfig>();
services.AddAuthentication(
CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = cookiesConfig.CookieName;
options.LoginPath = cookiesConfig.LoginPath;
options.LogoutPath = cookiesConfig.LogoutPath;
options.AccessDeniedPath = cookiesConfig.AccessDeniedPath;
options.ReturnUrlParameter = cookiesConfig.ReturnUrlParameter;
});
// Setup more authorization policies as an example.
// You can use them to protected more strict areas. Otherwise
// you don't need them.
services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly",
policy => policy.RequireClaim(ClaimTypes.Role, "[ADMIN_ROLE_OF_YOUR_COMPANY]"));
// More on Microsoft documentation
// https://learn.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-2.1
});
}
public void Configure(IApplicationBuilder app)
{
app.UseAuthentication();
app.UseMvc(...);
}
}
}
How to authenticate users using the authentication service:
namespace DL.SO.Web.UI.Controllers
{
public class AccountController : Controller
{
private readonly IAuthenticationService _authService;
public AccountController(IAuthenticationService authService)
{
_authService = authService;
}
[AllowAnonymous]
[HttpPost]
public async Task<IActionResult> Login(LoginViewModel model)
{
if (ModelState.IsValid)
{
try
{
var user = _authService.Login(model.Username, model.Password);
// If the user is authenticated, store its claims to cookie
if (user != null)
{
var userClaims = new List<Claim>
{
new Claim(ClaimTypes.Name, user.Username),
new Claim(CustomClaimTypes.DisplayName, user.DisplayName),
new Claim(ClaimTypes.Email, user.Email)
};
// Roles
foreach (var role in user.Roles)
{
userClaims.Add(new Claim(ClaimTypes.Role, role));
}
var principal = new ClaimsPrincipal(
new ClaimsIdentity(userClaims, _authService.GetType().Name)
);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = model.RememberMe
}
);
return Redirect(Url.IsLocalUrl(model.ReturnUrl)
? model.ReturnUrl
: "/");
}
ModelState.AddModelError("", #"Your username or password
is incorrect. Please try again.");
}
catch (Exception ex)
{
ModelState.AddModelError("", ex.Message);
}
}
return View(model);
}
}
}
How to read the information stored in the claims:
public class TopNavbarViewComponent : ViewComponent
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TopNavbarViewComponent(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public async Task<IViewComponentResult> InvokeAsync()
{
string loggedInUsername = _httpContextAccessor.HttpContext.User.Identity.Name;
string loggedInUserDisplayName = _httpContextAccessor.HttpContext.User.GetDisplayName();
...
return View(vm);
}
}
Extension method for ClaimsPrincipal:
namespace DL.SO.Framework.Mvc.Extensions
{
public static class ClaimsPrincipalExtensions
{
public static Claim GetClaim(this ClaimsPrincipal user, string claimType)
{
return user.Claims
.SingleOrDefault(c => c.Type == claimType);
}
public static string GetDisplayName(this ClaimsPrincipal user)
{
var claim = GetClaim(user, CustomClaimTypes.DisplayName);
return claim?.Value;
}
public static string GetEmail(this ClaimsPrincipal user)
{
var claim = GetClaim(user, ClaimTypes.Email);
return claim?.Value;
}
}
}
How to use policy authorization:
namespace DL.SO.Web.UI.Areas.Admin.Controllers
{
[Area("admin")]
[Authorize(Policy = "AdminOnly")]
public abstract class AdminControllerBase : Controller {}
}
Bonus
You can download the AD Explorer from Microsoft so that you can visualize your company AD.
Opps. I was planning to just give out something for head start but I ended up writing a very long post.