- Overview
- Setup
- Entity Framework Setup
- API Setup
- API Route Guards
- Angular Service
- Angular Authentication Workflow
- Angular Route Guards
In an effort to keep this document relevant to the core subject matter, I will not be going into any detail on Entity Framework, Web API, or Angular. They already have amazing documentation, and I highly encourage you to read up on anything you're not familiar with if this doesn't make sense to you.
ASP.NET Core Docs
Entity Framework Docs
Angular Docs
Angular Material Docs
The intent of this document is to build on the Active Directory Authentication setup provided previously. It will include the following features:
- Setting up Entity Framework Code First with a custom
User
entity - Building an API around
AdUser
andUser
- Setting up an Authorization guard for API endpoints
- Building an Angular service for interacting with the API
- Setting up an Angular Authentication workflow
- Building an Angular route guard
This will only feature a simple .IsAdmin
boolean property on the User
class, but you could extend it to work with a Permission
class with a UserPermission
join table for a more robust authorization scheme.
Throughout this document, when defining names or path segments that are up to the reader to define, they will be expressed inside of
{ }
. This means to replace the value, to include the brackets, with whatever your value is. For instance, a path of..\{Project}.Data
could be..\FullstackDemo.Data
.
You'll need to have access to a SQL Server (a SQL Server Express instance is present if you have Visual Studio installed with the Data workload) and work from a machine that's joined to an Active Directory domain.
I have a dotnet new
template that serves as good starting point for getting this running on GitHub. Just follow the instructions in the README to install the template.
To create a project, open a command prompt and create / navigate to a directory you want to build the project in, then run dotnet new fullstack
. This will create the project with the name of the directory it's hosted in. For example, if the name of the directory is Project, then the directory structure will look as follows:
- Project.Core
- Project.Web
- Project.sln
Alternatively, you can run the command as
dotnet new fullstack -n {Project Name} -o {Output Directory}
From the root of the directory you just created, run the following commands:
// Create new projects
{Project}>dotnet new classlib -f netcoreapp2.2 -n {Project}.Data -o {Project}.Data
{Project}>dotnet sln add .\{Project}.Data
{Project}>dotnet add classlib -f netcoreapp2.2 -n {Project}.Identity -o {Project}.Identity
{Project}>dotnet sln add .\{Project}.Identity
// Add Data references
{Project}>cd {Project}.Data
{Project}.Data>dotnet add package Microsoft.EntityFrameworkCore.SqlServer
{Project}.Data>dotnet add package Microsoft.EntityFrameworkCore.Tools
{Project}.Data>dotnet add reference ..\{Project}.Core
// Add Identity references
{Project}.Data> cd ..\{Project}.Identity
{Project}.Identity>dotnet add package Microsoft.AspNetCore.Http
{Project}.Identity>dotnet add package Microsoft.Extensions.Configuration.Abstractions
{Project}.Identity>dotnet add package Microsoft.Extensions.Configuration.Binder
{Project}.Identity>dotnet add package System.DirectoryServices.AccountManagement
// Add Web references
{Project}.Data>cd ..\{Project}.Web
{Project}.Web>dotnet add reference ..\{Project}.Data
{Project}.Web>dotnet add reference ..\{Project}.Identity
Delete both of the Class1.cs
files out of both the {Project}.Data
and {Project}.Identity
classes.
At this point, go ahead and build out all of the infrastructure for
{Project}.Identity
as defined in the Active Directory Authentication document. Note thatlaunchSettings.json
is located at{Project}.Web\Properties\launchSettings.json
.
{Project}.Web\appsettings.Development.json
already contains a connection string. The only modification you should need to make is to update the value of Server={instance}
to match the instance of the SQL Server you'll be using. (localdb)\\ProjectsV13
is installed by default if you have SQL Server Express installed from Visual Studio.
Create the following directory structure in {Project}.Data
:
- Entities
- Extensions
AppDbContext.cs
{Project}.Data\Entities\User.cs
The
SocketName
property is relevant for when using SignalR to send direct messages to users in aHub
.
namespace Project.Data.Entities
{
public int Id { get; set; }
public Guid Guid { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string SocketName { get; set; }
public string Theme { get; set; }
public bool IsDeleted { get; set; }
public bool IsAdmin { get; set; }
}
{Project}.Data\AppDbContext.cs
namespace Project.Data
{
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
/*
* This causes table names in SQL Server to be their singular class name
* as opposed to the plural DbSet<T> name.
*
* In this case, the table name will be User instead of Users.
*/
modelBuilder
.Model
.GetEntityTypes()
.ToList()
.ForEach(x =>
{
modelBuilder
.Entity(x.Name)
.ToTable(x.Name.Split('.').Last());
});
}
}
}
{Project}.Data\Extensions\IdentityExtensions.cs
namespace Project.Data.Extensions.IdentityExtensions
{
public static async Task<List<User>> GetUsers(this AppDbContext db, bool isDeleted = false)
{
var users = await db
.Users
.Where(x => x.IsDeleted == isDeleted)
.OrderBy(x => x.LastName)
.ToListAsync();
return users;
}
public static async Task<List<User>> SearchUsers(this AppDbContext db, string search, bool isDeleted = false)
{
search = search.ToLower();
var users = await db
.Users
.Where(x => x.IsDeleted == isDeleted)
.Where(x =>
x.Email.ToLower().Contains(search) ||
x.FirstName.ToLower().Contains(search) ||
x.LastName.ToLower().Contains(search) ||
x.Username.ToLower().Contains(search)
)
.OrderBy(x => x.LastName)
.ToListAsync();
return users;
}
public static async Task<User> GetUser(this AppDbContext db, int id)
{
var user = await db.Users.FindAsync(id);
return user;
}
public static async Task<User> SyncUser(this AdUser adUser, AppDbContext db)
{
var user = await db
.Users
.FirstOrDefaultAsync(x => x.Guid == adUser.Guid);
user = user == null ?
await db.AddUser(adUser) :
await db.UpdateUser(adUser);
return user;
}
public static async Task<User> AddUser(this AppDbContext db, AdUser adUser)
{
User user = null;
if (await adUser.Validate(db))
{
user = new User
{
Email = adUser.UserPrincipalName,
FirstName = adUser.GivenName,
Guid = adUser.Guid.Value,
IsDeleted = false,
LastName = adUser.Surname,
SocketName = $@"{adUser.GetDomainPrefix()}\{adUser.SamAccountName}",
Theme = "dark-green",
Username = adUser.SamAccountName
}
await db.Users.AddAsync(user);
await db.SaveChangesAsync();
}
return user;
}
public static async Task UpdateUser(this AppDbContext db, User user)
{
db.Users.Update(user);
await db.SaveChangesAsync();
}
private static async Task<User> UpdateUser(this AppDbContext db, AdUser adUser)
{
var user = await db.Users.FirstOrDefaultAsync(x => x.Guid == adUser.Guid);
user.Email = adUser.UserPrincipalName;
user.FirstName = adUser.GivenName;
user.LastName = adUser.Surname;
user.SocketName = $@"{adUser.GetDomainPrefix()}\{adUser.SamAccountName}";
user.Username = adUser.SamAccountName;
await db.SaveChangesAsync();
return user;
}
public static async Task ToggleUserDeleted(this AppDbContext db, User user)
{
db.Users.Attach(user);
user.IsDeleted = !user.IsDeleted;
await db.SaveChangesAsync();
}
public static async Task ToggleAdminUser(this AppDbContext db, User user)
{
db.Users.Attach(user);
user.IsAdmin = !user.IsAdmin;
await db.SaveChangesAsync();
}
public static async Task RemoveUser(this AppDbContext db, User user)
{
db.Users.Remove(user);
await db.SaveChangesAsync();
}
public static async Task<bool> Validate(this AdUser user, AppDbContext db)
{
var check = await db
.Users
.FirstOrDefaultAsync(x => x.Guid == user.Guid.Value);
if (check != null)
{
throw new Exception("The provided user already has an account");
}
return true;
}
}
AppDbContext
needs to be configured and registered as a service in {Project}.Web\Startup.cs
:
public void ConfigureServices(IServiceCollection services)
{
services
.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
.AddJsonOptions(options =>
{
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
});
services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(Configuration.GetConnectionString("Dev"));
options.EnableSensitiveDataLogging();
});
// Additional Configuration
}
Now all that's left for Entity Framework is to add a migration and update the database. From a command prompt pointed at {Project}.Data
, run the following commands:
{Project}.Data>dotnet ef migrations add initial -s ..\Project.Web
{Project}.Data>dotnet ef database update -s ..\Project.Web
In {Project}.Web
, create a folder named Controllers and add a file named IdentityController.cs
.
{Project}.Web\Controllers\IdentityController.cs
namespace Project.Web.Controllers
{
[Route("api/[controller]")]
public class IdentityController : Controller
{
private IUserProvider provider;
private AppDbContext db;
private readonly string appGroup;
public IdentityController(IUserProvider provider, AppDbContext db, IConfiguration config)
{
this.provider = provider;
this.db = db;
appGroup = config.GetValue<string>("AppAdGroup");
}
[HttpGet("[action]")]
public async Task<List<AdUser>> GetDomainUsers() => await provider.GetDomainUsers();
[HttpGet("[action]/{search}")]
public async Task<List<AdUser>> FindDomainUser([FromRoute]string search) => await provider.FindDomainUser(search);
[HttpGet("[action]")]
public async Task<List<User>> GetUsers() => await db.GetUsers();
[HttpGet("[action]")]
public async Task<List<User>> GetDeletedUsers() => await db.GetUsers(true);
[HttpGet("[action]/{search}")]
public async Task<List<User>> SearchUsers([FromRoute]string search) => await db.SearchUsers(search);
[HttpGet("[action]/{search}")]
public async Task<List<User>> SearchDeletedUsers([FromRoute]string search) => await db.SearchUsers(search, true);
[HttpGet("[action]/{id}")]
public async Task<User> GetUser([FromRoute]int id) => await db.GetUser(id);
[HttpGet("[action]")]
public async Task<User> SyncUser() => await provider.CurrentUser.SyncUser(db);
[HttpPost("[action]")]
public async Task AddUser([FromBody]AdUser adUser) => await db.AddUser(adUser);
[HttpPost("[action]")]
public async Task UpdateUser([FromBody]User user) => await db.UpdateUser(user);
[HttpPost("[action]")]
public async Task ToggleUserDeleted([FromBody]User user) => await db.ToggleUserDeleted(user);
[HttpPost("[action]")]
public async Task ToggleAdminUser([FromBody]User user) => await db.ToggleAdminUser(user);
[HttpPost("[action]")]
public async Task RemoveUser([FromBody]User user) => await db.RemoveUser(user);
}
}
This controller allows you to call the above messages and return data as JSON (where relevant) over HTTP. For instance, http://{localhost:5000}/api/identity/findDomainUser/jaime
will return all users in the domain who have jaime
as part of their UserPrincipal.SamAccountName
. You can also POST JSON data to any of the POST methods, and it will be serialized to the appropriate C# object type (so long as the JSON signature matches the signature of the C# object).
Create a folder in {Project}.Web
named Authorization
and add the following files:
AdminRequirement.cs
AdminAuthorizationHandler.cs
{Project}.Web\Authorization\AdminRequirement.cs
using AccountManager.Data;
using Microsoft.AspNetCore.Authorization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Project.Web.Authorization
{
public class AdminRequirement : IAuthorizationRequirement
{
}
}
{Project}.Web\Authorization\AdminAuthorizationHandler.cs
using Microsoft.AspNetCore.Authorization;
using Project.Data.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Project.Web.Authorization
{
public class AdminAuthorizationHandler : AuthorizationHandler<AdminRequirement>
{
AppUser user;
public AdminAuthorizationHandler(UserManager manager)
{
user = manager.CurrentUser;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AdminRequirement requirement)
{
if (user.IsAdmin)
{
context.Succeed(requirement);
}
else
{
context.Fail();
}
return Task.CompletedTask;
}
}
}
Now, Authorization needs to be configured in {Project}.Web\Startup.cs
:
{Project}.Web\Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// Additional Configuration
services.AddAuthorization(options =>
{
options.AddPolicy("IsAdmin", policy => policy.Requirements.Add(new AdminRequirement());
});
services.AddScoped<IAuthorizationHandler, AdminAuthorizationHandler>();
// Additional Configuration
}
With this infrastructure in place, you can now use the [Authorize]
attribute in conjunction with this new AdminAuthorizationHandler
.
[Authorize(Policy = "IsAdmin")]
[HttpPost("[action]")]
public async Task ToggleAdminUser([FromBody]User user) => await db.ToggleAdminUser(user);
If the currently logged in user is not an admin, they will not be able to execute the ToggleAdminUser
API route.
There's a folder convention that I use in Angular that makes managing / referencing each facet of the app much easier. Inside of the
{Project}.Web\ClientApp\src\app
folder, there is a folder for each Angular structure that contains anindex.ts
file. Any file contained within a folder must reference another file contained in the folder directly, or it will create a circular dependency loop. However, any file contained outside of this folder can reference files using the folder index.For instance, inside of a service, you can reference multiple models in one import statement:
import { AdUser, User } from '../models';
However, a model must reference another model directly:
import { AdUser} from './ad-user';
There are more benefits to this setup, especially concerning module configuration. If interested, take a look at the
index.ts
file in each folder, then take a look at bothapp.module.ts
andservices.module.ts
to see how this setup is used.
In order to create the service, we need to have TypeScript types that will map to the C# types it will interact with.
{Project}.Web/ClientApp/src/app/models/ad-user.ts
export class AdUser {
accountExpirationDate: Date;
accountLockoutTime: Date;
badLogonCount: number;
description: string;
displayName: string;
distinguisedName: string;
emailAddress: string;
employeeId: string;
enabled: boolean;
givenName: string;
guid: string;
homeDirectory: string;
homeDrive: string;
lastBadPasswordAttempt: Date;
lastLogon: Date;
lastPasswordSet: Date;
middleName: string;
name: string;
passwordNeverExpires: boolean;
passwordNotRequired: boolean;
samAccountName: string;
scriptPath: string;
sid: string;
surname: string;
userCannotChangePassword: boolean;
userPrincipalName: string;
voiceTelephoneNumber: string;
}
{Project}.Web\ClientApp\src\app\models\user.ts
export class User {
id: number;
guid: string;
firstName: string;
lastName: string;
username: string;
email: string;
socketName: string;
theme: string;
isDeleted: boolean;
isAdmin: boolean;
}
{Project}.Web\ClientApp\src\app\models\index.ts
export * from './ad-user';
export * from './user';
With the models defined, we can now build out the Angular service:
{Project}.Web\ClientApp\src\app\services\identity.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject } from 'rxjs';
import { SnackerService } from './snacker.service';
import {
AdUser,
User
} from '../models';
@Injectable()
export class IdentityService {
private domainUsers = new BehaviorSubject<AdUser[]>(null);
private userGroups = new BehaviorSubject<string[]>(null);
private users = new BehaviorSubject<User[]>(null);
private user = new BehaviorSubject<User>(null);
private currentUser = new BehaviorSubject<User>(null);
domainUsers$ = this.domainUsers.asObservable();
userGroups$ = this.userGroups.asObservable();
users$ = this.users.asObservable();
user$ = this.user.asObservable();
currentUser$ = this.currentUser.asObservable();
constructor(
private http: HttpClient,
private snacker: SnackerService
) { }
getDomainUsers = () =>
this.http.get<AdUser[]>('/api/identity/getDomainUsers')
.subscribe(
data => this.domainUsers.next(data),
err => this.snacker.sendErrorMessage(err.error)
);
findDomainUser = (search: string) =>
this.http.get<AdUser[]>(`/api/identity/findDomainUser/${search}`)
.subscribe(
data => this.domainUsers.next(data),
err => this.snacker.sendErrorMessage(err.error)
);
getUsers = () =>
this.http.get<User[]>('/api/identity/getUsers')
.subscribe(
data => this.users.next(data),
err => this.snacker.sendErrorMessage(err.error)
);
getDeletedUsers = () =>
this.http.get<User[]>('/api/identity/getDeletedUsers')
.subscribe(
data => this.users.next(data),
err => this.snacker.sendErrorMessage(err.error)
);
searchUsers = (search: string) =>
this.http.get<User[]>(`/api/identity/searchUsers/${search}`)
.subscribe(
data => this.users.next(data),
err => this.snacker.sendErrorMessage(err.error)
);
searchDeletedUsers = (search: string) =>
this.http.get<User[]>(`/api/identity/searchDeletedUsers/${search}`)
.subscribe(
data => this.users.next(data),
err => this.snacker.sendErrorMessage(err.error)
);
getUser = (id: number) =>
this.http.get<User>(`/api/identity/getUser/${id}`)
.subscribe(
data => this.user.next(data),
err => this.snacker.sendErrorMessage(err.error)
);
syncUser = () =>
this.http.get<User>('/api/identity/syncUser')
.subscribe(
data => this.currentUser.next(data),
err => this.snacker.sendErrorMessage(err.error)
);
addUser = (user: AdUser): Promise<boolean> =>
new Promise<boolean>((resolve) => {
this.http.post('/api/identity/addUser', user)
.subscribe(
() => {
this.snacker.sendSuccessMessage(`Account created for ${user.samAccountName}`);
resolve(true);
},
err => {
this.snacker.sendErrorMessage(err.error);
resolve(false);
}
)
});
updateUser = (user: User): Promise<boolean> =>
new Promise<boolean>((resolve) => {
this.http.post('/api/identity/updateUser', user)
.subscribe(
() => {
this.snacker.sendSuccessMessage(`${user.username} successfully updated`);
resolve(true);
},
err => {
this.snacker.sendErrorMessage(err.error);
resolve(false);
}
)
});
toggleUserDeleted = (user: User): Promise<boolean> =>
new Promise<boolean>((resolve) => {
this.http.post('/api/identity/toggleUserDeleted', user)
.subscribe(
() => {
const message = user.isDeleted ?
`${user.username} successfully restored` :
`${user.username} successfully deleted`;
this.snacker.sendSuccessMessage(message);
resolve(true);
},
err => {
this.snacker.sendErrorMessage(err.error);
resolve(false);
}
)
});
toggleAdminUser = (user: User): Promise<boolean> =>
new Promise<boolean>((resolve) => {
this.http.post('/api/identity/toggleAdminUser', user)
.subscribe(
() => {
const message = user.isAdmin ?
`Permissions removed from ${user.username}` :
`Permissions granted to ${user.username}`;
this.snacker.sendSuccessMessage(message);
resolve(true);
},
err => {
this.snacker.sendErrorMessage(err.error);
resolve(false);
}
)
});
removeUser = (user: User): Promise<boolean> =>
new Promise<boolean>((resolve) => {
this.http.post('/api/identity/removeUser', user)
.subscribe(
() => {
this.snacker.sendSuccessMessage(`${user.username} permanently deleted`);
resolve(true);
},
err => {
this.snacker.sendErrorMessage(err.error);
resolve(false);
}
)
});
}
In this service, each API endpoint is defined as a function. For GET requests, the results are pushed into an Observable stream via a private BehaviorSubject<T>
, which is exposed to the app as a read-only Observable. For POST requests, the HTTP call is wrapped in a Promise to enable async / await
to be used by the caller and for any subsequent actions to be performed once the transaction is complete.
With all of this complete, we can now sync the current Active Directory user to our User database table and keep track of them in the Angular app.
All we need to do is the following in the AppComponent
:
{Project}.Web\ClientApp\src\app\app.component.ts
import { Theme } from './models';
import { IdentityService } from './services';
import {
Component,
OnInit
} from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
providers: [ IdentityService ]
})
export class AppComponent implements OnInit {
constructor(
public identity: IdentityService
) { }
ngOnInit() {
this.identity.syncUser();
}
}
When the component initializes, the identity service will call the syncUser()
function. This will check to see if the current IIdentity
on the HttpContext
has an account in the database. If not, it will create a new account based on the UserPrincipal
that is retrieved from the IIdentity
instance. If the user does have an account, it will update their properties in the event that anything changed from the last time they logged in (for instance, someone was married and their name changed).
We can then subscribe to the currentUser$
Observable in the component template. We will only render the app if the current user is logged in via Active Directory.
<div class="mat-typography mat-app-background app-panel"
fxLayout="column"
*ngIf="identity.currentUser$ | async as user else loading">
<mat-toolbar color="primary">
<span fxFlex>Title</span>
<span>Hello, {{user.username}}</span>
</mat-toolbar>
<section class="app-body">
<router-outlet></router-outlet>
</section>
</div>
<ng-template #loading>
<mat-progress-bar mode="indeterminate"
color="secondary"></mat-progress-bar>
</ng-template>
Now that we have access to the current user, we can build a route guard to prevent non-admins from accessing certain areas of the app.
In the {Project}.Web\ClientApp\src\app
directory, create a guards folder with a file name auth-guard.ts
:
{Project}.Web\ClientApp\src\app\guards\auth-guard.ts
import { Injectable } from '@angular/core';
import {
CanActivate,
Router,
ActivatedRouteSnapshot,
RouterStateSnapshot
} from '@angular/router';
import { IdentityService } from '../services';
@Injectable()
export class AuthGuard implements CanActivate {
constructor (
private identity: IdentityService,
private router: Router
) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean {
return this.checkLogin();
}
checkLogin(): boolean {
if (this.identity.currentUser.value.isAdmin) { return true; }
this.router.navigate(['/home']);
return false;
}
}
Create an index.ts
file in the guards
folder:
{Project}.Web\ClientApp\src\app\guards\index.ts
import { AuthGuard } from './auth-guard';
export const Guards = [
AuthGuard
];
export * from './auth-guard';
Expand the Guards
array into the providers
array in services.module.ts
:
{Project}.Web\ClientApp\src\app\services.module.ts
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { Guards } from './guards';
import { Services } from './services';
import { Pipes } from './pipes';
@NgModule({
providers: [
[...Services],
[...Guards]
],
declarations: [
[...Pipes]
],
imports: [
HttpClientModule
],
exports: [
[...Pipes],
HttpClientModule
]
})
export class ServicesModule { }
Now, all you have to do in order to lock down a route in Angular is add the canActivate
property to a route (as defined in {Project}.Web\ClientApp\src\app\routes\index.ts
) as follows:
import { AuthGuard } from '../guards';
export const Routes: Route[] = [
{ path: 'admin', component: AdminComponent, canActive: [AuthGuard] },
// additional routes...
]