需求背景

使用前后端分离开发的 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 的示例: https://github.com/io84team/umi-plugin-oauth2-client,除了它没有将插件发布的嘈点外,其他都很好。这里列一下在 AntD Pro 项目中利用 umi-plugin-oauth2-client 接入 IdentityServer 的详细步骤:

引入 umi-plugin-oauth2-client

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

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

... plugins: [ ... require.resolve(./plugins/oauth2-client), ],

...

接入 IdentityServer 配置

IdentityServer 增加一个客户端

首先,给这个 Web App 起个名字,比如叫 CoolApp,然后,在 IdentityServer 里增加该客户端,相当于备一个案: csharp // src/IdentityServer/Config.cs

using Duende.IdentityServer; using Duende.IdentityServer.Models;

namespace IdentityServer;

public static class Config { ... public static IEnumerable 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 数据存储中,这里以内存为例: csharp // 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 元数据

typescript // .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(参考官网文档),同时增加一个登录的路由,如下: typescript // .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

typescript // 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 = (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 Loading...; };

export default Auth;

实现 login 组件

这不是必须的,但是建议增加一个简单的组件,展示一下登录态,如果已登录,就展示用户信息,并且提供一个退出的按钮(链接)。

typescript // 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}
SignOut
); return (
Login Page
User: {JSON.stringify(user)}
Token: {JSON.stringify(token)}

        {userContent}
        <Link to=/>Home</Link>
      </div>
    );
  }}
</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:

csharp // 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 项目中增加必要的依赖

xml // 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: java // 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 注解

java // xxxx controller

import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

... @RestController ... @EnableWebSecurity public class XxxController {

效果

如果不带 token 直接访问 API,或者带上了错误的过期的 token,会得到没有权限的错误:

image.png

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

总结

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

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