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("http://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 = "http://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中将侦听地址改为http://localhost:5000
5、dotnet run启动
使用浏览器打开http://localhost:5000,看到如下文档界面。
ionic配置keycloak支持
使用keyclock-angular快速添加对于keyclock的支持,https://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: 'http://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: 'http://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 = http://localhost:8100,也就是ionic app调试运行的地址
然后,建立一个用户并配置密码。
浏览器打开http://localhost:8100,如果配置正确,会看到请求被重定向到keycloak的登陆界面,输入用户名密码后跳转回ionic app。
至此,inoic集成keycloak的工作基本完成。
特别要说明的,集成keycloak之后,不仅能控制路由的访问,而且所有的http请求都会自动加上登陆时获取的token,方便了webapi的调用。你可以在app.module.ts的52行,添加例外。
调用WebApi
如果不使用OpenApi代码自动生成,调用WebApi的套路无非是编写interface,然后编写service,使用http调用webapi,有许多重复的代码和机械步骤。
OpenApi代码生成器解决了这一问题,可以替代我们生成这些代码,很方便。
首先,安装全局工具 https://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代码生成器自动生成客户端代码。