This article shows how Identity can be extended and used together with IdentityServer4 to implement application specific requirements. The application allows users to register and can access the application for 7 days. After this, the user cannot log in. Any admin can activate or deactivate a user using a custom user management API. Extra properties are added to the Identity user model to support this. Identity is persisted using EFCore and SQLite. The SPA application is implemented using Angular, Webpack 4 and Typescript 2.

Code: https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow

History:

2019-09-20: Updated ASP.NET Core 3.0, Angular 8.2.6

2018-06-22: Updated ASP.NET Core 2.1, Angular 6.0.6, ASP.NET Core Identity 2.1

Full history:

https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow#history

Other posts in this series:

Updating Identity

Updating Identity is pretty easy. The package provides the IdentityUser class implemented by the ApplicationUser. You can add any extra required properties to this class. This requires the Microsoft.AspNetCore.Identity.EntityFrameworkCore package which is included in the project as a NuGet package.

using System; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; namespace IdentityServerWithAspNetIdentity.Models { public class ApplicationUser : IdentityUser { public bool IsAdmin { get; set; } public string DataEventRecordsRole { get; set; } public string SecuredFilesRole { get; set; } public DateTime AccountExpires { get; set; } } }

Identity needs to be added to the application. This is done in the startup class in the ConfigureServices method using the AddIdentity extension. SQLite is used to persist the data. The ApplicationDbContext which uses SQLite is then used as the store for Identity.

services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(Configuration.GetConnectionString("DefaultConnection"))); services.AddIdentity<ApplicationUser, IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders();

The configuration is read from the appsettings for the SQLite database. The configuration is read using the ConfigurationBuilder in the Startup constructor.

"ConnectionStrings": { "DefaultConnection": "Data Source=C:\\git\\damienbod\\AspNet5IdentityServerAngularImplicitFlow\\src\\ResourceWithIdentityServerWithClient\\usersdatabase.sqlite" },

The Identity store is then created using the EFCore migrations.

dotnet ef migrations add testMigration dotnet ef database update

The new properties in the Identity are used in three ways; when creating a new user, when creating a token for a user and validating the token on a resource using policies.

Using Identity creating a new user

The Identity ApplicationUser is created in the Register method in the AccountController. The new extended properties which were added to the ApplicationUser can be used as required. In this example, a new user will have access for 7 days. If the user can set custom properties, the RegisterViewModel model needs to be extended and the corresponding view.

[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task<IActionResult> Register(RegisterViewModel model, string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; if (ModelState.IsValid) { var dataEventsRole = "dataEventRecords.user"; var securedFilesRole = "securedFiles.user"; if (model.IsAdmin) { dataEventsRole = "dataEventRecords.admin"; securedFilesRole = "securedFiles.admin"; } var user = new ApplicationUser { UserName = model.Email, Email = model.Email, IsAdmin = model.IsAdmin, DataEventRecordsRole = dataEventsRole, SecuredFilesRole = securedFilesRole, AccountExpires = DateTime.UtcNow.AddDays(7.0) }; var result = await _userManager.CreateAsync(user, model.Password); if (result.Succeeded) { // For more information on how to enable account confirmation and password reset please visit http://go.microsoft.com/fwlink/?LinkID=532713 // Send an email with this link //var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); //var callbackUrl = Url.Action("ConfirmEmail", "Account", new { userId = user.Id, code = code }, protocol: HttpContext.Request.Scheme); //await _emailSender.SendEmailAsync(model.Email, "Confirm your account", // $"Please confirm your account by clicking this link: <a href='{callbackUrl}'>link</a>"); await _signInManager.SignInAsync(user, isPersistent: false); _logger.LogInformation(3, "User created a new account with password."); return RedirectToLocal(returnUrl); } AddErrors(result); } // If we got this far, something failed, redisplay form return View(model); }

Using Identity creating a token in IdentityServer4

The Identity properties need to be added to the claims so that the client SPA or whatever client it is can use the properties. In IdentityServer4, the IProfileService interface is used for this. Each custom ApplicationUser property is added as claims as required.

using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using IdentityModel; using IdentityServer4.Extensions; using IdentityServer4.Models; using IdentityServer4.Services; using IdentityServerWithAspNetIdentity.Models; using Microsoft.AspNetCore.Identity; namespace IdentityServerWithAspNetIdentitySqlite { using IdentityServer4; public class IdentityWithAdditionalClaimsProfileService : IProfileService { private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory; private readonly UserManager<ApplicationUser> _userManager; public IdentityWithAdditionalClaimsProfileService(UserManager<ApplicationUser> userManager, IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory) { _userManager = userManager; _claimsFactory = claimsFactory; } public async Task GetProfileDataAsync(ProfileDataRequestContext context) { var sub = context.Subject.GetSubjectId(); var user = await _userManager.FindByIdAsync(sub); var principal = await _claimsFactory.CreateAsync(user); var claims = principal.Claims.ToList(); claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList(); claims.Add(new Claim(JwtClaimTypes.GivenName, user.UserName)); if (user.IsAdmin) { claims.Add(new Claim(JwtClaimTypes.Role, "admin")); } else { claims.Add(new Claim(JwtClaimTypes.Role, "user")); } if (user.DataEventRecordsRole == "dataEventRecords.admin") { claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.admin")); claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.user")); claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords")); claims.Add(new Claim(JwtClaimTypes.Scope, "dataEventRecords")); } else { claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.user")); claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords")); claims.Add(new Claim(JwtClaimTypes.Scope, "dataEventRecords")); } if (user.SecuredFilesRole == "securedFiles.admin") { claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles.admin")); claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles.user")); claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles")); claims.Add(new Claim(JwtClaimTypes.Scope, "securedFiles")); } else { claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles.user")); claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles")); claims.Add(new Claim(JwtClaimTypes.Scope, "securedFiles")); } claims.Add(new Claim(IdentityServerConstants.StandardScopes.Email, user.Email)); context.IssuedClaims = claims; } public async Task IsActiveAsync(IsActiveContext context) { var sub = context.Subject.GetSubjectId(); var user = await _userManager.FindByIdAsync(sub); context.IsActive = user != null; } } }

Using the Identity properties validating a token

The IsAdmin property is used to define whether a logged on user has the admin role. This was added to the token using the admin claim in the IProfileService. Now this can be used by defining a policy and validating the policy in a controller. The policies are added in the Startup class in the ConfigureServices method.

services.AddAuthorization(options => { options.AddPolicy("dataEventRecordsAdmin", policyAdmin => { policyAdmin.RequireClaim("role", "dataEventRecords.admin"); }); options.AddPolicy("admin", policyAdmin => { policyAdmin.RequireClaim("role", "admin"); }); options.AddPolicy("dataEventRecordsUser", policyUser => { policyUser.RequireClaim("role", "dataEventRecords.user"); }); });

The policy can then be used for example in a MVC Controller using the Authorize attribute. The admin policy is used in the UserManagementController.

[Authorize("admin")] [Produces("application/json")] [Route("api/UserManagement")] public class UserManagementController : Controller {

Now that users can be admin users and expire after 7 days, the application requires a UI to manage this. This UI is implemented in the Angular 2 SPA. The UI requires a user management API to get all the users and also update the users. The Identity EFCore ApplicationDbContext context is used directly in the controller to simplify things, but usually this would be separated from the Controller, or if you have a lot of users, some type of search logic would need to be supported with a filtered result list. I like to have no logic in the MVC controller.

using System; using System.Collections.Generic; using System.Linq; using IdentityServerWithAspNetIdentity.Data; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ResourceWithIdentityServerWithClient.Model; namespace ResourceWithIdentityServerWithClient.Controllers { [Authorize("admin")] [Produces("application/json")] [Route("api/UserManagement")] public class UserManagementController : Controller { private readonly ApplicationDbContext _context; public UserManagementController(ApplicationDbContext context) { _context = context; } [HttpGet] public IActionResult Get() { var users = _context.Users.ToList(); var result = new List<UserDto>(); foreach(var applicationUser in users) { var user = new UserDto { Id = applicationUser.Id, Name = applicationUser.Email, IsAdmin = applicationUser.IsAdmin, IsActive = applicationUser.AccountExpires > DateTime.UtcNow }; result.Add(user); } return Ok(result); } [HttpPut("{id}")] public void Put(string id, [FromBody]UserDto userDto) { var user = _context.Users.First(t => t.Id == id); user.IsAdmin = userDto.IsAdmin; if(userDto.IsActive) { if(user.AccountExpires < DateTime.UtcNow) { user.AccountExpires = DateTime.UtcNow.AddDays(7.0); } } else { // deactivate user user.AccountExpires = new DateTime(); } _context.Users.Update(user); _context.SaveChanges(); } } }

Angular User Management Component

The Angular SPA is built using Webpack 4 with typescript. See https://github.com/damienbod/Angular2WebpackVisualStudio on how to setup a Angular, Webpack 4 app with ASP.NET Core.

The Angular app requires a service to access the ASP.NET Core MVC service. This is implemented in the UserManagementService which needs to be added to the app.module then.

import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Configuration } from '../app.constants'; import { OidcSecurityService } from '../auth/services/oidc.security.service'; import { User } from './models/User'; @Injectable() export class UserManagementService { private actionUrl: string; private headers: HttpHeaders = new HttpHeaders(); constructor(private _http: HttpClient, configuration: Configuration, private _securityService: OidcSecurityService) { this.actionUrl = `${configuration.Server}/api/UserManagement`; } private setHeaders() { this.headers = new HttpHeaders(); this.headers = this.headers.set('Content-Type', 'application/json'); this.headers = this.headers.set('Accept', 'application/json'); const token = this._securityService.getToken(); if (token !== '') { const tokenValue = 'Bearer ' + token; this.headers = this.headers.append('Authorization', tokenValue); } } public GetAll = (): Observable<User[]> => { this.setHeaders(); return this._http.get<User[]>(this.actionUrl, { headers: this.headers }); } public Update = (id: string, itemToUpdate: User): Observable<any> => { this.setHeaders(); return this._http.put( this.actionUrl + id, JSON.stringify(itemToUpdate), { headers: this.headers } ); } }

The UserManagementComponent uses the service and displays all the users, and provides a way of updating each user.

import { Component, OnInit, OnDestroy } from '@angular/core'; import { Subscription } from 'rxjs' import { OidcSecurityService } from '../auth/services/oidc.security.service'; import { UserManagementService } from '../user-management/UserManagementService'; import { User } from './models/User'; @Component({ selector: 'app-user-management', templateUrl: 'user-management.component.html' }) export class UserManagementComponent implements OnInit, OnDestroy { isAuthorizedSubscription: Subscription | undefined; isAuthorized = false; public message: string; public Users: User[] = []; constructor( private _userManagementService: UserManagementService, public oidcSecurityService: OidcSecurityService, ) { this.message = 'user-management'; } ngOnInit() { this.isAuthorizedSubscription = this.oidcSecurityService.getIsAuthorized().subscribe( (isAuthorized: boolean) => { this.isAuthorized = isAuthorized; this.getData() }); } ngOnDestroy(): void { if (this.isAuthorizedSubscription) { this.isAuthorizedSubscription.unsubscribe(); } } private getData() { this._userManagementService .GetAll() .subscribe(data => this.Users = data, error => this.oidcSecurityService.handleError(error), () => console.log('User Management Get all completed')); } public Update(user: User) { this._userManagementService.Update(user.id, user) .subscribe((() => console.log('subscribed')), error => this.oidcSecurityService.handleError(error), () => console.log('update request sent!')); } }

The UserManagementComponent template uses the Users data to display, update etc.

<div class="col-md-12" *ngIf="securityService.IsAuthorized()"> <div class="panel panel-default"> <div class="panel-heading"> <h3 class="panel-title">{{message}}</h3> </div> <div class="panel-body" *ngIf="Users"> <table class="table"> <thead> <tr> <th>Name</th> <th>IsAdmin</th> <th>IsActive</th> <th></th> </tr> </thead> <tbody> <tr style="height:20px;" *ngFor="let user of Users"> <td>{{user.name}}</td> <td> <input type="checkbox" [(ngModel)]="user.isAdmin" class="form-control" style="box-shadow:none" /> </td> <td> <input type="checkbox" [(ngModel)]="user.isActive" class="form-control" style="box-shadow:none" /> </td> <td> <button (click)="Update(user)" class="form-control">Update</button> </td> </tr> </tbody> </table> </div> </div> </div>

The user-management component and the service need to be added to the module.

import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { AppComponent } from './app.component'; import { Configuration } from './app.constants'; import { routing } from './app.routes'; import { HttpClientModule } from '@angular/common/http'; import { ForbiddenComponent } from './forbidden/forbidden.component'; import { HomeComponent } from './home/home.component'; import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; import { UserManagementComponent } from './user-management/user-management.component'; import { DataEventRecordsModule } from './dataeventrecords/dataeventrecords.module'; import { NavigationComponent } from './navigation/navigation.component'; import { HasAdminRoleAuthenticationGuard } from './guards/hasAdminRoleAuthenticationGuard'; import { HasAdminRoleCanLoadGuard } from './guards/hasAdminRoleCanLoadGuard'; import { UserManagementService } from './user-management/UserManagementService'; import { AuthModule } from './auth/modules/auth.module'; import { OidcSecurityService } from './auth/services/oidc.security.service'; import { AuthWellKnownEndpoints } from './auth/models/auth.well-known-endpoints'; import { OpenIdConfiguration } from './auth/models/auth.configuration'; @NgModule({ imports: [ BrowserModule, FormsModule, routing, HttpClientModule, DataEventRecordsModule, AuthModule.forRoot(), ], declarations: [ AppComponent, ForbiddenComponent, HomeComponent, UnauthorizedComponent, UserManagementComponent, NavigationComponent, ], providers: [ OidcSecurityService, UserManagementService, Configuration, HasAdminRoleAuthenticationGuard, HasAdminRoleCanLoadGuard ], bootstrap: [AppComponent], }) export class AppModule { constructor( public oidcSecurityService: OidcSecurityService ) { const config: OpenIdConfiguration = { stsServer: 'https://localhost:44363', redirect_url: 'https://localhost:44363', client_id: 'singleapp', response_type: 'id_token token', scope: 'dataEventRecords openid', post_logout_redirect_uri: 'https://localhost:44363/Unauthorized', start_checksession: false, silent_renew: true, silent_renew_url: 'https://localhost:44363/silent-renew.html', post_login_route: '/dataeventrecords', forbidden_route: '/Forbidden', unauthorized_route: '/Unauthorized', log_console_warning_active: true, log_console_debug_active: true, max_id_token_iat_offset_allowed_in_seconds: 10 }; const authWellKnownEndpoints: AuthWellKnownEndpoints = { issuer: 'https://localhost:44363', jwks_uri: 'https://localhost:44363/.well-known/openid-configuration/jwks', authorization_endpoint: 'https://localhost:44363/connect/authorize', token_endpoint: 'https://localhost:44363/connect/token', userinfo_endpoint: 'https://localhost:44363/connect/userinfo', end_session_endpoint: 'https://localhost:44363/connect/endsession', check_session_iframe: 'https://localhost:44363/connect/checksession', revocation_endpoint: 'https://localhost:44363/connect/revocation', introspection_endpoint: 'https://localhost:44363/connect/introspect' }; this.oidcSecurityService.setupModule(config, authWellKnownEndpoints); } }

Now the Identity users can be managed fro the Angular UI.

Links

https://github.com/IdentityServer/IdentityServer4

http://docs.identityserver.io/en/dev/

https://github.com/IdentityServer/IdentityServer4.Samples

https://docs.asp.net/en/latest/security/authentication/identity.html

https://github.com/IdentityServer/IdentityServer4/issues/349

https://damienbod.com/2016/06/12/asp-net-core-angular2-with-webpack-and-visual-studio/