Last active
November 22, 2022 09:52
-
-
Save rbrayb/945b985a5559288d3663556e8321502d to your computer and use it in GitHub Desktop.
Using Active Directory (AD) as the repository for authentication with identityserver4
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ... | |
/// <summary> | |
/// Handle postback from username/password login | |
/// </summary> | |
[HttpPost] | |
[ValidateAntiForgeryToken] | |
public async Task<IActionResult> Login(LoginInputModel model, string button) | |
{ | |
// check if we are in the context of an authorization request | |
var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); | |
// the user clicked the "cancel" button | |
if (button != "login") | |
{ | |
if (context != null) | |
{ | |
// if the user cancels, send a result back into IdentityServer as if they | |
// denied the consent (even if this client does not require consent). | |
// this will send back an access denied OIDC error response to the client. | |
await _interaction.GrantConsentAsync(context, ConsentResponse.Denied); | |
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null | |
if (await _clientStore.IsPkceClientAsync(context.ClientId)) | |
{ | |
// if the client is PKCE then we assume it's native, so this change in how to | |
// return the response is for better UX for the end user. | |
return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); | |
} | |
return Redirect(model.ReturnUrl); | |
} | |
else | |
{ | |
// since we don't have a valid context, then we just go back to the home page | |
return Redirect("~/"); | |
} | |
} | |
if (ModelState.IsValid) | |
{ | |
// A useful overload is using (var principalContext = new PrincipalContext(ContextType.Domain, "YOUR_AD_DOMAIN", "CN=Users,DC=AwesomeDepartment,DC=com")) | |
//var adContext = new PrincipalContext(ContextType.Domain, "my-adfs", "OU=aaa,DC=bbb,DC=ccc,DC=com", ContextOptions.Negotiate); | |
// Access local DC | |
var adContext = new PrincipalContext(ContextType.Domain); | |
// validate username/password against in-memory store | |
// if (_users.ValidateCredentials(model.Username, model.Password)) | |
if (adContext.ValidateCredentials(model.Username, model.Password)) | |
{ | |
// https://xcentium.com/blog/2019/05/07/active-directory-integration-with-a-net-application | |
// https://stackoverflow.com/questions/44392817/customizing-asp-net-identity | |
// Equivalent of var user = _users.FindByUsername(model.Username) | |
UserPrincipal uPrincipal = null; | |
uPrincipal = UserPrincipal.FindByIdentity(adContext, IdentityType.SamAccountName, model.Username); | |
// Use "TestUser" model for convenience so following code works | |
TestUser tUser = new TestUser(); | |
// A better "SubjectId" would be userGUID | |
tUser.SubjectId = model.Username; | |
tUser.Username = model.Username; | |
var user = tUser; | |
await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username, clientId: context?.ClientId)); | |
// only set explicit expiration here if user chooses "remember me". | |
// otherwise we rely upon expiration configured in cookie middleware. | |
AuthenticationProperties props = null; | |
if (AccountOptions.AllowRememberLogin && model.RememberLogin) | |
{ | |
props = new AuthenticationProperties | |
{ | |
IsPersistent = true, | |
ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration) | |
}; | |
}; | |
// issue authentication cookie with subject ID and username | |
await HttpContext.SignInAsync(user.SubjectId, user.Username, props); | |
if (context != null) | |
{ | |
if (await _clientStore.IsPkceClientAsync(context.ClientId)) | |
{ | |
// if the client is PKCE then we assume it's native, so this change in how to | |
// return the response is for better UX for the end user. | |
return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); | |
} | |
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null | |
return Redirect(model.ReturnUrl); | |
} | |
// request for a local page | |
if (Url.IsLocalUrl(model.ReturnUrl)) | |
{ | |
return Redirect(model.ReturnUrl); | |
} | |
else if (string.IsNullOrEmpty(model.ReturnUrl)) | |
{ | |
return Redirect("~/"); | |
} | |
else | |
{ | |
// user might have clicked on a malicious link - should be logged | |
throw new Exception("invalid return URL"); | |
} | |
} | |
await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials", clientId:context?.ClientId)); | |
ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage); | |
} | |
// something went wrong, show form with error | |
var vm = await BuildLoginViewModelAsync(model); | |
return View(vm); | |
} | |
// ... | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using IdentityModel; | |
using IdentityServer4.Extensions; | |
using IdentityServer4.Models; | |
using IdentityServer4.Services; | |
using System.Collections.Generic; | |
using System.DirectoryServices; | |
using System.DirectoryServices.AccountManagement; | |
using System.Linq; | |
using System.Security.Claims; | |
using System.Threading.Tasks; | |
namespace is4inmem | |
{ | |
public class ADProfileService : IProfileService | |
{ | |
UserPrincipal uPrincipal = null; | |
public Task GetProfileDataAsync(ProfileDataRequestContext context) | |
{ | |
var adContext = new PrincipalContext(ContextType.Domain); | |
//var user = Users.FindBySubjectId(context.Subject.GetName); | |
var user = context.Subject.GetDisplayName(); | |
uPrincipal = UserPrincipal.FindByIdentity(adContext, IdentityType.SamAccountName, user); | |
var claims = new Claim[] | |
{ | |
new Claim(JwtClaimTypes.Name, uPrincipal.Name), | |
new Claim(JwtClaimTypes.GivenName, uPrincipal.GivenName), | |
new Claim(JwtClaimTypes.FamilyName, uPrincipal.DisplayName), | |
new Claim(JwtClaimTypes.Email, uPrincipal.EmailAddress), | |
new Claim(JwtClaimTypes.Address, "123 Main Street"), | |
new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean) | |
}; | |
//context.AddRequestedClaims(claims); | |
// To get another AD attribute not in "UserPrincipal" e.g. "Department" | |
string department = ""; | |
if (uPrincipal.GetUnderlyingObjectType() == typeof(DirectoryEntry)) | |
{ | |
// Transition to directory entry to get other properties | |
using (var entry = (DirectoryEntry)uPrincipal.GetUnderlyingObject()) | |
{ | |
if (entry.Properties["department"] != null) | |
department = entry.Properties["department"].Value.ToString(); | |
} | |
} | |
List<Claim> cl = new List<Claim>(); | |
cl = claims.ToList(); | |
// Add custom claims in token here based on user properties or any other source | |
cl.Add(new Claim ("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department", department)); | |
cl.Add(new Claim ("upn_custom", uPrincipal.UserPrincipalName)); | |
context.IssuedClaims = cl; | |
return Task.CompletedTask; | |
} | |
public Task IsActiveAsync(IsActiveContext context) | |
{ | |
var adContext = new PrincipalContext(ContextType.Domain); | |
var user = context.Subject; | |
Claim userClaim = user.Claims.FirstOrDefault(claimRecord => claimRecord.Type == "sub"); | |
uPrincipal = UserPrincipal.FindByIdentity(adContext, IdentityType.SamAccountName, userClaim.Value); | |
// To be active, user must be enabled and not locked out | |
var isLocked = uPrincipal.IsAccountLockedOut(); | |
context.IsActive = (bool)(uPrincipal.Enabled & !isLocked); | |
return Task.CompletedTask; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. | |
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. | |
using IdentityModel; | |
using IdentityServer4.Models; | |
using System.Collections.Generic; | |
namespace is4inmem | |
{ | |
public static class Config | |
{ | |
public static IEnumerable<IdentityResource> Ids => | |
new IdentityResource[] | |
{ | |
new IdentityResources.OpenId(), | |
new IdentityResources.Profile(), | |
new IdentityResources.Email(), | |
new IdentityResources.Address(), | |
}; | |
public static IEnumerable<ApiResource> Apis => | |
new ApiResource[] | |
{ | |
// new ApiResource("api1", "My API #1") | |
new ApiResource("api1", "My API", new[] { JwtClaimTypes.Subject, JwtClaimTypes.Email, JwtClaimTypes.Address, "upn_custom"}) | |
}; | |
public static IEnumerable<Client> Clients => | |
new Client[] | |
{ | |
// client credentials flow client | |
new Client | |
{ | |
ClientId = "client", | |
ClientName = "Client Credentials Client", | |
AllowedGrantTypes = GrantTypes.ClientCredentials, | |
ClientSecrets = { new Secret("511536EF-F270-4058-80CA-1C89C192F69A".Sha256()) }, | |
AllowedScopes = { "api1" } | |
}, | |
// MVC client using code flow + pkce | |
new Client | |
{ | |
//ClientId = "mvc", | |
ClientId = "mvc.code", | |
ClientName = "MVC Client", | |
// Note | |
AlwaysIncludeUserClaimsInIdToken = true, | |
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials, | |
//RequirePkce = true, | |
RequirePkce = false, | |
//ClientSecrets = { new Secret("49C1A7E1-0C79-4A89-A3D6-A37998FB86B0".Sha256()) }, | |
ClientSecrets = { new Secret("secret".Sha256()) }, | |
//RedirectUris = { "http://localhost:5003/signin-oidc" }, | |
RedirectUris = { "https://localhost:44392/signin-oidc" }, | |
FrontChannelLogoutUri = "http://localhost:5003/signout-oidc", | |
PostLogoutRedirectUris = { "http://localhost:5003/signout-callback-oidc" }, | |
AllowOfflineAccess = true, | |
AllowedScopes = { "openid", "profile", "email", "address", "api1", "upn_custom" } | |
}, | |
// SPA client using code flow + pkce | |
new Client | |
{ | |
ClientId = "spa", | |
ClientName = "SPA Client", | |
ClientUri = "http://identityserver.io", | |
AllowedGrantTypes = GrantTypes.Code, | |
RequirePkce = true, | |
RequireClientSecret = false, | |
RedirectUris = | |
{ | |
"http://localhost:5002/index.html", | |
"http://localhost:5002/callback.html", | |
"http://localhost:5002/silent.html", | |
"http://localhost:5002/popup.html", | |
}, | |
PostLogoutRedirectUris = { "http://localhost:5002/index.html" }, | |
AllowedCorsOrigins = { "http://localhost:5002" }, | |
AllowedScopes = { "openid", "profile", "api1" } | |
} | |
}; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Update to add groups to JWT | |
// GetProfileDataAsync(ProfileDataRequestContext context) is now: | |
public Task GetProfileDataAsync(ProfileDataRequestContext context) | |
{ | |
var adContext = new PrincipalContext(ContextType.Domain); | |
//var user = Users.FindBySubjectId(context.Subject.GetName); | |
var user = context.Subject.GetDisplayName(); | |
uPrincipal = UserPrincipal.FindByIdentity(adContext, IdentityType.SamAccountName, user); | |
var claims = new Claim[] | |
{ | |
new Claim(JwtClaimTypes.Name, uPrincipal.Name), | |
new Claim(JwtClaimTypes.GivenName, uPrincipal.GivenName), | |
new Claim(JwtClaimTypes.FamilyName, uPrincipal.DisplayName), | |
new Claim(JwtClaimTypes.Email, uPrincipal.EmailAddress), | |
new Claim(JwtClaimTypes.Address, "123 Main Street"), | |
new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean) | |
}; | |
//context.AddRequestedClaims(claims); | |
List<Claim> cl = new List<Claim>(); | |
cl = claims.ToList(); | |
// Get all groups user is a "memberOf" | |
PrincipalSearchResult<System.DirectoryServices.AccountManagement.Principal> oPrincipalSearchResult = null; | |
oPrincipalSearchResult = uPrincipal.GetGroups(); | |
// Add groups as a claim type of "role" | |
foreach (System.DirectoryServices.AccountManagement.Principal oResult in oPrincipalSearchResult) | |
{ | |
// Getting all groups causes JWT to be far too big so just using one as an example. | |
// To see if a user is a "memberOf" a group, use "uPrincipal.IsMemberOf" | |
if (oResult.Name == "Domain Users") | |
cl.Add(new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role", oResult.Name)); | |
} | |
// To get another AD attribute not in "UserPrincipal" e.g. "Department" | |
string department = ""; | |
if (uPrincipal.GetUnderlyingObjectType() == typeof(DirectoryEntry)) | |
{ | |
// Transition to directory entry to get other properties | |
using (var entry = (DirectoryEntry)uPrincipal.GetUnderlyingObject()) | |
{ | |
if (entry.Properties["department"] != null) | |
department = entry.Properties["department"].Value.ToString(); | |
} | |
} | |
// Add custom claims in token here based on user properties or any other source | |
cl.Add(new Claim ("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department", department)); | |
cl.Add(new Claim ("upn_custom", uPrincipal.UserPrincipalName)); | |
context.IssuedClaims = cl; | |
return Task.CompletedTask; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. | |
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. | |
using IdentityServer4; | |
using IdentityServer4.Quickstart.UI; | |
using IdentityServer4.Services; | |
using Microsoft.AspNetCore.Builder; | |
using Microsoft.AspNetCore.Hosting; | |
using Microsoft.Extensions.Configuration; | |
using Microsoft.Extensions.DependencyInjection; | |
using Microsoft.Extensions.Hosting; | |
namespace is4inmem | |
{ | |
public class Startup | |
{ | |
public IWebHostEnvironment Environment { get; } | |
public IConfiguration Configuration { get; } | |
public Startup(IWebHostEnvironment environment, IConfiguration configuration) | |
{ | |
Environment = environment; | |
Configuration = configuration; | |
} | |
public void ConfigureServices(IServiceCollection services) | |
{ | |
services.AddControllersWithViews(); | |
// configures IIS out-of-proc settings (see https://github.com/aspnet/AspNetCore/issues/14882) | |
services.Configure<IISOptions>(iis => | |
{ | |
iis.AuthenticationDisplayName = "Windows"; | |
iis.AutomaticAuthentication = false; | |
}); | |
// configures IIS in-proc settings | |
services.Configure<IISServerOptions>(iis => | |
{ | |
iis.AuthenticationDisplayName = "Windows"; | |
iis.AutomaticAuthentication = false; | |
}); | |
var builder = services.AddIdentityServer(options => | |
{ | |
options.Events.RaiseErrorEvents = true; | |
options.Events.RaiseInformationEvents = true; | |
options.Events.RaiseFailureEvents = true; | |
options.Events.RaiseSuccessEvents = true; | |
}); | |
//.AddTestUsers(TestUsers.Users); | |
// in-memory, code config | |
builder.AddInMemoryIdentityResources(Config.Ids); | |
builder.AddInMemoryApiResources(Config.Apis); | |
builder.AddInMemoryClients(Config.Clients); | |
services.AddScoped<IProfileService, ADProfileService>(); | |
// or in-memory, json config | |
//builder.AddInMemoryIdentityResources(Configuration.GetSection("IdentityResources")); | |
//builder.AddInMemoryApiResources(Configuration.GetSection("ApiResources")); | |
//builder.AddInMemoryClients(Configuration.GetSection("clients")); | |
// not recommended for production - you need to store your key material somewhere secure | |
builder.AddDeveloperSigningCredential(); | |
services.AddAuthentication() | |
.AddGoogle(options => | |
{ | |
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; | |
// register your IdentityServer with Google at https://console.developers.google.com | |
// enable the Google+ API | |
// set the redirect URI to http://localhost:5000/signin-google | |
options.ClientId = "copy client ID from Google here"; | |
options.ClientSecret = "copy client secret from Google here"; | |
}); | |
} | |
public void Configure(IApplicationBuilder app) | |
{ | |
if (Environment.IsDevelopment()) | |
{ | |
app.UseDeveloperExceptionPage(); | |
} | |
app.UseStaticFiles(); | |
app.UseRouting(); | |
app.UseIdentityServer(); | |
app.UseAuthorization(); | |
app.UseEndpoints(endpoints => | |
{ | |
endpoints.MapDefaultControllerRoute(); | |
}); | |
} | |
} | |
} |
var claims = new Claim[]
{
//...
}
List<Claim> cl = new List<Claim>();
cl = claims.ToList();
why not just creating a list in first place?
var claims = new List<Claim>()
{
// ...
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://medium.com/the-new-control-plane/using-active-directory-ad-as-the-repository-for-authentication-with-identityserver4-fa010e0980db