Secure your .NET Core APIs with Identity Server

Kyiv ALT.NET

About me

@programulya, full-stack software engineer

member of kottans.org

part of Materialise family

GDPR/SOX

Solution? :)

SOX

Requirements

* Authentication/authorization for Materialise users (users are stored in Active Directory) in internal applications

 

* Roles-based/permissions-based access for actions in applications

 

* Authorization between services (APIs)

 

* Strict deadline (!) :)

What we want to secure

APIs

App 2

App 3

App 1

External APIs

Authorization with OAuth 2.0

RFC 6749 OAuth 2.0

Common OAuth 2.0 flows

- Authorization Code ​

 

- Implicit

 

- Resource Owner Password Credentials

 

- Client Credentials

Authorization Code

Server-side applications

Implicit

Mobile apps or Web apps run on the user's device

Resource Owner Password Credentials

Trusted applications

Client Credentials

API to API access

How to choose flow?

Internal scenario

External scenario

About Identity Server

- Open-source project

- Implements the following specifications:

  • OAuth 2.0 (RFC 6749)
  • OAuth 2.0 Bearer Token Usage (RFC 6750)
  • OAuth 2.0 Multiple Response Types (spec)
  • OAuth 2.0 Form Post Response Mode (spec)
  • OAuth 2.0 Token Introspection (RFC 7662)
  • Proof Key for Code Exchange (RFC 7636)
  • JSON Web Tokens for Client Authentication (RFC 7523)
  • ...

https://github.com/IdentityServer/IdentityServer4/blob/release/docs/index.rst

https://github.com/aspnet-contrib/AspNet.Security.OpenIdConnect.Server

Authorization Server. Go!

Setup Identity Server

Install-Package IdentityServer4

public void ConfigureServices(IServiceCollection services)
{
   ...
   var connectionString = this.config
                         .GetConnectionString("IdentityProviderDataContext");

   var identityProvider = services.AddIdentityServer()
                .AddConfigurationStore(
                builder => builder.UseSqlServer(connectionString,
                    options => options.MigrationsAssembly(migrationsAssembly)))
                .AddOperationalStore(
                builder => builder.UseSqlServer(connectionString,
                    options => options.MigrationsAssembly(migrationsAssembly)));

  services.AddIdentityProviderServices();
  ...
}

Test by visit Discovery endpoint .well-known/openid-configuration

Database

Install-Package IdentityServer4.EntityFramework

 

 

Identity Server supports also in-memory storage, for production purpose use database

Configuration

"IdentityProviderSettings": {
    "Clients": [
      {
        "ClientId": "yourClientId",
        "GrantTypes": [ "password" ],
        "ClientSecrets": [
          {
            "Value": "yourSecret"
          }
        ],
        "AllowedScopes": [
          "yourScope1.access",
          "yourScope2.access",
          "yourScope3.access",
          "yourScope4.access"
        ],
        "AccessTokenLifetime": 800,
        "Enabled": true
      },
...

Integration with Active Directory (LDAP)

public class LdapService : ILdapService
{
    private const string CompanyDomain = "yourCompanyDomain";
    private readonly PrincipalContext context;

    public LdapService()
    {
        this.context = new PrincipalContext(ContextType.Domain, CompanyDomain);
    }

    public UserPrincipal Authenticate(string userName, string password)
    {
        var user = UserPrincipal.FindByIdentity(this.context, userName);

        if (user == null)
        {
            throw new UnauthorizedAccessException("User doesn't exist");
        }

        var isValid = this.context.ValidateCredentials(userName, password);

        if (!isValid)
        {
            throw new UnauthorizedAccessException("User name or 
                                                   password is inncorect");
        }

        return user;
    }

    public IEnumerable<string> GetUserGroups(UserPrincipal user)
    {
        var groups = user.GetGroups();

        return groups.Select(c => c.Name).ToList();
    }
}

1) Install-Package System.DirectoryServices.AccountManagement

2) Install-Package System.DirectoryServices

Validate credentials

public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
{
   ...
   public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
   {
       ...
       try
       {
          var user = this.ldapService
                    .Authenticate(userName, password);
          var permissions = this.adminService
                    .GetPermissionsByUserName(userName);
          var roles = this.adminService
                    .GetRolesByUserName(userName);

          var claims = new List<Claim>
          {
              new Claim(JwtClaimTypes.Name, user.SamAccountName),
              new Claim(JwtClaimTypes.GivenName, user.DisplayName)
          };

          claims.AddRange(permissions.Select(permission => new Claim(
                    permission.ShortName, 
                    permission.Value.ToString().ToLower())));

          context.Result = new GrantValidationResult(user.Guid.ToString(), 
                    OidcConstants.AuthenticationMethods.Password, claims);
       }
       catch (UnauthorizedAccessException)
       {...}

JWT Tokens

https://jwt.io, jwt plugin

Header (Alg & Token Type)| Payload (Data) | Signature

Client. Go!

Client (back-end, service)

public async Task<OauthInfo> AuthenticateAsync(string userName, string password)
{
    var identityProvider = await DiscoveryClient
                           .GetAsync(this.options
                                    .IdentityProviderApi.ToString());

    if (identityProvider.IsError)
    {
       throw new UnauthorizedAccessException($"Error while 
         running Identity Provider. 
         Error: {identityProvider.Error}, 
         exception: {identityProvider.Exception}");
    }

    var tokenClient = new TokenClient(identityProvider.TokenEndpoint, 
          this.options.ClientId,
          this.options.ClientSecret);
    var jwt = await tokenClient
             .RequestResourceOwnerPasswordAsync(userName, password);

    if (jwt.IsError)
    {
        throw new UnauthorizedAccessException(
          "Error while token request: " + jwt.Error);
    }

    var accessTokenDecoded = new JwtSecurityToken(jwt.AccessToken);
    var name = accessTokenDecoded.Claims
                    .First(c => c.Type == JwtClaimTypes.Name).Value;
    var givenName = accessTokenDecoded.Claims
                    .First(c => c.Type == JwtClaimTypes.GivenName).Value;
    var permissions = this.ExtractPermissions(accessTokenDecoded);

    if (!permissions.Any())
    {
       throw new UnauthorizedAccessException(
             "User doesn't have any permission");
    }

    var oauthInfo = new OauthInfo
    {
        AccessToken = jwt.AccessToken,
        UserName = name,
        UserGivenName = givenName,
        Permissions = permissions
    };

    return oauthInfo;
}

Install-Package IdentityModel

Client (front-end, component)

import { AuthService } from './../services/auth.service';
import { Credentials } from './../../models/authorization/credentials';
import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';

@Component({
    selector: 'authorization-component',
    templateUrl: 'authorization.component.html'
})
export class AuthorizationComponent implements OnInit {
    message: string;
    credentials: Credentials = new Credentials();
    returnUrl: string;

    constructor(private route: ActivatedRoute,
                private router: Router,
                private authService: AuthService) {
    }

    ngOnInit() {
        this.logout();
        this.invalidateUser();

        this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
    }

    login() {
        this.authService
            .login(this.credentials)
            .subscribe((oauthInfo) => {
                this.authService.setAuthData(oauthInfo);
                this.router.navigate([this.returnUrl]);
            }, () => {
                this.logout();
                this.message = 'The user name or password is 
                                incorrect or you do not have permissions';
            });
    }

    private logout() {
        this.authService
            .logout()
            .subscribe(() => {
                this.authService.removeAuthData();
            }, () => {
                this.message = 'Error while user logout';
            });
    }

    private invalidateUser() {
        this.credentials.userName = '';
        this.credentials.password = '';
        this.message = '';
    }
}

Add token to each request

import { OauthInfo } from './../../models/authorization/oauthInfo';
import { AuthService } from './auth.service';
import { LoaderService } from './../../shared/services/loader.service';
import { Injectable, Injector } from '@angular/core';
import { Router } from '@angular/router';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } 
       from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';
import 'rxjs/add/operator/finally';

@Injectable()
export class AuthInterceptorService implements HttpInterceptor {
    constructor(private injector: Injector) {
    }

    intercept(request: HttpRequest<any>, next: HttpHandler): 
        Observable<HttpEvent<any>> {
        const accessToken = this.authService.accessToken;

        if (accessToken) {
            request = request.clone(
                      { setHeaders: 
                      { Authorization: `Bearer ${accessToken}` } });
        }

        return next.handle(request).catch((err) => {
            if (err.status === 401 || err.status === 403) {
                ...
            }

            return Observable.throw(err);
        }).finally(() => {
            ...
        });
    }

    private get router() {
        return this.injector.get(Router);
    }

    private get authService() {
        return this.injector.get(AuthService);
    }

    private get loaderService() {
        return this.injector.get(LoaderService);
    }
}

Configure routing

import { Injectable } from '@angular/core';
import { Router, CanActivate, ActivatedRouteSnapshot, 
         RouterStateSnapshot } from '@angular/router';
import { AuthService } from './../services/auth.service';

@Injectable()
export class AuthGuard implements CanActivate {
    constructor(private router: Router, private authService: AuthService) { }

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
        if (this.authService.isAuth) {
            return true;
        }
        
        this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } });

        return false;
    }
}

Configure actions depends on permissions

 <button *ngIf="your permission"
         md-mini-fab 
         class="btn btn-secondary" 
         mdTooltip="Your Action" 
         [mdTooltipPosition]="'above'" 
         (click)="your action" 
         [disabled]="your conditions">
     <md-icon class="fa fa-bookmark"></md-icon>
</button>

3. Resource Server. Go!

Configuration for secure API

public void ConfigureServices(IServiceCollection services)
{
    ...

    var scopePolicy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .Build();

    services.AddAuthorization(options =>
    {
       options.AddPolicy("yourScope1.access",
           policy => { policy.RequireClaim(JwtClaimTypes.Scope,
                      "yourScope1.access"); });
       options.AddPolicy("yourPermission",
           policy => { policy.RequireClaim("yourPermission", 
                      "true"); });
               ...
    });

    services.AddMvc(options => { options.Filters
            .Add(new AuthorizeFilter(scopePolicy)); });

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new Info
          {
              Title = "Your API",
              Version = "v1",
              Description = "Your API Documentation"
          });

          var basePath = PlatformServices.Default
                        .Application.ApplicationBasePath;
          var xmlPath = Path.Combine(basePath, "YourApi.xml");
          c.OperationFilter<SwaggerAuthorizationHeader>();
          c.IncludeXmlComments(xmlPath);
    });
}

public void Configure(IApplicationBuilder app, 
                      IHostingEnvironment env, 
                      ILoggerFactory loggerFactory)
{
    loggerFactory.AddConsole(this.config.GetSection("Logging"));
    loggerFactory.AddDebug();
    loggerFactory.AddSerilog();

    app.UseIdentityServerAuthentication(
    new IdentityServerAuthenticationOptions
    {
        Authority = this.config.GetSection(
                    "Authentication:identityProviderApi").Value,
        RequireHttpsMetadata = false,
        ApiName = "yourScope1.access"
    });

    app.UseMvc(); 

    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Your API V1");
    });
}

Install-Package IdentityServer4.AccessTokenValidation

Secure APIs

[EnableCors("ApiPolicy")]
[Route("api/orders")]
[Authorize("yourScope1.access")]
[Authorize("yourPermission")]
public class OrderController : Controller
{
   ...
   [HttpGet]
   [Route("")]
   [ProducesResponseType(200)]
   [ProducesResponseType(400)]
   public async Task<IActionResult> GetOrdersAsync(
        SearchParams searchParams, 
        GridParams gridParams)
   {
       try
       {
          var orders = await this.orderService
                       .GetOrdersAsync(searchParams, gridParams);

          return this.Ok(new
          {
              Lines = orders.SearchOrders,
              TotalCount = orders.Count
          });
       }
       catch (Exception ex)
       {
           this.logger.LogError($"Threw exception 
                                  while getting orders: {ex}");

           return this.BadRequest();
       }
}

Add token for internal calls

public class AuthHttpService : IAuthHttpService
{
   ...
   public async Task<HttpClient> CreateHttpClient()
   {
       var client = new HttpClient();
       string accessToken;
       var httpContext = this.httpContextAccessor.HttpContext;

       if (httpContext == null)
       {
            accessToken = tokenProvider.GetToken();
       }
       else
       {
           var authenticateInfo = await httpContext.Authentication
                                 .GetAuthenticateInfoAsync("Bearer");
           accessToken = authenticateInfo.Properties
                        .Items[".Token.access_token"];
                
           if (string.IsNullOrEmpty(accessToken))
           {
               throw new UnauthorizedAccessException(
                         "User is not authorized for this operation");
           }
       }

       client.DefaultRequestHeaders.Authorization = 
              new AuthenticationHeaderValue("Bearer", accessToken);

       return client;
  }
}

Done!

Secure .NET 4.* APIs

public class AuthorizeScopeAttribute : AuthorizeAttribute
{
   private string[] grantedScopes;

   public AuthorizeScopeAttribute(params string[] scopes)
   {
       this.grantedScopes = scopes ?? throw 
            new ArgumentNullException(nameof(scopes));
   }

   protected override bool IsAuthorized(HttpActionContext actionContext)
   {
       var claims = actionContext.ControllerContext
                    .RequestContext.Principal as ClaimsPrincipal;

       if (claims == null)
       {
           return false;
       }

       var scopes = claims.FindAll("scope").Select(c => c.Value).ToList();

       return scopes.Any(scope => grantedScopes.Contains(scope, 
                                  StringComparer.OrdinalIgnoreCase));
   }

   protected override void HandleUnauthorizedRequest(
                           HttpActionContext actionContext)
   {
       var response = actionContext.Request.CreateErrorResponse(
                      HttpStatusCode.Forbidden, "insufficient_scope");

       actionContext.Response = response;
   }
}

* Similar attribute should be implemented for permission

Hangfire

1) Create separate Client Id/Client Secret for Hangfire

2) Add authorization to dashboard

public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var accessTokenCookie = HttpContext.Current
                                .Request.Cookies["yourAuthCookieName"];
        var accessToken = accessTokenCookie?.Value;
        var isAuthorized = false;

        if (!string.IsNullOrEmpty(accessToken))
        {
            var accessTokenDecoded = new JwtSecurityToken(accessToken);
            var supportActionsPermission = accessTokenDecoded.Claims
                           .FirstOrDefault(c => c.Type == 
                                           Permissions.SupportActions);

            if (supportActionsPermission != null)
            {
                isAuthorized = Convert.ToBoolean(
                               supportActionsPermission.Value);
            }
        }

        return isAuthorized;
    }
}

Certificate

public void ConfigureServices(IServiceCollection services)
{
    ...
    var certificateName = this.config.GetSection("Certificate").Value;

    if (string.IsNullOrEmpty(certificateName))
    {
        identityProvider.AddTemporarySigningCredential();
    }
    else
    {
        var certificate = CertificateHelper.GetCertificate(certificateName, 
                          X509FindType.FindBySubjectName);
        identityProvider.AddSigningCredential(certificate);
    }

    ...
}

1) Install certificate in system

2) Manage private keys (grant system user that run app have access to private keys)

Logs

2018-03-01 13:23:17.721 +02:00 [Debug] Resource owner password token request validation success.
2018-03-01 13:23:17.722 +02:00 [Information] Token request validation success
"{
  \"ClientId\": \"yourClientId\",
  \"GrantType\": \"password\",
  \"Scopes\": \"yourScope1.access yourScope2.access\",
  \"UserName\": \"savinkova\",
  \"Raw\": {
    \"grant_type\": \"password\",
    \"username\": \"savinkova\",
    \"password\": \"***REDACTED***\"
  }
}"
2018-03-01 13:23:17.722 +02:00 [Debug] Getting claims for access token for client: "yourClientId"
2018-03-01 13:23:17.722 +02:00 [Debug] Getting claims for access token for subject: 
"213c95d7-f618-4647-b8a5-d898e2d5b440"
2018-03-01 13:23:17.722 +02:00 [Debug] Claim types from profile service that were filtered: 
["sub", "amr", "idp", "auth_time"]
2018-03-01 13:23:17.772 +02:00 [Debug] Token request success.

Pros

+ Standardized library

+ All grant types are supported correctly

+ Easy usage in client applications

Cons

- Some not transparent things are missed in documentation (like ProfileService overriding)

- Roles/permissions logic are not unified and should be implemented by yourself

- Admin UI (changing clients, scopes etc.) is commercial product

- .NET Core 2.0 issues

- Package installing

Wrap-up

- Map configuration (clients, scopes etc.) to Identity Server entities for changing in DB

- For flexibility depend user actions on permissions, not roles

- For each permission introduce short name (name could be changed)

- If you have a lot of APIs create common NuGet package with security logic

- Use separate Authorization Server (Identity Provider) per environment

- Generate own Client Secrets and certificates per environment

- Trust nobody :)

Lessons learned

Links

So... Не надо стесняться (с)

 We call for papers for next ALT.NET meetup

Thanks!

Secure Your .NET Core APIs with Identity Server

By Julia Savinkova

Secure Your .NET Core APIs with Identity Server

Talk for ALT.NET

  • 215
Loading comments...

More from Julia Savinkova