Extending Identity in IdentityServer4 to manage users in ASP.NET Core

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.

extendingidentity_01

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/

20 comments

  1. […] on November 17, 2016 submitted by /u/mpact0 [link] [comments] Leave a […]

  2. Andy Pook · · Reply

    Have you watched this page in the debugger?!?!?
    Dozens of request (lots failing) even few seconds. Is this acceptable?

    1. 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

      1. Andy Pook · ·

        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

  3. Paul Wade · · Reply

    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

    1. 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

  4. Paul Wade · · Reply

    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?

    1. 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.

      1. Paul Wade · ·

        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

      2. cool, thanks

  5. mark baer · · Reply

    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.

    1. Hi Mark

      That should be enough

      Greetings Damien

  6. Wow! Damien you’re the man! I visited your main site and Im blown away from all your work since 2013!

    1. Hi nivre

      thanks

  7. Joel Palmer · · Reply

    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?

    1. Joel Palmer · · Reply

      Sorry, the above question is invalid. I found my answers in your great examples. Keep up the good work!

  8. This was a lovely blog postt

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: