在前面兩篇文章中,我介紹了基於IdentityServer4的一個Identity Service的實現,並且實現了一個Weather API和基於Ocelot的API網關,然后實現了通過Ocelot API網關整合Identity Service做身份認證的API請求。今天,我們進入前端開發,設計一個簡單的Angular SPA,並在Angular SPA上調用受Ocelot API網關和Identity Service保護的Weather API。
回顧
- 《Angular SPA基於Ocelot API網關與IdentityServer4的身份認證與授權(一)》
- 《Angular SPA基於Ocelot API網關與IdentityServer4的身份認證與授權(二)》
Angular SPA的實現
我們搭建一個Angular SPA的應用程序,第一步先實現一些基礎功能,比如頁面布局和客戶端路由;第二步先將Ocelot API網關中設置的身份認證功能關閉,並設計一個Component,在Component中調用未受保護的Weather API,此時可以毫無阻攔地在Angular SPA中調用Weather API並將結果顯示在頁面上;第三步,我們在Ocelot API網關上開啟身份認證,然后修改Angular SPA,使其提供登錄按鈕以實現用戶登錄與身份認證,進而訪問受保護的Weather API。在進行接下來的實操演練之前,請確保已經安裝Angular 8 CLI。
基礎功能的實現
在文件系統中,使用ng new命令,新建一個Angular 8的單頁面應用,為了有比較好的界面布局,我使用了Bootstrap。方法很簡單,在項目目錄下,執行npm install --save bootstrap,然后,打開angular.json文件,將bootstrap的js和css添加到配置中:
"styles": [ "src/styles.css", "node_modules/bootstrap/dist/css/bootstrap.min.css" ], "scripts": [ "node_modules/bootstrap/dist/js/bootstrap.min.js" ]
然后,修改app.component.html,使用下面代碼覆蓋:
<nav class="navbar navbar-expand-md navbar-dark bg-dark"> <a class="navbar-brand" href="#">Identity Demo</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarSupportedContent"> <ul class="navbar-nav mr-auto"> <li class="nav-item active"> <a class="nav-link" href="#">首頁 <span class="sr-only">(current)</span></a> </li> <li class="nav-item"> <a class="nav-link" href="#">API</a> </li> <li class="nav-item"> <a class="nav-link" href="#">關於</a> </li> </ul> <form class="form-inline my-2 my-md-0"> <ul class="navbar-nav mr-auto"> <a class="nav-link" href="javascript:void(0)">登錄</a> </ul> </form> </div> </nav>
ng serve跑起來,得到一個具有標題欄的空頁面:
接下來,使用ng g c命令創建3個component,分別是HomeComponent,ApiComponent和AboutComponent,並且修改app.modules.ts文件,將這三個components加入到router中:
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { AppComponent } from './app.component'; import { HomeComponent } from './home/home.component'; import { ApiComponent } from './api/api.component'; import { AboutComponent } from './about/about.component'; const appRoutes: Routes = [ { path: 'about', component: AboutComponent }, { path: 'home', component: HomeComponent }, { path: 'api', component: ApiComponent }, { path: '**', component: HomeComponent } ]; @NgModule({ declarations: [ AppComponent, HomeComponent, ApiComponent, AboutComponent ], imports: [ BrowserModule, RouterModule.forRoot( appRoutes, { enableTracing: false } ) ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
然后,在app.component.html中,加入:
<router-outlet></router-outlet>
再次運行站點,可以看到,我們已經可以通過菜單來切換component了:
在Angular頁面中調用API顯示結果
Angular調用API的方法我就不詳細介紹了,Angular的官方文檔有很詳細的內容可以參考。在這個演練中,我們需要注意的是,首先將上篇文章中對於Weather API的認證功能關閉,以便測試API的調用是否成功。關閉認證功能其實很簡單,只需要將Ocelot API網關中有關Ocelot的配置的相關節點注釋掉就行了:
{ "ReRoutes": [ { "DownstreamPathTemplate": "/weatherforecast", "DownstreamScheme": "http", "DownstreamHostAndPorts": [ { "Host": "localhost", "Port": 5000 } ], "UpstreamPathTemplate": "/api/weather", "UpstreamHttpMethod": [ "Get" ], //"AuthenticationOptions": { // "AuthenticationProviderKey": "AuthKey", // "AllowedScopes": [] //} } ] }
接下來修改Angular單頁面應用,在app.module.ts中加入HttpClientModule:
imports: [ BrowserModule, HttpClientModule, RouterModule.forRoot( appRoutes, { enableTracing: false } ) ],
然后實現一個調用Weather API的Service(服務):
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { WeatherData } from '../models/weather-data'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class WeatherService { constructor(private httpClient: HttpClient) { } getWeather(): Observable<WeatherData[]> { return this.httpClient.get<WeatherData[]>('http://localhost:9000/api/weather'); } }
在這個Service實現中,沒有加入異常處理部分,因為作為一個研究性質的項目,沒有必要進行異常處理,到瀏覽器的調試窗口查看錯誤信息就行。上面的代碼引用了一個類型,就是WeatherData,它其實非常簡單,對應着Weather API所返回的數據模型:
export class WeatherData { constructor(public temperatureF: number, public temperatureC: number, private summary: string, private date: string) { } }
現在,修改api.component.ts,通過調用這個WeatherService來獲取Weather API的數據:
import { Component, OnInit } from '@angular/core'; import { WeatherService } from '../services/weather.service'; import { WeatherData } from '../models/weather-data'; @Component({ selector: 'app-api', templateUrl: './api.component.html', styleUrls: ['./api.component.css'] }) export class ApiComponent implements OnInit { data: WeatherData[]; constructor(private api: WeatherService) { } ngOnInit() { this.api.getWeather() .subscribe(ret => this.data = ret); } }
並顯示在前端:
<div class="container" *ngIf="data"> <table class="table table-striped"> <thead> <tr> <th scope="col">Summary</th> <th scope="col">TempF</th> <th scope="col">TempC</th> <th scope="col">Date</th> </tr> </thead> <tbody> <tr *ngFor="let d of data"> <td>{{d.summary}}</td> <td>{{d.temperatureF}}</td> <td>{{d.temperatureC}}</td> <td>{{d.date}}</td> </tr> </tbody> </table> </div>
完成之后,啟動Weather API和Ocelot API網關,然后運行Angular單頁面應用,我們已經可以在API這個頁面顯示調用結果了:
開啟身份認證
在Ocelot API網關的配置中,打開被注釋掉的部分,重新啟用身份認證功能,再次刷新Angular頁面,發現頁面已經打不開了,在開發者工具的Console中輸出了錯誤信息:401 (Unauthorized),表示身份認證部分已經起作用了。
下面我們來解決這個問題。既然是需要身份認證才能訪問Weather API,那么我們就在Angular頁面上實現登錄功能。首先在Angular單頁面應用中安裝oidc-client,oidc-client是一款為Javascript應用程序提供OpenID Connect和OAuth2協議支持的框架,在Angular中使用也非常的方便。用npm install來安裝這個庫:
npm install oidc-client
然后,實現一個用於身份認證的Service:
import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { UserManager, UserManagerSettings, User } from 'oidc-client'; @Injectable({ providedIn: 'root' }) export class AuthService { private authStatusSource = new BehaviorSubject<boolean>(false); private userNameStatusSource = new BehaviorSubject<string>(''); private userManager = new UserManager(this.getUserManagerSettings()); private user: User | null; authStatus$ = this.authStatusSource.asObservable(); userNameStatus$ = this.userNameStatusSource.asObservable(); constructor() { this.userManager.getUser().then(user => { this.user = user; this.authStatusSource.next(this.isAuthenticated()); this.userNameStatusSource.next(this.user.profile.name); }); } async login() { await this.userManager.signinRedirect(); } async logout() { await this.userManager.signoutRedirect(); } async completeAuthentication() { this.user = await this.userManager.signinRedirectCallback(); this.authStatusSource.next(this.isAuthenticated()); this.userNameStatusSource.next(this.user.profile.name); } isAuthenticated(): boolean { return this.user != null && !this.user.expired; } get authorizationHeaderValue(): string { return `${this.user.token_type} ${this.user.access_token}`; } private getUserManagerSettings(): UserManagerSettings { return { authority: 'http://localhost:7889', client_id: 'angular', redirect_uri: 'http://localhost:4200/auth-callback', post_logout_redirect_uri: 'http://localhost:4200/', response_type: 'id_token token', scope: 'openid profile email api.weather.full_access', filterProtocolClaims: true, loadUserInfo: true, automaticSilentRenew: true, silent_redirect_uri: 'http://localhost:4200/silent-refresh.html' }; } }
AuthService為Angular應用程序提供了用戶身份認證的基本功能,比如登錄、注銷,以及判斷是否經過身份認證(isAuthenticated)等。需要注意的是getUserManagerSettings方法,它為oidc-client提供了基本的參數配置,其中的authority為Identity Service的URL;redirect_uri為認證完成后,Identity Service需要返回到哪個頁面上;post_logout_redirect_uri表示用戶注銷以后,需要返回到哪個頁面上;client_id和scope為Identity Service中為Angular應用所配置的Client的ClientId和Scope(參考Identity Service中的Config.cs文件)。
接下來,修改app.component.html,將原來的“登錄”按鈕改為:
<form class="form-inline my-2 my-md-0"> <ul class="navbar-nav mr-auto"> <a *ngIf="!isAuthenticated" class="nav-link" href="javascript:void(0)" (click)="onLogin()">登錄</a> <li *ngIf="isAuthenticated" class="nav-item dropdown"> <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> {{userName}} </a> <div class="dropdown-menu" aria-labelledby="navbarDropdown"> <a class="dropdown-item" href="javascript:void(0)" (click)="onLogOut()">注銷</a> </div> </li> </ul> </form>
然后,修改app.component.ts,完成登錄和注銷部分的代碼:
import { Component, OnInit, OnDestroy } from '@angular/core'; import { AuthService } from './services/auth.service'; import { Subscription } from 'rxjs'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit, OnDestroy { title = 'identity-demo-spa'; isAuthenticated: boolean; authStatusSubscription: Subscription; userNameSubscription: Subscription; userName: string; constructor(private authService: AuthService) { } ngOnDestroy(): void { this.authStatusSubscription.unsubscribe(); this.userNameSubscription.unsubscribe(); } ngOnInit(): void { this.authStatusSubscription = this.authService.authStatus$.subscribe(status => this.isAuthenticated = status); this.userNameSubscription = this.authService.userNameStatus$.subscribe(status => this.userName = status); } async onLogin() { await this.authService.login(); } async onLogOut() { await this.authService.logout(); } }
我們還需要增加一個新的component:AuthCallbackComponent,用來接收登錄成功之后的回調,它會通知AuthService以更新登錄狀態和用戶信息:
import { Component, OnInit } from '@angular/core'; import { AuthService } from '../services/auth.service'; import { Router, ActivatedRoute } from '@angular/router'; @Component({ selector: 'app-auth-callback', templateUrl: './auth-callback.component.html', styleUrls: ['./auth-callback.component.css'] }) export class AuthCallbackComponent implements OnInit { constructor(private authService: AuthService, private router: Router, private route: ActivatedRoute) { } async ngOnInit() { await this.authService.completeAuthentication(); this.router.navigate(['/home']); } }
最后將AuthCallbackComponent添加到Route中:
const appRoutes: Routes = [ { path: 'about', component: AboutComponent }, { path: 'home', component: HomeComponent }, { path: 'api', component: ApiComponent }, { path: 'auth-callback', component: AuthCallbackComponent }, { path: '**', component: HomeComponent } ];
重新運行Angular應用,你會看到以下效果:
現在我們就可以在Angular的頁面中完成用戶登錄和注銷了。如你所見:
- 登錄界面來自Identity Service,本身也是由IdentityServer4提供的界面,開發者可以自己修改Identity Service來定制界面
- 登錄成功后,原本的“登錄”按鈕變成了顯示用戶名稱的下拉菜單,選擇菜單就可以點擊“注銷”按鈕退出登錄
- 此時訪問API頁面,仍然無法正確調用Weather API,因為我們還沒有將Access Token傳入API調用
登錄狀態下的API調用
接下來,我們將Access Token傳入,使得Angular應用可以使用登錄用戶獲取的Access Token正確調用Weather API。修改AuthService如下:
export class WeatherService { constructor(private httpClient: HttpClient, private authService: AuthService) { } getWeather(): Observable<WeatherData[]> { const authHeaderValue = this.authService.authorizationHeaderValue; const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json', Authorization: authHeaderValue }) }; return this.httpClient.get<WeatherData[]>('http://localhost:9000/api/weather', httpOptions); } }
再次運行Angular應用,可以看到,已經可以在登錄的狀態下成功調用Weather API。你也可以試試,在退出登錄的狀態下,是否還能正確調用API。
小結
本文詳細介紹了Angular單頁面應用作為Ocelot API網關的客戶端,通過Identity Service進行身份認證和API調用的整個過程。當然,很多細節部分沒有做到那么完美,本身也是為了能夠演示開發過程中遇到的問題。從下一講開始,我會開始介紹基於Ocelot API網關的授權問題。
源代碼
訪問以下Github地址以獲取源代碼: