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


身份验证哪家强?Identity Server 初体验

流程示意


前端接入

前端使用了 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-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.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;
@Configuration@EnableResourceServerpublic 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;
...@RestController...@EnableWebSecuritypublic 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 项目都是以前独立开发的,今天终于通过登录保护这条线,把它们串在一起了,爽!




浏览 45
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报