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:
- Authorization Policies and Data Protection with IdentityServer4 in ASP.NET Core
- Angular OpenID Connect Implicit Flow with IdentityServer4
- Angular 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 Angular in a single project
- Extending Identity in IdentityServer4 to manage users in ASP.NET Core
- Implementing a silent token renew in Angular for the OpenID Connect Implicit flow
- OpenID Connect Session Management using an Angular application and IdentityServer4
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/
[…] on November 17, 2016 submitted by /u/mpact0 [link] [comments] Leave a […]
Have you watched this page in the debugger?!?!?
Dozens of request (lots failing) even few seconds. Is this acceptable?
Hi Andy
Are you using the debug build in IE? This is a known bug with webpack -d development build. If you do a production build or a plain webpack build from the cmd, this does not happen or use in Firefox, Chrome.
Otherwise, no it’s not ok, I’ll check and fix this.
Greetings Damien
I was referring to watching this post page in the developer tools in browser. I’d left the tab open for some time (reading list). I was using Chrome 57. Killing the chrome “process” and reloading seemed to resolve it.So, no worries
Ok, thanks
Hi
I’ve cloned the AspNet5IdentityServerAngularImplicitFlow solution from https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow.git
I can run and debug the ResourceWithIdentityServerClient project and register a new user and login.
The project initially launches with https://localhost:44363/index.html, but this gives a 404 error. If I remove the /index.html then the Home page is displayed.
I’m also unable to access the Angular 2 usermanagement route https://localhost:44363/usermanagement. Also, my project Dependencies/npm folder is displaying the warning message ‘npm – not installed’, although all the dependencies have been resolved successfully.
Any idea what the solution to these problems might be?
Any help appreciated.
Regards, Paul
Hi Paul
whole list of problems. Try a build from the command line > npm run build-production, I need to check the startup file, why the static route isn’t mapping to the default. I check tomorrow and get back to you
Greetings Damien
Hi Damien,
Thanks for getting back to me.
Just to let you know…..
npm run build-production fixed the problem:)
The site now spins up from index.html with a ‘Loading’ message, before displaying the Angular Welcome/Login view.
I’m still seeing ‘npm – not installed’ on my Dependencies/npm folder, but this does’n t seem to stop the site from working correctly.
Regards, Paul
PS – Do you have any idea how to implement refresh tokens?
Hi Paul
refresh is a different OpenID spec, if your using an SPA your need to use iframes, the OpenID specs define this. Havn’t tried it together with IdentityServer4. I use reference tokens which are killed when logging out.
Hi Damien,
I’ll take a look at refresh tokens and let you know how I get on.
Btw…… Here’s a link a found by Scott Hanselman re. npm – not installed http://www.hanselman.com/blog/VisualStudio2015FixingDependenciesNpmNotInstalledFromFseventsWithNodeOnWindows.aspx
Looks like a known problem.
Regards, Paul
cool, thanks
Damien, I am trying to add a custom claim in the profile service but it doesn’t appear in my mvc client unless I choose AlwaysIncludeUserClaimsInIdToken=true in the config for IDServer4. Is there something else I need to do? I have options.GetClaimsFromUserInfoEndpoint = true; on the MVC OpenIdConnection settings. Thanks.
Hi Mark
That should be enough
Greetings Damien
Wow! Damien you’re the man! I visited your main site and Im blown away from all your work since 2013!
Hi nivre
thanks
Thanks for this post. However, I see this applies to Angular. I was hoping (because of the title) for a pure ASP.NET 3.0 Core MVC example using Razor or Blazor. How much of your solution do I need to change to make this work?
Sorry, the above question is invalid. I found my answers in your great examples. Keep up the good work!
[…] https://damienbod.com/2016/11/18/extending-identity-in-identityserver4-to-manage-users-in-asp-net-co… […]
This was a lovely blog postt
[…] https://damienbod.com/2016/11/18/extending-identity-in-identityserver4-to-manage-users-in-asp-net-co… […]