software architect
member of kottans.org
SOX
* 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 (!) :)
APIs
App 2
App 3
App 1
External APIs
RFC 6749 OAuth 2.0
- Authorization Code
- Implicit
- Resource Owner Password Credentials
- Client Credentials
Server-side applications
Mobile apps or Web apps run on the user's device
Trusted applications
API to API access
- Open-source project
- Implements the following specifications:
https://github.com/IdentityServer/IdentityServer4/blob/release/docs/index.rst
https://github.com/aspnet-contrib/AspNet.Security.OpenIdConnect.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
Install-Package IdentityServer4.EntityFramework
Identity Server supports also in-memory storage, for production purpose use database
"IdentityProviderSettings": {
"Clients": [
{
"ClientId": "yourClientId",
"GrantTypes": [ "password" ],
"ClientSecrets": [
{
"Value": "yourSecret"
}
],
"AllowedScopes": [
"yourScope1.access",
"yourScope2.access",
"yourScope3.access",
"yourScope4.access"
],
"AccessTokenLifetime": 800,
"Enabled": true
},
...
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
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)
{...}
https://jwt.io, jwt plugin
Header (Alg & Token Type)| Payload (Data) | Signature
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
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 = '';
}
}
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);
}
}
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;
}
}
<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>
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
[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();
}
}
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;
}
}
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
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;
}
}
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)
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.
+ Standardized library
+ All grant types are supported correctly
+ Easy usage in client applications
- 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
- 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 :)
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