ionic + asp.net core webapi + keycloak實現前後端用戶認證和自動生成客戶端程式碼

概述

本文使用ionic/angular開發網頁前台,asp.net core webapi開發restful service,使用keycloak保護前台頁面和後台服務,並且利用open api自動生成程式碼功能,減少了重複程式碼編寫。

準備工作

1、使用docker搭建並啟動keycloak伺服器,新建名稱為test的realm,並建立幾個測試用戶,並且建立1個名稱為my_client的客戶端,注意客戶端的回調url要正確。

2、安裝ionic,使用 ionic start myApp tabs,初始化一個tabs格式的前端應用。

3、使用dotnet new webapi命令創建一個webapi。

WebApi設置

1、控制器使用[Authorize]保護

namespace WebApi1.Controllers
{
    /// <summary>
    /// 天氣預報服務
    /// </summary>
    [Authorize]
    [ApiController]
    [Route("[controller]")]
    [Produces("application/json")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };

        private readonly ILogger<WeatherForecastController> _logger;

        public WeatherForecastController(ILogger<WeatherForecastController> logger)
        {
            _logger = logger;
        }

        /// <summary>
        /// 獲取全部天氣預報資訊
        /// </summary>
        /// <returns></returns>
        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
}

2、修改項目文件

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <PropertyGroup>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.4" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="5.4.1" />
  </ItemGroup>

</Project>

 1591那一段主要是為了編譯時生成xml格式的注釋文檔,該文檔給OpenApi使用,用來給方法和屬性添加註釋。

JwtBearer用於實現基於JWT的身份認證,Swashbuckle.AspNetCore用於自動生成OpenApi文檔以及圖形介面。

3、修改Startup

 1 namespace WebApi1
 2 {
 3     public class Startup
 4     {
 5         readonly string MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
 6 
 7         public Startup(IConfiguration configuration)
 8         {
 9             Configuration = configuration;
10         }
11 
12         public IConfiguration Configuration { get; }
13 
14         // This method gets called by the runtime. Use this method to add services to the container.
15         public void ConfigureServices(IServiceCollection services)
16         {
17             services.AddCors(options =>
18             {
19                 options.AddPolicy(name: MyAllowSpecificOrigins,
20                                 builder =>
21                                 {
22                                     builder.WithOrigins("//localhost:8100").AllowAnyHeader().AllowAnyMethod();
23                                 });
24             });
25 
26             services.AddControllers();
27 
28             services.AddSwaggerGen(c =>
29             {
30                 c.SwaggerDoc("v1", new OpenApiInfo { Title = "一個測試用的天氣預報服務", Version = "v1" });
31                 
32                 var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
33                 var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
34                 c.IncludeXmlComments(xmlPath);
35             });
36 
37             services.AddAuthentication(options =>
38              {
39                  options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
40                  options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
41              }).AddJwtBearer(options =>
42                  {
43                      options.Authority = "//localhost:8180/auth/realms/test";
44                      options.RequireHttpsMetadata = false;
45                      options.Audience = "account";
46                      options.TokenValidationParameters = new TokenValidationParameters
47                      {
48                          NameClaimType = "preferred_username"
49                      };
50                  });
51         }
52 
53         // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
54         public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
55         {
56             if (env.IsDevelopment())
57             {
58                 app.UseDeveloperExceptionPage();
59             }
60 
61             app.UseHttpsRedirection();
62 
63             // Enable middleware to serve generated Swagger as a JSON endpoint.
64             app.UseSwagger();
65 
66             // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.),
67             // specifying the Swagger JSON endpoint.
68             app.UseSwaggerUI(c =>
69             {
70                 c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
71                 c.RoutePrefix = string.Empty;
72             });
73 
74             app.UseRouting();
75 
76             app.UseAuthentication();
77             app.UseAuthorization();
78 
79             app.UseCors(MyAllowSpecificOrigins);
80 
81             app.UseEndpoints(endpoints =>
82             {
83                 endpoints.MapControllers();
84             });
85         }
86     }
87 }

17行程式碼添加CORS支援,此處只允許來自我的客戶端的訪問。

28行配置OpenApi文檔生成邏輯。

68行生成OpenApi文檔介面,使用c.RoutePrefix使得一打開網站就能看到文檔介面,而不是打開404.

37行配置JWT參數,連接到keycloak服務的test realm。

4、修改偵聽埠

為了方便配置回調介面,在lauchSettings.json中將偵聽地址改為//localhost:5000

5、dotnet run啟動

使用瀏覽器打開//localhost:5000,看到如下文檔介面。

ionic配置keycloak支援

使用keyclock-angular快速添加對於keyclock的支援,//github.com/mauriciovigolo/keycloak-angular

npm i –save keycloak-angular

npm i –save keycloak-js@version

這裡的version我設置的是9.0.3,最新的是10,但是keyclock-angular安裝時明確指定要求版本小於10,不知道是不是一個bug。

安裝完畢後,修改app.module.ts,

 1 import { BASE_PATH } from './../../services/variables';
 2 import { NgModule, APP_INITIALIZER} from '@angular/core';
 3 import { BrowserModule } from '@angular/platform-browser';
 4 import { RouteReuseStrategy } from '@angular/router';
 5 
 6 import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
 7 import { SplashScreen } from '@ionic-native/splash-screen/ngx';
 8 import { StatusBar } from '@ionic-native/status-bar/ngx';
 9 
10 import { AppRoutingModule } from './app-routing.module';
11 import { AppComponent } from './app.component';
12 import { KeycloakAngularModule, KeycloakService } from 'keycloak-angular';
13 import { HttpClientModule }  from '@angular/common/http';
14 
15 @NgModule({
16   declarations: [AppComponent],
17   entryComponents: [],
18   imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, KeycloakAngularModule, HttpClientModule],
19   providers: [
20     StatusBar,
21     SplashScreen,
22     { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
23     {
24       provide: APP_INITIALIZER,
25       useFactory: initializer,
26       multi: true,
27       deps: [KeycloakService]
28     },
29     {
30       provide: BASE_PATH, useValue: '//localhost:5000'
31     }
32   ],
33   bootstrap: [AppComponent]
34 })
35 
36 export class AppModule {}
37 
38 function initializer(keycloak: KeycloakService): () => Promise<any> {
39   return (): Promise<any> => {
40     return new Promise(async (resolve, reject) => {
41       try {
42         await keycloak.init({
43           config: {
44             url: '//localhost:8180/auth',
45             realm: 'test',
46             clientId: 'my-client'
47           },
48           initOptions: {
49             onLoad: 'login-required',
50             checkLoginIframe: false
51           },
52           bearerExcludedUrls: []
53         });
54         resolve();
55       } catch (error) {
56         reject(error);
57       }
58     });
59   };
60 }

首先,第2行增加引入APP_INITIALIZER;

然後,12行引入keyclock相關組件;

然後,23行增加provider,其實就是指定程式啟動時執行的腳本為initializer;

最後,38行編寫initializer方法,配置keyclock啟動參數,並且配置了應用啟動時直接調用登錄(可選)

接下來,實現如下的CanAuthenticationGuard,用來控制路由。

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { KeycloakAuthGuard, KeycloakService } from 'keycloak-angular';
 
@Injectable({
  providedIn: 'root'
})
export class CanAuthenticationGuard extends KeycloakAuthGuard implements CanActivate {
  constructor(protected router: Router, protected keycloakAngular: KeycloakService) {
    super(router, keycloakAngular);
  }
 
  isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (!this.authenticated) {
        this.keycloakAngular.login()
          .catch(e => console.error(e));
        return reject(false);
      }
 
      const requiredRoles: string[] = route.data.roles;
      if (!requiredRoles || requiredRoles.length === 0) {
        return resolve(true);
      } else {
        if (!this.roles || this.roles.length === 0) {
          resolve(false);
        }
        resolve(requiredRoles.every(role => this.roles.indexOf(role) > -1));
      }
    });
  }
}

在app-routing.module.ts中引用該guard,並且在需要控制的路由如下處理

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./tabs/tabs.module').then(m => m.TabsPageModule)
  },
  {
    path: 'hero/list',
    canActivate: [CanAuthenticationGuard],
    loadChildren: () => import('./hero/list/list.module').then( m => m.ListPageModule)
  },

主要是canActive一行。

配置keyclock

略去如何安裝以及運行keyclock的詳細說明,我是通過docker安裝運行的。

首先,新建realm = test

然後,新建client = my-client,url = //localhost:8100,也就是ionic app調試運行的地址

然後,建立一個用戶並配置密碼。

瀏覽器打開//localhost:8100,如果配置正確,會看到請求被重定向到keycloak的登陸介面,輸入用戶名密碼後跳轉回ionic app。

至此,inoic集成keycloak的工作基本完成。

特別要說明的,集成keycloak之後,不僅能控制路由的訪問,而且所有的http請求都會自動加上登陸時獲取的token,方便了webapi的調用。你可以在app.module.ts的52行,添加例外。

調用WebApi

如果不使用OpenApi程式碼自動生成,調用WebApi的套路無非是編寫interface,然後編寫service,使用http調用webapi,有許多重複的程式碼和機械步驟。

OpenApi程式碼生成器解決了這一問題,可以替代我們生成這些程式碼,很方便。

首先,安裝全局工具 //github.com/OpenAPITools/openapi-generator

該工具提供了npm包,但只是一個封裝,還是需要系統有java環境的。

npm install @openapitools/openapi-generator-cli -g

使用如下命令生成程式碼:

openapi-generator -i {swagger文件url} -g typescript-angular -o {程式碼存放目錄}

運行完畢後,看到程式碼目錄下生成了一堆文件,暫時不必理會這些文件,也不要修改這些文件。

找到任意一個page的ts文件,添加程式碼,使用生成的客戶端:

import { WeatherForecast } from './../../../../services/model/weatherForecast';
import { WeatherForecastService } from './../../../../services/api/weatherForecast.service';
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-list',
  templateUrl: './list.page.html',
  styleUrls: ['./list.page.scss'],
})
export class ListPage implements OnInit {
  weathers: WeatherForecast[];

  constructor(private weatherforcastService: WeatherForecastService) { }

  ngOnInit() {
    this.weatherforcastService.weatherForecastGet()
      .subscribe(data => this.weathers = data);
  }

}

程式碼相當簡單,WeatherForcast和WeatherForecastService已經幫我們自動生成了,直接使用就可以,是不是很cool呢?!

接下來,你可能有疑問了,光看到service,也不能修改生成的源碼,那麼去哪兒修改service的地址呢?很簡單,翻看前面的app.module.ts,第1行引入BASE_PATH,然後在provider中替換它的內容即可。

總結

至此,我們實現了在angular/ionic中使用keycloak進行oauth認證並且訪問webapi資源,還實現了使用openapi程式碼生成器自動生成客戶端程式碼。