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 2, Webpack 2 and Typescript 2.
Code: github
Other posts in this series:
- OAuth2 Implicit Flow with Angular and ASP.NET Core 1.0 IdentityServer4
- Authorization Policies and Data Protection with IdentityServer4 in ASP.NET Core
- Angular OpenID Connect Implicit Flow with IdentityServer4
- Angular2 OpenID Connect Implicit Flow with IdentityServer4
- Secure file download using IdentityServer4, Angular2 and ASP.NET Core
- Angular2 secure file download without using an access token in URL or cookies
- Full Server logout with IdentityServer4 and OpenID Connect Implicit Flow
- IdentityServer4, Web API and Angular2 in a single project
- Extending Identity in IdentityServer4 to manage users in ASP.NET Core
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) { await _signInManager.SignInAsync(user, isPersistent: false); _logger.LogInformation(3, "User created a new account with password."); return RedirectToLocal(returnUrl); } AddErrors(result); } 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.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 ResourceWithIdentityServerWithClient { 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(); if (!context.AllClaimsRequested) { 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")); } else { claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.user")); claims.Add(new Claim(JwtClaimTypes.Role, "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")); } else { claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles.user")); claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles")); } claims.Add(new System.Security.Claims.Claim(StandardScopes.Email.Name, 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 && user.AccountExpires > DateTime.UtcNow; } } }
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 2 User Management Component
The Angular 2 SPA is built using Webpack 2 with typescript. See https://github.com/damienbod/Angular2WebpackVisualStudio on how to setup a Angular 2, Webpack 2 app with ASP.NET Core.
The Angular 2 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 { Http, Response, Headers, RequestOptions } from '@angular/http'; import 'rxjs/add/operator/map'; import { Observable } from 'rxjs/Observable'; import { Configuration } from '../app.constants'; import { SecurityService } from '../services/SecurityService'; import { User } from './models/User'; @Injectable() export class UserManagementService { private actionUrl: string; private headers: Headers; constructor(private _http: Http, private _configuration: Configuration, private _securityService: SecurityService) { this.actionUrl = `${_configuration.Server}/api/UserManagement/`; } private setHeaders() { console.log("setHeaders started"); this.headers = new Headers(); this.headers.append('Content-Type', 'application/json'); this.headers.append('Accept', 'application/json'); var token = this._securityService.GetToken(); if (token !== "") { let tokenValue = 'Bearer ' + token; console.log("tokenValue:" + tokenValue); this.headers.append('Authorization', tokenValue); } } public GetAll = (): Observable<User[]> => { this.setHeaders(); let options = new RequestOptions({ headers: this.headers, body: '' }); return this._http.get(this.actionUrl, options).map(res => res.json()); } public Update = (id: string, itemToUpdate: User): Observable<Response> => { 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 } from '@angular/core'; import { SecurityService } from '../services/SecurityService'; import { Observable } from 'rxjs/Observable'; import { Router } from '@angular/router'; import { UserManagementService } from '../user-management/UserManagementService'; import { User } from './models/User'; @Component({ selector: 'user-management', templateUrl: 'user-management.component.html' }) export class UserManagementComponent implements OnInit { public message: string; public Users: User[]; constructor( private _userManagementService: UserManagementService, public securityService: SecurityService, private _router: Router) { this.message = "user-management"; } ngOnInit() { this.getData(); } private getData() { console.log('User Management:getData starting...'); this._userManagementService .GetAll() .subscribe(data => this.Users = data, error => this.securityService.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.securityService.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 { HttpModule, JsonpModule } from '@angular/http'; import { SecurityService } from './services/SecurityService'; import { DataEventRecordsService } from './dataeventrecords/DataEventRecordsService'; import { DataEventRecord } from './dataeventrecords/models/DataEventRecord'; import { ForbiddenComponent } from './forbidden/forbidden.component'; import { HomeComponent } from './home/home.component'; import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; import { DataEventRecordsListComponent } from './dataeventrecords/dataeventrecords-list.component'; import { DataEventRecordsCreateComponent } from './dataeventrecords/dataeventrecords-create.component'; import { DataEventRecordsEditComponent } from './dataeventrecords/dataeventrecords-edit.component'; import { UserManagementComponent } from './user-management/user-management.component'; import { HasAdminRoleAuthenticationGuard } from './guards/hasAdminRoleAuthenticationGuard'; import { HasAdminRoleCanLoadGuard } from './guards/hasAdminRoleCanLoadGuard'; import { UserManagementService } from './user-management/UserManagementService'; @NgModule({ imports: [ BrowserModule, FormsModule, routing, HttpModule, JsonpModule ], declarations: [ AppComponent, ForbiddenComponent, HomeComponent, UnauthorizedComponent, DataEventRecordsListComponent, DataEventRecordsCreateComponent, DataEventRecordsEditComponent, UserManagementComponent ], providers: [ SecurityService, DataEventRecordsService, UserManagementService, Configuration, HasAdminRoleAuthenticationGuard, HasAdminRoleCanLoadGuard ], bootstrap: [AppComponent], }) export class AppModule {}
Now the Identity users can be managed fro the Angular 2 UI.
Image may be NSFW.
Clik here to view.
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
ASP.NET Core, Angular2 with Webpack and Visual Studio
Image may be NSFW.
Clik here to view.
Clik here to view.
