9

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.

spottedmahn
  • 14,823
  • 13
  • 108
  • 178
Xiao Han
  • 1,004
  • 7
  • 16
  • 33
  • Looks into claims-based authorization – David Brossard May 02 '18 at 10:51
  • Thanks David. I had a look on Microsoft's website: https://learn.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-2.1, not sure how the implementation would like for claims-based authorization condition. For AD group, would role-based authorization be more appropriate check if a user is belonging in a group? – Xiao Han May 02 '18 at 13:48
  • what type authorization are you looking for? – David Brossard May 02 '18 at 14:37
  • 2
    This is an internal application, so will be running within intranet. I am thinking, AD group authorization would be the way. – Xiao Han May 02 '18 at 14:58
  • I reckon this post answer my question too: [https://stackoverflow.com/questions/36413476/mvc-core-how-to-force-set-global-authorization-for-all-actions?rq=1](https://stackoverflow.com/questions/36413476/mvc-core-how-to-force-set-global-authorization-for-all-actions?rq=1) – Xiao Han May 14 '18 at 17:15

1 Answers1

17

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.

spottedmahn
  • 14,823
  • 13
  • 108
  • 178
David Liang
  • 20,385
  • 6
  • 44
  • 70
  • Thanks David. So from my understanding, your implementation requires to perform searching/lookup in AD every time when a user tries to access the application and the customized code will check that at the global level, right? – Xiao Han May 02 '18 at 18:54
  • @XiaoHan: only when the user has not been authenticated? If the user is already authenticated, the claims are already stored in the cookie. Now I do understand that they won't be refreshed if the AD admin changes the user's information. Hence it would be a good idea to set cookie's expiration to a reasonable short period so that the app will require the user to re-authenticate again soon enough and update the claims. – David Liang May 02 '18 at 18:59
  • Thank you for your reply. I haven't gone that far yet. Still exploring what are the options for this common scenario dealing with AD group for internal application. I guess authentication has been taken care of as it is internal and .net core has built in feature. right? Correct me if I am wrong, to archive my goal, I need to write customize code to look up whether the user (e.g [domain name]\smith) is in AD group (e.g. [domain name]\dev group), right? So far, I only have the AuthorizationPolicyBuilder and AddAuthorization part in the ConfigureServices. – Xiao Han May 02 '18 at 19:46
  • See my updates on the post. Yes if you turn on Windows Authentication. No if you want to use regular Form Authentication. For later, you need to write the authentication piece yourself. – David Liang May 02 '18 at 20:19
  • Thanks. In the lookup code I write I have some simple logic, but it always return false when checking if principal is in role of the specific group. Any idea? var myIdentity = WindowsIdentity.GetCurrent(); var myPrincipal = new WindowsPrincipal(myIdentity); return myPrincipal.IsInRole(groupName); – Xiao Han May 04 '18 at 19:18
  • That's hard to answer because I don't have access to your company active directory or don't know how your guys' computers are setup. If you run the command prompt and type `gpresult /R` you should see a list of groups your computer joined. `IsInRole()` on any of those group should return True. By the way, you don't need to convert the current Principal to Windows if you don't need any Windows information. See my updated post. – David Liang May 04 '18 at 20:10
  • Thank you. The IsInRole method works when checking against the group names(without specify domain) using the command "gpresult /R". I compared the group names, it looks different to what I see in AD explorer under a specific account's memberOf field value. Like you said, some of the groups are in AD but not showing uding the gpresult /r command. Maybe permission? – Xiao Han May 07 '18 at 15:26
  • A quick question, this line of code in Startup.cs: var cookiesConfig = this.Configuration.GetSection("cookies").Get(); I guess "cookies" is the section name in configure file, is CookiesConfig a customized class to store configuration data? – Xiao Han May 09 '18 at 19:10
  • @XiaoHan: yes see my updates. And yes `CookiesConfig` is just a class whose properties match the ones defined in the `appsettings.json` file, same as the `LdapConfig`. – David Liang May 09 '18 at 19:27
  • If I follow your approach, does it mean I need to specify authorize attribute like in your example: [Authorize(Policy = "AdminOnly")] for all the controller class? The reason I am thinking using global authentication/authorization is to avoid specify policy/role based authorization everywhere. Is it possible I just do all in one place? – Xiao Han May 09 '18 at 20:08
  • @XiaoHan: No if your site doesn't need to be protected by role/policy based other than authentication. And I already set up the global filter that requires `RequireAuthenticatedUser()` so your site should be protected globally from unauthenticated users. The `AdminOnly` policy setup is there in the example to demonstrate how you can protect certain areas of your site with more specific roles, like the `Admin` area in the example. Note that even the user is authenticated, as long as (s)he doesn't have `[ADMIN_ROLE_OF_YOUR_COMPANY]`, (s)he can't access to the admin area. English is hard.. – David Liang May 09 '18 at 20:12
  • Thank you. Is it possible to have authorization at global level as well? Just like how authentication is done in Startup.cs I have a customized code to check if this log in user is belonging to an AD group which the site permit to access the contents, if I invoke the function in similar way in Startup.cs, will that check whether the logged in user is authorized or not? – Xiao Han May 09 '18 at 20:29
  • @XiaoHan: it's up to you as a developer but if you ask me I will separate authentication and authorization. Previous identity frameworks like `SimpleMembership` and even earlier `Asp.Net Identity 1.x` mixed authentication and authorization. In Core 2.x, you see they separate them, for good reasons. I mean you could have done the additional role check inside `LdapAuthenticationService` and return `Null` if it fails but that's not the right place to do so. `LdapAuthenticationService` is for authentication! The right place to do is after `.RequireAuthenticatedUser()`. See updates. – David Liang May 09 '18 at 20:32
  • Thank you David. I checked your updates in Startup.cs, as your comments said the AddAuthorization is "to protected more strict areas. Otherwise you don't need them.", so if I want to authenticate all AD users in here, which has been done by calling RequireAuthenticatedUser, then I want to only authorize certain group of AD users, where do you recommend to implement the logic? I am not sure for every single controller class, I do something like user.IsInGroup(groupname) check? Or I can do the check within Startup.cs? – Xiao Han May 10 '18 at 15:22
  • I added few comments after `.RequireAuthenticatedUser()`. Did you see them? In your case, you might be able to authorize certain group of AD users by chaining .RequireRole("role1", "role2", "role3", ...) after `.RequireAuthenticatedUser()`? Since `.RequireAuthenticatedUser()` and `.RequireRole` are added to the global filter, you don't need to check if user.IsInGroup() anymore. If the user is not in the group, i.e., not authorized, the app should throw 403, which will result redirecting to `accessDeniedPath` in the cookies setting from `appsettings.json`. – David Liang May 10 '18 at 17:44
  • Got you. I was trying something like: var policy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .RequireRole("[domain][group]") .Build(); config.Filters.Add(new AuthorizeFilter(policy)); before, but didn't figure out why not working, I guess it was because some groups showing in AD explorer, but not in gpresult /r. After I added the RequireRole() after RequireAuthenticatedUser, I got "The trust relationship between the primary domain and the trusted domain failed" error, seems it failed at System.Security.Principal.NTAccount.TranslateToSids. Do I need to use SID instead? – Xiao Han May 10 '18 at 18:31
  • I have never seen that error. Did it come out of your `LdapAuthenticationService`? Do note that option 2 approach is basically to login with admin username and password first (`_config.Username`/`_config.Password`), do the search, and then login with the found user DN and its password from the login form (`user.DN`/ `password)`. Also note that `.RequireAuthenticatedUser()` doesn't do the authentication for you automatically. It just sets a challenge so that your app will redirect to the login page. Same as `.RequireRole()`. It actually looks into the claims you build of the `principal` object. – David Liang May 10 '18 at 18:36
  • I am not sure if it is because the group I tested is showing in AD explorer(could be an email distribution list) but not in gpresult /r. Other groups showing in gpresult /r I tested works. I didn't use any other AD authentication service, only added RequireAuthenticatedUser() and RequireRole() for AuthorizationPolicyBuilder in ConfigureServices within Startup.cs and make sure Windows Authentication is true in launchSettings.json. – Xiao Han May 10 '18 at 19:19
  • No, if you're using Option 2, it's supposed to be Form Authentication not Windows Authentication. Option 1 is for Windows Authentication. – David Liang May 10 '18 at 19:21
  • If you're using Option 1, I don't think the code in Option 2 section works for that. With Windows Authentication, you're basically done with no additional configurations you can do. And the only information you can get is via `User.IsInRole()` and `User.Identity.Name`. From Microsoft Docs: "When Windows authentication is enabled and anonymous access is disabled, the [Authorize] and [AllowAnonymous] attributes have no effect". Now I remember in previous version of .NET, you can provide a customized `MembershipProvider` and `RoleProvider` in `web.config`. Not sure if still possible in .NET Core – David Liang May 10 '18 at 19:36
  • 1
    I am trying Option 1, but with the code updated in original post on the top, I found if use any AD group returned using gpresult /r command, it will give me a new error: InvalidOperationException: No authenticationScheme was specified, and there was no DefaultForbidScheme found. I guess the previous error is caused by the group must be for email distribution. So to resolve this new error, I added services.AddAuthentication(IISDefaults.AuthenticationScheme); within ConfigureServices, now I get a page with HTTP 403 message The website declined to show this webpage, guess this is what I want. – Xiao Han May 10 '18 at 19:41