Secure your .NET Core APIs with Identity Server
Kyiv ALT.NET
About me
software architect
member of kottans.org
GDPR/SOX
Solution? :)
SOX
Requirements
* Authentication/authorization for internal 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
https://jwt.io/introduction http://docs.identityserver.io/en/release/topics/grant_types.html http://blog.leanix.net/en/authorization-authentication-with-microservices https://www.jsonwebtoken.io/ https://www.digitalocean.com/community/tutorials/an-introduction-to-oauth-2 http://www.jerriepelser.com/blog/using-roles-with-the-jwt-middleware/ https://github.com/IdentityServer/IdentityServer4/issues/1333h
*All images are under CCO License
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
- 4,059