教學課程:準備 Angular SPA 以在外部租用戶進行驗證
本教學課程是系列的第 2 部分,示範如何建置 Angular 單頁應用程式 (保護特殊權限存取 (SPA)),並準備使用 Microsoft Entra 系統管理中心,對其進行驗證。 在本系列的第 1 部分中,您已在外部租用戶中註冊應用程式並設定使用者流程。 本教學課程示範如何使用 npm
建立 Angular SPA,以及如何建立驗證和授權所需的檔案。
在本教學課程中:
- 在 Visual Studio Code 中建立 Angular 專案
- 設定應用程式的使用者介面
- 設定首頁和受防護元件
必要條件
- 教學課程:準備外部租用戶以在 Angular SPA 中驗證使用者。
- 雖然您可以使用任何支援 React 應用程式的整合式開發環境 (IDE),但本教學課程使用 Visual Studio Code。
- Node.js。
建立 Angular 專案
在本節中,我們將使用 Visual Studio Code 中的 Angular CLI,建立新的 Angular 專案。
開啟 Visual Studio Code,選取 [檔案]>[開啟資料夾...]。瀏覽至並選取您要在其中建立專案的位置。
選取 [終端機]>[新增終端機] 來開啟新的終端機。
執行下列命令以建立名稱為
angularspalocal
的新 Angular 專案、安裝 Angular Material 元件程式庫、MSAL 瀏覽器、MSAL Angular,並產生首頁和受防護元件。npm install -g @angular/cli@14.2.0 ng new angularspalocal --routing=true --style=css --strict=false cd angularspalocal npm install @angular/material@13.0.0 @angular/cdk@13.0.0 npm install @azure/msal-browser@2.37.0 @azure/msal-angular@2.5.7 ng generate component home ng generate component guarded
設定 UI 元素
下列步驟會設定應用程式的 UI 元素。 CSS 樣式會新增至應用程式,以定義色彩和字型。 應用程式頁首和頁尾定義於 HTML 檔案中,且 CSS 樣式會新增至應用程式的首頁。
開啟 src/styles.css,並將現有的程式碼取代為下列程式碼片段。
@import '~@angular/material/prebuilt-themes/deeppurple-amber.css'; html, body { height: 100%; } body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
開啟 src/app/app.component.html,並將現有的程式碼取代為下列程式碼片段。
<mat-toolbar color="primary"> <a class="title" href="/">{{ title }}</a> <div class="toolbar-spacer"></div> <a mat-button [routerLink]="['guarded']">Guarded Component</a> <button mat-raised-button *ngIf="!loginDisplay" (click)="login()">Login</button> <button mat-raised-button color="accent" *ngIf="loginDisplay" (click)="logout()">Logout</button> </mat-toolbar> <div class="container"> <!--This is to avoid reload during acquireTokenSilent() because of hidden iframe --> <router-outlet *ngIf="!isIframe"></router-outlet> </div> <footer *ngIf="loginDisplay"> <mat-toolbar> <div class="footer-text"> How did we do? <a href="https://forms.office.com/Pages/ResponsePage.aspx?id=v4j5cvGGr0GRqy180BHbR_ivMYEeUKlEq8CxnMPgdNZUNDlUTTk2NVNYQkZSSjdaTk5KT1o4V1VVNS4u" target="_blank"> Share your experience with us!</a> </div> </mat-toolbar> </footer>
開啟 src/app/app.component.css,並將程式碼取代為下列程式碼片段。
.toolbar-spacer { flex: 1 1 auto; } a.title { color: white; } footer { position: fixed; left: 0; bottom: 0; width: 100%; color: white; text-align: center; } .footer-text { font-size: small; text-align: center; flex: 1 1 auto; }
設定應用程式元件
在本節中,您將設定應用程式的首頁和受防護元件。 首頁元件是應用程式的登陸頁面,而受防護元件是僅已驗證使用者才能存取的頁面。
開啟 src/app/home/home.component.ts,並將現有的程式碼取代為下列程式碼片段。
import { Component, Inject, OnInit } from '@angular/core'; import { Subject } from 'rxjs'; import { filter } from 'rxjs/operators'; import { MsalBroadcastService, MsalGuardConfiguration, MsalService, MSAL_GUARD_CONFIG } from '@azure/msal-angular'; import { AuthenticationResult, InteractionStatus, InteractionType } from '@azure/msal-browser'; import { createClaimsTable } from '../claim-utils'; @Component({ selector: 'app-home', templateUrl: './home.component.html', styleUrls: ['./home.component.css'], }) export class HomeComponent implements OnInit { loginDisplay = false; dataSource: any = []; displayedColumns: string[] = ['claim', 'value', 'description']; private readonly _destroying$ = new Subject<void>(); constructor( @Inject(MSAL_GUARD_CONFIG) private msalGuardConfig: MsalGuardConfiguration, private authService: MsalService, private msalBroadcastService: MsalBroadcastService ) { } ngOnInit(): void { this.msalBroadcastService.inProgress$ .pipe( filter((status: InteractionStatus) => status === InteractionStatus.None) ) .subscribe(() => { this.setLoginDisplay(); this.getClaims( this.authService.instance.getActiveAccount()?.idTokenClaims ); }); } setLoginDisplay() { this.loginDisplay = this.authService.instance.getAllAccounts().length > 0; } getClaims(claims: any) { if (claims) { const claimsTable = createClaimsTable(claims); this.dataSource = [...claimsTable]; } } signUp() { if (this.msalGuardConfig.interactionType === InteractionType.Popup) { this.authService.loginPopup({ scopes: [], prompt: 'create', }) .subscribe((response: AuthenticationResult) => { this.authService.instance.setActiveAccount(response.account); }); } else { this.authService.loginRedirect({ scopes: [], prompt: 'create', }); } } // unsubscribe to events when component is destroyed ngOnDestroy(): void { this._destroying$.next(undefined); this._destroying$.complete(); } }
開啟 src/app/home/home.component.html,並將現有的程式碼取代為下列程式碼片段。 程式代碼會定義應用程式首頁的 HTML 元素。
<mat-card class="card-section" *ngIf="!loginDisplay"> <mat-card-title>Angular single-page application built with MSAL Angular</mat-card-title> <mat-card-subtitle>Sign in with Microsoft Entra External ID</mat-card-subtitle> <mat-card-content>This sample demonstrates how to configure MSAL Angular to sign up, sign in and sign out with Microsoft Entra External ID</mat-card-content> <button mat-raised-button color="primary" (click)="signUp()">Sign up</button> </mat-card> <br> <p class="text-center" *ngIf="loginDisplay"> See below the claims in your <strong> ID token </strong>. For more information, visit: <span> <a href="https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens#claims-in-an-id-token"> docs.microsoft.com </a> </span> </p> <div id="table-container"> <table mat-table [dataSource]="dataSource" class="mat-elevation-z8" *ngIf="loginDisplay"> <!-- Claim Column --> <ng-container matColumnDef="claim"> <th mat-header-cell *matHeaderCellDef> Claim </th> <td mat-cell *matCellDef="let element"> {{element.claim}} </td> </ng-container> <!-- Value Column --> <ng-container matColumnDef="value"> <th mat-header-cell *matHeaderCellDef> Value </th> <td mat-cell *matCellDef="let element"> {{element.value}} </td> </ng-container> <!-- Value Column --> <ng-container matColumnDef="description"> <th mat-header-cell *matHeaderCellDef> Description </th> <td mat-cell *matCellDef="let element"> {{element.description}} </td> </ng-container> <tr mat-header-row *matHeaderRowDef="displayedColumns sticky: true"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> </table> </div>
開啟 src/app/home/home.component.css。 將任何現有的程式碼取代為下列程式碼片段。
#table-container { height: '100vh'; overflow: auto; } table { margin: 3% auto 1% auto; width: 70%; } .mat-row { height: auto; } .mat-cell { padding: 8px 8px 8px 0; } p { text-align: center; } .card-section { margin: 10%; padding: 5%; }
開啟 src/app/guarded/guarded.component.ts,並將現有的程式碼取代為下列程式碼片段。
import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-guarded', templateUrl: './guarded.component.html', styleUrls: ['./guarded.component.css'] }) export class GuardedComponent implements OnInit { constructor() { } ngOnInit(): void { } }
在 src/app/ 資料夾中建立稱為 claim-utils.ts 的檔案,並將下列程式碼片段貼入其中。
/** * Populate claims table with appropriate description * @param {Record} claims ID token claims * @returns claimsTable */ export const createClaimsTable = (claims: Record<string, string>): any[] => { const claimsTable: any[] = []; Object.keys(claims).map((key) => { switch (key) { case 'aud': populateClaim( key, claims[key], "Identifies the intended recipient of the token. In ID tokens, the audience is your app's Application ID, assigned to your app in the Azure portal.", claimsTable ); break; case 'iss': populateClaim( key, claims[key], 'Identifies the issuer, or authorization server that constructs and returns the token. It also identifies the Azure AD tenant for which the user was authenticated. If the token was issued by the v2.0 endpoint, the URI will end in /v2.0.', claimsTable ); break; case 'iat': populateClaim( key, changeDateFormat(+claims[key]), '"Issued At" indicates the timestamp (UNIX timestamp) when the authentication for this user occurred.', claimsTable ); break; case 'nbf': populateClaim( key, changeDateFormat(+claims[key]), 'The nbf (not before) claim dictates the time (as UNIX timestamp) before which the JWT must not be accepted for processing.', claimsTable ); break; case 'exp': populateClaim( key, changeDateFormat(+claims[key]), "The exp (expiration time) claim dictates the expiration time (as UNIX timestamp) on or after which the JWT must not be accepted for processing. It's important to note that in certain circumstances, a resource may reject the token before this time. For example, if a change in authentication is required or a token revocation has been detected.", claimsTable ); break; case 'name': populateClaim( key, claims[key], "The name claim provides a human-readable value that identifies the subject of the token. The value isn't guaranteed to be unique, it can be changed, and it's designed to be used only for display purposes. The 'profile' scope is required to receive this claim.", claimsTable ); break; case 'preferred_username': populateClaim( key, claims[key], 'The primary username that represents the user. It could be an email address, phone number, or a generic username without a specified format. Its value is mutable and might change over time. Since it is mutable, this value must not be used to make authorization decisions. It can be used for username hints, however, and in human-readable UI as a username. The profile scope is required in order to receive this claim.', claimsTable ); break; case 'nonce': populateClaim( key, claims[key], 'The nonce matches the parameter included in the original /authorize request to the IDP.', claimsTable ); break; case 'oid': populateClaim( key, claims[key], 'The oid (user object id) is the only claim that should be used to uniquely identify a user in an Azure AD tenant.', claimsTable ); break; case 'tid': populateClaim( key, claims[key], 'The id of the tenant where this application resides. You can use this claim to ensure that only users from the current Azure AD tenant can access this app.', claimsTable ); break; case 'upn': populateClaim( key, claims[key], 'upn (user principal name) might be unique amongst the active set of users in a tenant but tend to get reassigned to new employees as employees leave the organization and others take their place or might change to reflect a personal change like marriage.', claimsTable ); break; case 'email': populateClaim( key, claims[key], 'Email might be unique amongst the active set of users in a tenant but tend to get reassigned to new employees as employees leave the organization and others take their place.', claimsTable ); break; case 'acct': populateClaim( key, claims[key], 'Available as an optional claim, it lets you know what the type of user (homed, guest) is. For example, for an individual's access to their data you might not care for this claim, but you would use this along with tenant id (tid) to control access to say a company-wide dashboard to just employees (homed users) and not contractors (guest users).', claimsTable ); break; case 'sid': populateClaim( key, claims[key], 'Session ID, used for per-session user sign-out.', claimsTable ); break; case 'sub': populateClaim( key, claims[key], 'The sub claim is a pairwise identifier - it is unique to a particular application ID. If a single user signs into two different apps using two different client IDs, those apps will receive two different values for the subject claim.', claimsTable ); break; case 'ver': populateClaim( key, claims[key], 'Version of the token issued by the Microsoft identity platform', claimsTable ); break; case 'login_hint': populateClaim( key, claims[key], 'An opaque, reliable login hint claim. This claim is the best value to use for the login_hint OAuth parameter in all flows to get SSO.', claimsTable ); break; case 'idtyp': populateClaim( key, claims[key], 'Value is app when the token is an app-only token. This is the most accurate way for an API to determine if a token is an app token or an app+user token', claimsTable ); break; case 'uti': case 'rh': break; default: populateClaim(key, claims[key], '', claimsTable); } }); return claimsTable; }; /** * Populates claim, description, and value into an claimsObject * @param {String} claim * @param {String} value * @param {String} description * @param {Array} claimsObject */ const populateClaim = ( claim: string, value: string, description: string, claimsTable: any[] ): void => { claimsTable.push({ claim: claim, value: value, description: description, }); }; /** * Transforms Unix timestamp to date and returns a string value of that date * @param {number} date Unix timestamp * @returns */ const changeDateFormat = (date: number) => { let dateObj = new Date(date * 1000); return `${date} - [${dateObj.toString()}]`; };
開啟 src/index.html,並將程式碼取代為下列程式碼片段。
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Microsoft identity platform</title> <base href="/"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/x-icon" href="favicon.svg"> <link rel="preconnect" href="https://fonts.gstatic.com"> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> </head> <body class="mat-typography"> <app-root></app-root> <app-redirect></app-redirect> </body> </html>