首发于 哈德韦
使用 IdentityServer 保护 Web 应用(AntD Pro 前端 + SpringBoot 后端)

使用 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,可以参考:

流程示意


image.png


前端接入

前端使用了 AntD Pro 框架,而 AntD Pro 又是基于 UmiJs,在网上找到了一个 UmiJs 对接 OAuth 2 Server 的示例: github.com/io84team/umi ,除了它没有将插件发布的嘈点外,其他都很好。这里列一下在 AntD Pro 项目中利用 umi-plugin-oauth2-client 接入 IdentityServer 的详细步骤:

引入 umi-plugin-oauth2-client

由于上面提到的那个示例,作者似乎没有发布成 npm 包,因此引入的方式不太优雅,但能工作!拷贝相应的源码到 plugins 目录,如下图所示:

image.png


然后再在配置文件里,增加这个插件的引用:

// .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 && (
            {user.name}
            <Link to="/" onClick={signOut}>
              SignOut
            </Link>
        return (
            <div>Login Page</div>
            <div>User: {JSON.stringify(user)}</div>
            <div>Token: {JSON.stringify(token)}</div>
            {userContent}
            <Link to="/">Home</Link>
    </OAuth2UserContext.Consumer>
};

效果

打开任意页面(除了 /login 外),只要是非登录态,就会跳转到 IdentityServer 服务器,登录后跳回。如果打开 /login 页面,可以查看已登录用户信息:

image.png

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

image.png

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

image.png

注意其中的 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;
@Configuration
@EnableResourceServer
public class ResourcesServerConfiguration extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("前面的 jwt 令牌中的 aud 字段");

对需要保护的接口增加 @EnableWebSecurity 注解

// xxxx controller 
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;