使用 IdentityServer 保护 Web 应用(AntD Pro 前端 + SpringBoot 后端)
需求背景
使用前后端分离开发的 Web 应用,想通过 IdentityServer 作为授权服务器将它保护起来,只允许登录后的用户使用。不管是前端页面,还是后端 API,都希望在登录前不可使用,而在登录后,都可以使用。即没有复杂的权限管理,只有登录和未登录的区别。
技术栈
这个 Web 应用的前端项目基于 AntD Pro,而后端 API 项目基于 Java SpringBoot;同时,授权服务器是基于 ASP.NET Core 的 IdentityServer。保护的方式是 OAuth 2 的授权码流程,即在打开页面时,如果没有登录,会自动跳转到 IdentityServer 做统一登录,登录完成后,跳转回 Web 应用的页面,这时页面已经拿到了访问令牌,同时页面开始向后端发送 ajax 请求,并带上这个访问令牌。也就是说,无论前端页面还是后端 API,都对同样的访问令牌做校验,通过则页面与 API 都能访问,否则,都不能访问。
关于如何部署一个 IdentityServer,可以参考:
流程示意

前端接入
前端使用了 AntD Pro 框架,而 AntD Pro 又是基于 UmiJs,在网上找到了一个 UmiJs 对接 OAuth 2 Server 的示例:https://github.com/io84team/umi-plugin-oauth2-client,除了它没有将插件发布的嘈点外,其他都很好。这里列一下在 AntD Pro 项目中利用 umi-plugin-oauth2-client 接入 IdentityServer 的详细步骤:
引入 umi-plugin-oauth2-client
由于上面提到的那个示例,作者似乎没有发布成 npm 包,因此引入的方式不太优雅,但能工作!拷贝相应的源码到 plugins 目录,如下图所示:

然后再在配置文件里,增加这个插件的引用:
// .umirc...plugins: [...require.resolve('./plugins/oauth2-client'),],...
接入 IdentityServer 配置
IdentityServer 增加一个客户端
首先,给这个 Web App 起个名字,比如叫 CoolApp,然后,在 IdentityServer 里增加该客户端,相当于备一个案:
// src/IdentityServer/Config.csusing Duende.IdentityServer;using Duende.IdentityServer.Models;namespace IdentityServer;public static class Config{...public static IEnumerable<Client> Clients =>new[]{new(){ClientId = "CoolApp",ClientSecrets ={new Secret("CoolApp".Sha256())},ClientName = "CoolApp",AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,RequireClientSecret = false,RedirectUris ={"http://localhost:8000/oauth2/callback","https://your.cool.app/oauth2/callback"},AllowedScopes ={IdentityServerConstants.StandardScopes.OpenId,IdentityServerConstants.StandardScopes.Profile,IdentityServerConstants.StandardScopes.Email,}},...};...
然后要将客户端添加到 IdentityServer 数据存储中,这里以内存为例:
// src/IdentityServer/HostingExtensions.csnamespace IdentityServer;internal static class HostingExtensions{public static WebApplication ConfigureServices(this WebApplicationBuilder builder){// uncomment if you want to add a UIbuilder.Services.AddRazorPages();builder.Services.AddIdentityServer().AddInMemoryIdentityResources(Config.IdentityResources).AddInMemoryApiScopes(Config.ApiScopes).AddInMemoryClients(Config.Clients).AddTestUsers(TestUsers.Users);...
在前端配置该 IdentityServer 元数据
// .umirc...oauth2Client: {clientId: 'CoolApp',accessTokenUri: 'https://your.identity.server/connect/token',authorizationUri: 'https://your.identity.server/connect/authorize',redirectUri:'http://localhost:8000/oauth2/callback',scopes: ['openid', 'email', 'profile'],userInfoUri: 'https://your.identity.server/connect/userinfo',userSignOutUri: 'https://your.identity.server/connect/endsession',homePagePath: '/',},...
路由修改
由于是保护所有的页面,因此将原来的父级路由增加一个 wrappers(参考官网文档),同时增加一个登录的路由,如下:
// .umirc.tsconst routes: IRoute[] = [{path: '/login', // 非必须,可以留作后续扩展使用component: 'login',layout: false,},{path: '/',wrappers: ['@/wrappers/auth'],component: '../layouts/BlankLayout',flatMenu: true,routes: [{name: 'xxx'path: '/yyy',component: './zzz',},...]}]
实现 auth wrapper
// src/wrappers/auth.tsximport React from 'react';import { useEffect } from 'react';import type { IRouteComponentProps } from 'umi';// @ts-ignoreimport { useOAuth2User } from 'umi';const Auth: React.FC<IRouteComponentProps> = (props) => {const { children } = props;// const { token, user, signIn, getSignUri } = useOAuth2User();const { token, user, signIn } = useOAuth2User();useEffect(() => {if (token === undefined && user === undefined) {// token 和 user 都是 undefined 时才需要请求。// const uri = getSignUri();// return <a href={uri}>Goto SSO</a>;// 显示登录链接,或者自动跳转登录,或者跳转到自己的登录页面。debugger;signIn();}},// 注销时不会重复登录// eslint-disable-next-line react-hooks/exhaustive-deps[],);if (token !== undefined && user !== undefined) {return children;}return <span>Loading...</span>;};export default Auth;
实现 login 组件
这不是必须的,但是建议增加一个简单的组件,展示一下登录态,如果已登录,就展示用户信息,并且提供一个退出的按钮(链接)。
// src/pages/login.tsximport { Link } from 'react-router-dom';import { OAuth2UserContext } from 'umi';export default () => {return (<OAuth2UserContext.Consumer>{({ user, token, signOut }) => {const userContent = token && user && (<div>{user.name}<br /><Link to="/" onClick={signOut}>SignOut</Link></div>);return (<div><div>Login Page</div><div>User: {JSON.stringify(user)}</div><div>Token: {JSON.stringify(token)}</div>{userContent}<Link to="/">Home</Link></div>);}}</OAuth2UserContext.Consumer>);};
效果
打开任意页面(除了 /login 外),只要是非登录态,就会跳转到 IdentityServer 服务器,登录后跳回。如果打开 /login 页面,可以查看已登录用户信息:

同时,发现 Local Storage 里有了访问令牌信息:

该令牌是一个 JWT,结构如下:

注意其中的 aud 字段,在后面保护 API 时需要用到。另外,注意 typ 字段,如果是 at+jwt,则需要对 IdentityServer 做相应配置,改成 jwt,以便让 SpringBoot 项目识别该令牌。
后端接入
虽然前端页面已经被保护起来了,但是,其后端 API 仍然可以使用 Postman 等方式直接访问,绕过了被保护起来的 UI。所以 API 也得保护起来,以 SpringBoot 项目为例,详解接入 IdentityServer 的步骤。
IdentityServer 做个小修改
IdentityServer 颁发的令牌,其 typ 字段默认是 at+jwt,这不被 springboot 项目识别,需要修改为 jwt:
// src/IdentityServer/HostingExtensions.csnamespace IdentityServer;internal static class HostingExtensions{public static WebApplication ConfigureServices(this WebApplicationBuilder builder){// uncomment if you want to add a UIbuilder.Services.AddRazorPages();builder.Services.AddIdentityServer(options =>{// https://docs.duendesoftware.com/identityserver/v6/fundamentals/resources/api_scopes#authorization-based-on-scopesoptions.EmitStaticAudienceClaim = true;// 将默认的 at+jwt 修改为 jwtoptions.AccessTokenJwtType = "jwt";})...
在 SpringBoot 项目中增加必要的依赖
// pom.xml...<dependency><groupId>org.springframework.security.oauth.boot</groupId><artifactId>spring-security-oauth2-autoconfigure</artifactId><version>2.1.4.RELEASE</version></dependency><dependency><groupId>org.springframework.security.oauth</groupId><artifactId>spring-security-oauth2</artifactId><version>2.3.5.RELEASE</version></dependency><dependency><groupId>com.sun.xml.bind</groupId><artifactId>jaxb-impl</artifactId><version>2.3.1</version></dependency><dependency><groupId>com.sun.xml.messaging.saaj</groupId><artifactId>saaj-impl</artifactId><version>1.5.1</version></dependency>...
增加资源服务器配置
增加一个资源服务器配置 ResourcesServerConfiguration 类,将前面的 JWT 令牌中的 aud 字段配置为该项目的 resourceId:
// src/main/java/com/.../application/ResourcesServerConfiguration.javapackage com.xxx.application;import org.springframework.context.annotation.Configuration;import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;public class ResourcesServerConfiguration extends ResourceServerConfigurerAdapter {public void configure(ResourceServerSecurityConfigurer resources) throws Exception {resources.resourceId("前面的 jwt 令牌中的 aud 字段");}}
对需要保护的接口增加 @EnableWebSecurity 注解
// xxxx controllerimport org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;......public class XxxController {
效果
如果不带 token 直接访问 API,或者带上了错误的过期的 token,会得到没有权限的错误:


而当带上正确有效的的 token 时,就可以得到预期的结果:

总结
本文以具体的例子,详解(手把手教)了如何保护前端页面和后端 API。这个方式其实可以举一反三,推广到更多的应用场景中。比如 IdentityServer 可以换成 Keycloak 等等任何支持 OAuth 2 的认证授权服务器;前端除了 Umi 技术栈外,也可以是其他技术栈,比如 Next Js,这时就可以使用 NextAuth(我做了一个示例:https://notion.inversify.cn/sign-in,欢迎体验) 。后端也可以是任何的技术栈,但是需要不同的接入方法。

nodejs 项目、springboot 项目和 ASP.NET Core 项目都是以前独立开发的,今天终于通过登录保护这条线,把它们串在一起了,爽!
