torsdag den 20. september 2018

Angular 6, ADAL and ASP.NET Web API (B2B)

Background

This article is based on challenges when starting with Angular 6, ADAL and Web API Core 2.1. So its an article from a beginner to beginners, so there will properly be many thinks that should have been done differently. It's a sample of making the most minimal working setup.

As stated I am new to Angular, so my first question was how do I work work with Angular. There is only a version 5 as a template in Visual Studio, and I realized fast, that if you want to use Visual Studio as a tool, you have to do some configuration to make Visual Studio and Angular work together. After some conversations with more experienced Angular developers, I decided to split the Web API and the Portal in two separate projects. I work with Web API in Visual Studio, and Angular via the Angular cli (command line interface). In this way I feel I am using the right tool for the right technology and not trying to bend Angular into the Visual Studio world. When working with Angular I use Visual Studio Code as editor and debugger, but its nothing more than an editor. You can use Notepad or what ever you feel like.

Prerequisites

Before starting make sure you have installed:
  • NPM
  • Angular CLI
  • Visual Studio for the Web API
Following frameworks will be used:

Implementation

Lets get started.

Azure Active Directory Setup

Sign into your Active Directory and create a new app registration.


Notice when you have created your Web API and Angular projects you need to add the reply url's (you properly first know you API url after creating the project. Go back following and add it to Reply url's):
Save following:
TenantId (click Azure Active Directory->Properties)
ClientId

Angular

Navigate to an empty folder where you want your projects to be located, and type following:

ng new AngularPortal --routing
cd AngularPortal

This will create a new Angular Project through the Angular CLI.

Add the ADAL/Angular wrapper:
npm install --save adal-angular4

I am going to use Visual Studio Code so I go (but feel free to use your favorite editor):
AngularPortal Code .

You can try run your application by running:
ng serve --open

Create components needed in the CLI:

ng g class authinterceptor
ng g c secured
ng g c nonsecured
ng g c authcallback
ng g service api

Add following to your files:

authinterceptor.ts

import { Injectable } from '@angular/core';
import { HttpEvent,HttpHandler,HttpRequest,HttpInterceptor } from '@angular/common/http';
import { AdalService } from 'adal-angular4';
import { Observable } from 'rxjs';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private adalService: AdalService) { }

intercept(req: HttpRequest<any>, next:HttpHandler): Observable<HttpEvent<any>>{
const authHeader = this.adalService.userInfo.token;

const authReq = req.clone({headers: req.headers.set('Authorization', `Bearer ${authHeader}`)});
return next.handle(authReq);
}
}

secured.component.ts
import { Component, OnInit } from '@angular/core';
import { ApiService } from '../api.service';

@Component({
selector: 'app-secured',
templateUrl: './secured.component.html',
styleUrls: ['./secured.component.css']
})
export class SecuredComponent implements OnInit {
private contacts: Array<object> = [];
constructor(private apiService: ApiService) { }

ngOnInit() {
}

callApi(){
this.apiService.getValues().subscribe((data: Array<object>) => {
this.contacts = data;
console.log(data);
});
}
}

nonsecured.component.ts
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-nonsecured',
templateUrl: './nonsecured.component.html',
styleUrls: ['./nonsecured.component.css']
})
export class NonsecuredComponent implements OnInit {
constructor() { }
ngOnInit() {
}
}
 
authcallback.component.ts
import { Component, OnInit, NgZone } from '@angular/core';
import { Router } from '@angular/router';
import { AdalService } from 'adal-angular4';
@Component({
selector: 'app-auth-callback',
templateUrl: './authcallback.component.html',
styleUrls: ['./authcallback.component.css']
})
export class AuthCallbackComponent implements OnInit {
constructor(private router: Router, private adalService: AdalService, private _zone: NgZone) { }
ngOnInit() {

var config = {
tenant: 'xxxxxxxx-a8b0-4555-97b3-70001a6a7448',
clientId: 'xxxxxxxx-afc3-4327-b668-9e9c894bc276',
redirectUri: "http://localhost:4200/authcallback/",
logOutUri: "http://localhost:4200",
postLogoutRedirectUri: "http://localhost:4200",
endpoints: {
"https://localhost:44300/": "xxxxxxxx-9947-466c-be42-98c8a44db994"
}};

this.adalService.init(config);

this.adalService.handleWindowCallback();
setTimeout(() => {
this._zone.run(
() => this.router.navigate(['/'])
);
}, 200);
}
}

api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient} from '@angular/common/http';

@Injectable({
providedIn: 'root'
})
export class ApiService {

constructor(private httpClient: HttpClient) {}

getValues(){
return this.httpClient.get("https://localhost:44300/api/values");
}
}

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

import { AdalService,AdalGuard } from 'adal-angular4';
import { SecuredComponent } from './secured/secured.component';
import { NonsecuredComponent } from './nonsecured/nonsecured.component';
import { AuthInterceptor } from './auth-interceptor';
import { Nonsecured2Component } from './nonsecured2/nonsecured2.component';
import { AuthCallbackComponent } from './authcallback/authcallback.component';

@NgModule({
declarations: [
AppComponent,
SecuredComponent,
NonsecuredComponent,
Nonsecured2Component,
AuthCallbackComponent
],
imports: [
BrowserModule,
AppRoutingModule,
HttpClientModule
],
providers: [AdalService, AdalGuard,{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }],
bootstrap: [AppComponent]
})
export class AppModule { }

app.component.ts
import { Component,OnInit } from '@angular/core';
import { AdalService } from 'adal-angular4';
import { environment } from '../environments/environment';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})

export class AppComponent {
config = {
tenant: 'xxxxxxxx-a8b0-4555-97b3-70001a6a7448',
clientId: 'xxxxxxxx-afc3-4327-b668-9e9c894bc276',
redirectUri: "http://localhost:4200/authcallback/",
logOutUri: "http://localhost:4200",
postLogoutRedirectUri: "http://localhost:4200",
endpoints: {
"https://localhost:44300/": "xxxxxxxx-9947-466c-be42-98c8a44db994"
}};

constructor(private adalService: AdalService)
{
}
ngOnInit() {
}

login()
{
this.adalService.init(this.config);
this.adalService.login();
}

logout()
{
this.adalService.init(this.config);
this.adalService.logOut();
}

}

app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AdalGuard } from 'adal-angular4';
import { SecuredComponent } from './secured/secured.component';
import { NonsecuredComponent } from './nonsecured/nonsecured.component';
import { Nonsecured2Component } from './nonsecured2/nonsecured2.component';
import { AuthCallbackComponent } from './authcallback/authcallback.component';

const routes: Routes = [
{ path: '', component: NonsecuredComponent },
{ path: 'authcallback', component: AuthCallbackComponent },
{ path: 'nonsecured2', component: Nonsecured2Component },
{ path: 'secured', component: SecuredComponent, canActivate: [AdalGuard] },
];

@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }

secured.component.html
<p>
secured works!
</p>

<div (click)="callApi()">Call secured API</div>

app.component.html

<!--The content below is only a placeholder and can be replaced.-->

<table>
<tr>
<td routerLink="/">Non Secured</td>
<td div routerLink="/secured">Secured</td>
<td (click)="login()">Login</td>
<td (click)="logout()">Logout</td>
</tr>
<tr>
<td colspan="4">
<router-outlet></router-outlet>
</td>
</tr>
</table>

ASP.NET Core Web API

Create a new ASP.NET Core Web API. I found its most easy to add the authentication while creating the project so the owin middle where is created for you:

Because we in debug are having two different hosts/domains we will need to configure CORS. For this sample I disable CORS. Dont do that in productions. Also we need to tell the API to use bearer authentication. The relevant files are shown below:

Startup.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.AzureAD.UI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Authentication.JwtBearer;

namespace WebApi21
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddAuthentication(AzureADDefaults.BearerAuthenticationScheme)
                .AddAzureADBearer(options => Configuration.Bind("AzureAd", options));
            services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

            var authority = Configuration["AzureAd:Instance"] + Configuration["AzureAd:TenantId"];

            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.Authority = authority;
                options.Audience = Configuration["AzureAd:ClientId"]; // web api client id
            });

            services.AddCors(options =>
            {
                options.AddPolicy("AllowSpecificOrigin",
                    builder => builder.AllowAnyOrigin()
                           .AllowAnyHeader()
                           .AllowAnyMethod());
            });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseHsts();
            }

            app.UseDefaultFiles();
            app.UseStaticFiles();
            app.UseCors("AllowSpecificOrigin");
            app.UseHttpsRedirection();
            app.UseAuthentication();
            app.UseMvc();
        }
    }
}

Values Controller:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace WebApi21.Controllers
{
    [Authorize]
    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        // GET api/values
        [HttpGet]
        public ActionResult<IEnumerable<string>> Get()
        {
            return new string[] { "value1", "value2" };
        }

        // GET api/values/5
        [HttpGet("{id}")]
        public ActionResult<string> Get(int id)
        {
            return "value";
        }

        // POST api/values
        [HttpPost]
        public void Post([FromBody] string value)
        {
            // For more information on protecting this API from Cross Site Request Forgery (CSRF) attacks, see https://go.microsoft.com/fwlink/?LinkID=717803
        }

        // PUT api/values/5
        [HttpPut("{id}")]
        public void Put(int id, [FromBody] string value)
        {
            // For more information on protecting this API from Cross Site Request Forgery (CSRF) attacks, see https://go.microsoft.com/fwlink/?LinkID=717803
        }

        // DELETE api/values/5
        [HttpDelete("{id}")]
        public void Delete(int id)
        {
            // For more information on protecting this API from Cross Site Request Forgery (CSRF) attacks, see https://go.microsoft.com/fwlink/?LinkID=717803
        }
    }
}

Test

Lets test it. Start your API project by in Visual Studio (F5), and your Angular portal (ng serve):

If you click secured you should not be able so see secured component. Click login, and now you can click Secured. In the secured component click Call secured API and in the console you should see an array from the API.

Feel free to comment article if you have any changes or additions to the description.

3 kommentarer:

  1. Mine is very similar. I didn't need to addJwtBearer tokens. Unless you are supporting tokens from another token service?

    Also, the authority should be pulled from the config when it binds the options to it. Not sure why you needed that.

    This is all the configuration I have:

    services.AddAuthentication(sharedOptions =>
    {
    sharedOptions.DefaultScheme = AzureADDefaults.BearerAuthenticationScheme;
    })
    .AddAzureADBearer(options => Configuration.Bind("AzureAd", options));

    appsettings has an azure AD section with:

    "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "myaddomain",
    "TenantId": "mytenant id",
    "ClientId": "my client id"
    }


    Also, you have to modify the manifest of the AAD app to allow impmlicit flow. (Although this may not be allowed by default, I'm not sure)

    Finally, I did start with the Angular VS2017 template. Once you do that you can go to the CLient ap folder, and run ng update to move it to Angualar 6. You might have to update the local ng cli first before this works. I don't recal the exact sequence I followed.

    This also eliminates CORS, because your API and Angular app are service from the same Domain/Path.

    Not that having two separate projects is an issue. It's probably best if you have a separate back end and front end teams. But, since I'm a one man show on the app I'm using this for, I prefer it to all be in one project. There good info on configuring it all here:

    https://docs.microsoft.com/en-us/aspnet/core/client-side/spa/angular?view=aspnetcore-2.1&tabs=visual-studio

    SvarSlet