使用 IdentityServer 保护 Web 应用(AntD Pro 前端 + SpringBoot 后端)
共 9024字,需浏览 19分钟
·
2022-06-27 16:36
需求背景
使用前后端分离开发的 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.cs
using 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.cs
namespace IdentityServer;
internal static class HostingExtensions
{
public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
// uncomment if you want to add a UI
builder.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.ts
const 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.tsx
import React from 'react';
import { useEffect } from 'react';
import type { IRouteComponentProps } from 'umi';
// @ts-ignore
import { 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.tsx
import { 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.cs
namespace IdentityServer;
internal static class HostingExtensions
{
public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
// uncomment if you want to add a UI
builder.Services.AddRazorPages();
builder.Services.AddIdentityServer(options =>
{
// https://docs.duendesoftware.com/identityserver/v6/fundamentals/resources/api_scopes#authorization-based-on-scopes
options.EmitStaticAudienceClaim = true;
// 将默认的 at+jwt 修改为 jwt
options.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.java
package 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 controller
import 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 项目都是以前独立开发的,今天终于通过登录保护这条线,把它们串在一起了,爽!