Spring Security Sample

①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳✕✓✔✖

1. 例子目录

Spring 的文档越来越友好了,给出了相应的例子

2. Getting Started

官方提供了三个例子:

  • Hello:没有安全认证
  • Hello Security: 最简单的例子
  • Hello Security Explicit: 明确的配置文件

2.1 代码说明

添加Spring Security其实很简单。添加依赖包就可以了

添加以依赖包

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
}

如果要明确配置Explicit Config,需要添加配置类。

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.httpBasic(withDefaults())
.formLogin(withDefaults());
// @formatter:on
return http.build();
}
// @formatter:off
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
// @formatter:on
}

2.2 额外收获

2.2.1 测试

① 依赖包

添加了nebula.integtest selenium

plugins {
id "nebula.integtest" version "8.2.0"
}
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
integTestImplementation "org.seleniumhq.selenium:htmlunit-driver"
}
//这个需要了解一下具体的功能
tasks.withType(Test).configureEach {
useJUnitPlatform()
outputs.upToDateWhen { false }
}
② 测试代码

这里使用了MockMvc @WithMockUser

测试的代码可以实现那些功能呢?

  • 模拟一个 http 连接
  • 模拟一个用户
@SpringBootTest
@AutoConfigureMockMvc
public class HelloApplicationTests {
@Autowired
private MockMvc mockMvc;
@Test
void indexThenOk() throws Exception {
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().isOk());
// @formatter:on
}
@Test
@WithMockUser
void indexWhenAuthenticatedThenOk() throws Exception {
// @formatter:off
this.mockMvc.perform(get("/"))
.andExpect(status().isOk());
// @formatter:on
}
}

3. Authentication 认证

3.1 Remember-me

记住我,在一般的程序中可以用,但是在单点登陆就不好用了。官方的例子是基于 servlet 进行的,但是实际的过程中,可能都是Springboot的形式了。

① 核心代码

添加这个功能不复杂,只需要下面两点:

// SecurityConfiguration 类中添加下面的内容
.rememberMe((rememberMe) -> rememberMe.userDetailsService(users));

然后重载原有的 html 文件,并且添加记着我的代码:

<p>
<label for="remember-me">Remember Me?</label>
<input type="checkbox" id="remember-me" name="remember-me" />
</p>

② 测试代码

这里使用了MockMvc来进行模拟的测试

@SpringBootTest
public class RememberMeTests {
@Test
void loginWhenRemembermeThenAuthenticated(WebApplicationContext context) throws Exception {
// @formatter:off
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(springSecurity())
.build();
MockHttpServletRequestBuilder login = post("/login")
.with(csrf())
.param("username", "user")
.param("password", "password")
.param("remember-me", "true");
MvcResult mvcResult = mockMvc.perform(login)
.andExpect(authenticated())
.andReturn();
// @formatter:on
Cookie rememberMe = mvcResult.getResponse().getCookie("remember-me");
// @formatter:off
mockMvc.perform(get("/").cookie(rememberMe))
.andExpect(authenticated());
// @formatter:on
}
}

③ 堆栈过程

认证过程

authenticate:127, AbstractUserDetailsAuthenticationProvider (org.springframework.security.authentication.dao)
authenticate:182, ProviderManager (org.springframework.security.authentication)
authenticate:201, ProviderManager (org.springframework.security.authentication)
attemptAuthentication:85, UsernamePasswordAuthenticationFilter (org.springframework.security.web.authentication)
doFilter:223, AbstractAuthenticationProcessingFilter (org.springframework.security.web.authentication)
doFilter:213, AbstractAuthenticationProcessingFilter (org.springframework.security.web.authentication)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:103, LogoutFilter (org.springframework.security.web.authentication.logout)
doFilter:89, LogoutFilter (org.springframework.security.web.authentication.logout)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilterInternal:132, CsrfFilter (org.springframework.security.web.csrf)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doHeadersAfter:90, HeaderWriterFilter (org.springframework.security.web.header)
doFilterInternal:75, HeaderWriterFilter (org.springframework.security.web.header)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:110, SecurityContextPersistenceFilter (org.springframework.security.web.context)
doFilter:80, SecurityContextPersistenceFilter (org.springframework.security.web.context)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilterInternal:55, WebAsyncManagerIntegrationFilter (org.springframework.security.web.context.request.async)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilterInternal:211, FilterChainProxy (org.springframework.security.web)
doFilter:183, FilterChainProxy (org.springframework.security.web)
invokeDelegate:358, DelegatingFilterProxy (org.springframework.web.filter)
doFilter:271, DelegatingFilterProxy (org.springframework.web.filter)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core) [4]
doFilterInternal:100, RequestContextFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core) [3]
doFilterInternal:93, FormContentFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core) [2]
doFilterInternal:201, CharacterEncodingFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core) [1]
invoke:202, StandardWrapperValve (org.apache.catalina.core)
invoke:97, StandardContextValve (org.apache.catalina.core)
invoke:542, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:143, StandardHostValve (org.apache.catalina.core)
invoke:92, ErrorReportValve (org.apache.catalina.valves)
invoke:78, StandardEngineValve (org.apache.catalina.core)
service:357, CoyoteAdapter (org.apache.catalina.connector)
service:382, Http11Processor (org.apache.coyote.http11)
process:65, AbstractProcessorLight (org.apache.coyote)
process:893, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1723, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1128, ThreadPoolExecutor (java.util.concurrent)
run:628, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:829, Thread (java.lang)

授权过程

ExceptionTranslationFilter -> AuthorizationFilter ->AuthorizationManager(这是一个接口,有很多实现的类)::RequestMatcherDelegatingAuthorizationManager(这是一个通过 URL 匹配的 Manager) -> AuthorityAuthorizationManager(判断属性的的 Manager)

matcher:164, AntPathRequestMatcher (org.springframework.security.web.util.matcher)
check:74, RequestMatcherDelegatingAuthorizationManager (org.springframework.security.web.access.intercept)
check:44, RequestMatcherDelegatingAuthorizationManager (org.springframework.security.web.access.intercept)
verify:42, AuthorizationManager (org.springframework.security.authorization)
doFilterInternal:57, AuthorizationFilter (org.springframework.security.web.access.intercept)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:122, ExceptionTranslationFilter (org.springframework.security.web.access)
doFilter:116, ExceptionTranslationFilter (org.springframework.security.web.access)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:126, SessionManagementFilter (org.springframework.security.web.session)
doFilter:81, SessionManagementFilter (org.springframework.security.web.session)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:109, AnonymousAuthenticationFilter (org.springframework.security.web.authentication)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:149, SecurityContextHolderAwareRequestFilter (org.springframework.security.web.servletapi)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:63, RequestCacheAwareFilter (org.springframework.security.web.savedrequest)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilterInternal:58, DefaultLogoutPageGeneratingFilter (org.springframework.security.web.authentication.ui)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:237, DefaultLoginPageGeneratingFilter (org.springframework.security.web.authentication.ui)
doFilter:223, DefaultLoginPageGeneratingFilter (org.springframework.security.web.authentication.ui)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:219, AbstractAuthenticationProcessingFilter (org.springframework.security.web.authentication)
doFilter:213, AbstractAuthenticationProcessingFilter (org.springframework.security.web.authentication)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:103, LogoutFilter (org.springframework.security.web.authentication.logout)
doFilter:89, LogoutFilter (org.springframework.security.web.authentication.logout)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilterInternal:117, CsrfFilter (org.springframework.security.web.csrf)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doHeadersAfter:90, HeaderWriterFilter (org.springframework.security.web.header)
doFilterInternal:75, HeaderWriterFilter (org.springframework.security.web.header)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilter:110, SecurityContextPersistenceFilter (org.springframework.security.web.context)
doFilter:80, SecurityContextPersistenceFilter (org.springframework.security.web.context)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilterInternal:55, WebAsyncManagerIntegrationFilter (org.springframework.security.web.context.request.async)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
doFilterInternal:211, FilterChainProxy (org.springframework.security.web)
doFilter:183, FilterChainProxy (org.springframework.security.web)
invokeDelegate:358, DelegatingFilterProxy (org.springframework.web.filter)
doFilter:271, DelegatingFilterProxy (org.springframework.web.filter)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:100, RequestContextFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:93, FormContentFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:201, CharacterEncodingFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core)
invoke:202, StandardWrapperValve (org.apache.catalina.core)
invoke:97, StandardContextValve (org.apache.catalina.core)
invoke:542, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:143, StandardHostValve (org.apache.catalina.core)
invoke:92, ErrorReportValve (org.apache.catalina.valves)
invoke:78, StandardEngineValve (org.apache.catalina.core)
service:357, CoyoteAdapter (org.apache.catalina.connector)
service:382, Http11Processor (org.apache.coyote.http11)
process:65, AbstractProcessorLight (org.apache.coyote)
process:893, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1723, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1128, ThreadPoolExecutor (java.util.concurrent)
run:628, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:829, Thread (java.lang)
AbstractFilterRegistrationBean

3.2 Form Login

① Login 页面

自定义Login页面,并且在SecurityConfiguration中配置.

.formLogin((form) -> form
.loginPage("/login")
.permitAll()
);

实际上有很多细节要处理,例如 logout 后如何在首页显示出已经 logout 了。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeRequests((authorize) -> authorize
// 这个必须添加不然返回:/login?logout会认为没有权限,然后直接给跳转到/login,这样参数就没有了。
.antMatchers(HttpMethod.GET,"/login").permitAll()
.anyRequest().authenticated()
)
// .formLogin(withDefaults())
.formLogin((form) -> form
.loginPage("/login")
.permitAll()
)
;
// @formatter:on
return http.build();
}

也可以用下面的来实现相同的效果 :

.exceptionHandling(exceptionHandling->{
exceptionHandling.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));
})
.formLogin(withDefaults())

② Logout 页面

Post 到/logout 会执行退出,但是这个要求添加_csrf

Get 到/logout 会显示退出确认页

  • 如果是使用.formLogin(withDefaults()) ,系统会自动添加一个DefaultLogoutPageGeneratingFilter,来生成一个页面。
    • DefaultLoginPageConfigurer代码中,会将DefaultLogoutPageGeneratingFilter添加到FilterChain
  • 如果不想使用默认的,可以自己添加一个 Controller 来显示自定义的页面。

③ CSRF 说明

会自动启用 CSRF,并在每个 Form 中添加一个 CSRF Token,这是怎么做到呢?

官方文档写的很清楚

自动 CSRF 令牌包含

  • Spring Security 的 CSRF 支持通过其 CsrfRequestDataValueProcessor 提供与 Spring 的 RequestDataValueProcessor 的集成。这意味着如果您利用 Spring 的表单标签库、Thymeleaf 或任何其他与 RequestDataValueProcessor 集成的视图技术,那么具有不安全 HTTP 方法(即 post)的表单将自动包含实际的 CSRF 令牌。

3.3 Multi-factor Authentication

参考网址

这个代码很奇怪,在 Chrome 中不行,但是在 firefox 中可以执行完整。

3.3.1 代码分析

① 自定义用户
  • 核心是要实现UserDetailService这个接口,其他都是辅助类。

官方例子中的 custom-user 中有详细的说明。

类名说明
CustomUserRepository接口,数据存储类,里面定义了 findCustomUserByEmail 函数。
MapCustomUserRepository使用 map 来实现了上面的接口。
CustomUserRepositoryUserDetailService继承了UserDetailService ,这是整个系统的核心。
1:实现了UserDetails loadUserByUsername(...) 这个方法。
2:定义了一个子类:CustomUserDetail 这个类继承了CustomUserDetail类,并实现了UserDetail接口
CustomUser类似与自定义的一个数据库类,里面有用户名、密码等信息。
② 权限相关
类名说明
MfaApplication1:通常的App启动程序
2:配置了一个MapCustomUserRepository,初始化了用户数据。
SecurityConfig1:配置安全信息。
1.1 /second-factor, /third-factor 使用自定义的mfaAuthoriztionManager
1.2 重载了formLoginsuccessHandlerfailureHandler,使用了自己的mfaAuthenticationHandler
1.3 exceptionHandling 中使用了自己的MfaTrustResolver
MfaAuthentication继承了AbstractAuthenticationToken
1:重载父类的getPrincipalgetCredentialseraseCredentialsisAuthenticated 方法。
2:实现了自己的方法:getFirst=得到当前登陆输入的用户名密码的那个Authentication
MfaAuthenticationHandler实现了AuthenticationSuccessHandlerAuthenticationFailureHandler接口。
1:重载了onAuthenticationFailure方法,如果登陆失败,就新建匿名Authentication,并设置到 Context 中,并进行跳转。
2:重载了onAuthenticationSuccess方法,将得到的authentication设置到 Context 中,并进行跳转。
主要功能:进行跳转的 Handler
1:在SecurityConfig配置类中,将formLoginsuccessHandlerfailureHandler当登陆成功后,跳转到/second-factor
2:在MfaController中的/second-factorPost 方法中,指定如果成功后,跳转到/third-factor
MfaController进行页面跳转的类:
构造函数:得到一些 Bean:加密解密类,密码加密类,系统默认successHandlerfailureHandler。这些 Bean 都定义在SecurityConfig类中
1:requestSecondFactor显示第二页面
2:processSecondFactor处理第二页面请求
3:requestThirdFactor显示第三页面
4:processThirdFactor处理第三页面请求
MfaService检测输入的 code 是否正确。在MfaController中用到。
MfaTrustResolver异常处理时,可以使用的类AuthenticationTrustResolver
SecurityConfigexceptionHandling配置了这个类
③ 设计技巧
  • 要实现 SpringBoot 的登陆设计,就必须要实现UserDetailServiceUserDetail这两个类。

  • 设计出了一个Repository接口,让后用Map来实现,今后可以换成 JDBC 等实现的方式。

  • 实现了UserDetailService,这样系统在登陆的时候,就可以直接调用了这个类,这个类又注入了Repository来检索出用户。

  • CustomUserDetail是先继承了一个 Bean 类,然后再实现了UserDetails接口。

3.3.2 TOTP 算法

在 MfaApplication.java 中,对用户的信息进行了初始化。

/*
通过TOTP算法得到一个hexSecret,并对这个进行了加密。
1:将hexSecret字符串转成byte[],然后使用encryptor进行加密,加密后得到一个byte[];
2: 加密后的byte[]不便于显示,所以先用Hex编码成char[],然后将char[]转成字符串,并保存到数据库中与用户进行关联。
*/
String hexSecret = "80ed266dd80bcd32564f0f4aaa8d9b149a2b1eaa";
String encrypted = new String(Hex.encode(encryptor.encrypt(hexSecret.getBytes())));
/*
如何解码呢?
1、将加密后的字符串encrypted,转成char[],由于Hex.decode只支持String, 所以转String后通过Hex.decode转成byte[],
2、用encryptor.decrypt将这个byte[]解密成新的byte[].
3、将byte[]这个数组再转成字符串,就是还原的
*/
String decrypted= new String(encryptor.decrypt(Hex.decode(new String(encrypted.toCharArray()))));

3.4 Custom-user

3.4.1 功能设计

在 Spring 默认的UserDetails只有用户名、密码、是否有效、是否过期、是否被锁定这些基本信息。但是在实际业务中,需要添加下面的属性:

  • ID、email
  • 同时添加可以按照email检索用户,实际上是把email来顶替了用户名。

3.4.2 开发过程

先把程序运行起来

  • 写一个最简单的入口程序:UserDetailsServiceApplication
  • 写一个@RestController ,就返回一个字符串。
  • 执行程序,能出现登陆入口,然后返回那个字符串。

完成与 User 存储与查询

  • 定义一个CustomUser类,就是一个简单的get set类,用来保存数据。
  • 定一个接口,CustomUserRepository,里面有一个方法:findCustomUserByEmail
  • 定义上个接口的实现类,MapCustomUserRepository,来实现这个方法。

真正组合来了:实现SpringSecurity内部的UserDetailsServiceUserDetails接口

  • 定义一个类CustomUserRepositoryUserDetailsService,实现了UserDetailsService
  • 把这个类定义成@Service,那么SpringSecurity会自定加载这个类。
  • 这个类的构造函数只有一个,并且参数是CustomUserRepository,这里用到了Spring@Autowired。为了让Spring能找到这个类,那么需要定义一个@Bean
  • UserDetailsServiceApplication中定义一个@Bean返回CustomUserRepository对象,这个对象里面包含了初始化的用户:user-password
  • 重载默认的函数loadUserByUsername,在函数体内,使用customUserRepository.findCustomUserByEmail来查询出用户。并返回一个UserDetails对象。
  • 由于SpringSecurity返回的都是UserDetails对象,所以要定义一个CustomUserDetails,将CustomUserUserDetails关联起来。
    • UserDetails是一个接口,继承就是,然后把默认的函数都返回true
    • 继承CustomUser类,这样就结合在一起了。
    • 为了方便,把构造函数做成可以输入CustomUser,然后就包装成一个UserDetails类了。
  • 上面两个做好后,就可以执行程序,看到结果了。

优化获取当前登陆用户的方法

  • 定义一个annotation CurrentUser,通过(@CurrentUser CustomUser user)来让Spring@Autowired自动填充对象。
  • @RestController 返回当前登陆用的信息。
@RestController
public class UserController {
@GetMapping("/user")
public CustomUser user(@CurrentUser CustomUser user){
return user;
}
}

3.4.3 测试

按照官方的例子,写了几个单元测试的例子,其中有些包找不到引发编译错误,感觉由于版本升级,测试模块还不稳定。

  • 主要是import static org.hamcrest.Matchers.equalTo这个包找不到了

引入必要的包与注解

  • 手工引入静态包

    • * import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
      * import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  • 类注解

    • @SpringBootTest
      @AutoConfigureMockMvc
  • 类变量

    • @Autowired
      private MockMvc mockMvc;

撰写登陆成功,并解析 json

  • @Test
    @WithUserDetails
    public void userWhenWithUserDetailsThenOK() throws Exception{
    MvcResult mvcResult= mockMvc.perform(get("/user"))
    .andExpect(status().isOk())
    .andReturn()
    ;
    String renStr= mvcResult.getResponse().getContentAsString();
    System.out.println(renStr);
    ObjectMapper om= new ObjectMapper();
    JsonNode node = om.readTree(renStr);
    Assertions.assertEquals(node.get("id").asLong(),1L);
    }

在官方的测试用例中,描述了很多功能

  • 单元测试方面
    • 使用了@WithUserDetails @WithMockUser 来模拟用户
    • 自己定义注解,来模拟用户
    • 使用了 jsonPath 方便的解析 json
  • 集成测试
    • 使用了 TestRestTemplate,来远程连接测试脚本。

3.5 githubAndWx 登陆

自定义一个页面,可以使用 github 进行的登陆。

3.5.1 github

① 设置回调地址

在 github 中的 Settings >Developer settings>中设置 oauth2 登陆:

② 引用依赖

主要需要这个:

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
③ 配置 yml
server:
port: 8080
spring:
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
github:
client-id: cadd65bbbc65a1111
client-secret: 62b27f0df2fcebb881111
④ 配置 github 登陆
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http
,CustomSavedRequestAwareAuthenticationSuccessHandler successHandler) throws Exception {
http
.authorizeRequests((authorize) -> authorize
// 这个必须添加不然返回:/login?logout会认为没有权限,然后直接给跳转到/login,这样参数就没有了。
.antMatchers(HttpMethod.GET,"/login").permitAll()
.antMatchers(HttpMethod.GET,"/oauth2/authorization/github").permitAll()
.anyRequest().authenticated()
)
//配置login
.exceptionHandling(exceptionHandling->{
exceptionHandling.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));
})
.formLogin(withDefaults());
// 自定义成功认证
http.oauth2Login()
.successHandler(successHandler);
return http.build();
}
  • 上面有三个注意点:
    • 使用:http.oauth2Login()
    • 使用:exceptionHandling 引入自定义的 login 界面
    • antMatchers(HttpMethod.GET,"/oauth2/authorization/github").permitAll() 放开权限。

注意不能使用下面的方法,使用自定义 login 界面。

.formLogin((form) -> form
.loginPage("/login")
.permitAll()
)
⑤ 登陆成功的处理

CustomSavedRequestAwareAuthenticationSuccessHandler 在上一步被传入了http.oauth2Login()

@Component
public class CustomSavedRequestAwareAuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request
, HttpServletResponse response, Authentication authentication)
throws ServletException, IOException {
// 这里仅仅打印了当前登录的oauth2用户信息
// 可以做一些用户同步操作,比如没有在本平台绑定手机号啥的,进行跳转,要求绑定什么的。
String name = authentication.getName();
super.logger.info("oauth2 authentication success, user: "+ name);
super.onAuthenticationSuccess(request, response, authentication);
}
}

可以参考这个网站的说明:

3.5.2 添加微信的登陆

微信登陆与现有的 oauth2 不同,要做特殊处理:

  • client_idappid
  • token 返回值不是 json
① 参考内容

参考文档

主的要的类

  • Spring 默认类
    • CommonOAuth2Provider : 几个常见登陆模式默认的参数 GOOGLE、GITHUB、FACEBOOK、OKTA

3.6 定制 Spring Security Client

Customizing Authorization and Token Requests with Spring Security 5.1 Client

3.6.1 定制 Authorization Request

如果要定制 Authorization Request,需要了解 OAuth2AuthorizationRequestResolver 接口。

public interface OAuth2AuthorizationRequestResolver {
OAuth2AuthorizationRequest resolve(HttpServletRequest request);
OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId);
}

系统默认的实现类是DefaultOAuth2AuthorizationRequestResolver,这个类是完全按照 OAuth2 的标准实现,如果发现你的服务器不是标准的服务器,那么需要自己定义类,并设置到 http 中。

//添加authorization request customizer
http.oauth2Login(oauth2->oauth2
.authorizationEndpoint(authorization->authorization
.authorizationRequestResolver(你定义的OAuth2AuthorizationRequestResolver)
)
);

自定实现 OAuth2AuthorizationRequestResolver 接口有两个途径:

  • 自定义一个类,并实现 OAuth2AuthorizationRequestResolver 接口。
  • 直接返回系统默认DefaultOAuth2AuthorizationRequestResolver,可以通过这个类的setAuthorizationRequestCustomizer函数来实现个性化定制。
    • 微信开放平台,就是用的这个方法。

3.6.2 定制 Token Request

系统通过接口OAuth2AccessTokenResponseClient来获取 Token

@FunctionalInterface
public interface OAuth2AccessTokenResponseClient<T extends AbstractOAuth2AuthorizationGrantRequest> {
OAuth2AccessTokenResponse getTokenResponse(T authorizationGrantRequest);
}

Spring 默认的实现是DefaultAuthorizationCodeTokenResponseClient,可以通过下面的方法进行定制化

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.tokenEndpoint()
.accessTokenResponseClient(accessTokenResponseClient())
//...
}
@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient(){
DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient =
new DefaultAuthorizationCodeTokenResponseClient();
accessTokenResponseClient.setRequestEntityConverter(new CustomRequestEntityConverter());
OAuth2AccessTokenResponseHttpMessageConverter tokenResponseHttpMessageConverter =
new OAuth2AccessTokenResponseHttpMessageConverter();
tokenResponseHttpMessageConverter.setTokenResponseConverter(new CustomTokenResponseConverter());
RestTemplate restTemplate = new RestTemplate(Arrays.asList(
new FormHttpMessageConverter(), tokenResponseHttpMessageConverter));
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
accessTokenResponseClient.setRestOperations(restTemplate);
return accessTokenResponseClient;
}
}

需要给 accessTokenResponseClient 设置两项内容:

  • setRequestEntityConverter 的请求内容
  • setRestOperations 设置 restTemplate
    • 设置返回的转换形式 tokenResponseHttpMessageConverter
    • 设置一个 setErrorHandler
OAuth2LoginAuthenticationFilter

4. JWT

这个是官方的例子:Login - Spring Boot

4.1 设计

这是一个入门的例子,不需要 Authentication 认证服务器就可以了。

按照正常的 JWT 逻辑:

  • 在系统中配置 Authentication 认证服务器地址,然后 Resrouce 自动去那个地址获取公钥。
  • 用户在 Authentication 认证服务器得到 Token 后,把 Token 发送到 Resource-Server 进行认证,认证通过就通过了。

在本例子中做了特殊处理:

  • 获取 Token,自己用了一个最简单的算法,生成 Token,模拟了 Authentication 认证服务器
  • 然后配置了一个 Resource-Server

4.2 测试方法

实验一下是否有返回值,如果没有,添加-I,看看返回的 header 中是否有 403 错误

curl -XPOST user:password@localhost:8080/token

然后把内容放到一个变量中

export TOKEN=`curl -XPOST user:password@localhost:8080/token`

把这个放入 Header 中,

curl -H "Authorization: Bearer $TOKEN" localhost:8080 && echo

最终可能会显示

Hello, user-token!

4.3 开发

先开发一个普通的 Application。

  • 定义一个普通的启动程序:JwtLoginApplication

  • 定义一个@RestController

    • @RestController
      public class HelloController {
      @GetMapping("/")
      public String hell(Authentication authentication){
      return "Hello, "+ authentication.getName() + "!";
      }
      }
  • 定义一个 SecurityConfig

    • 覆盖默认的用户

      • @Bean
        public UserDetailsService users(){
        return new InMemoryUserDetailsManager(
        User.withUsername("user")
        .password("{noop}password")
        .authorities("app")
        .build()
        );
        }
    • 设置所有 URL 都必须受到权限控制

      • @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http
        .authorizeRequests(authorize->authorize
        .anyRequest().authenticated()
        )
        .httpBasic(Customizer.withDefaults())
        ;
        return http.build();
        }
  • 执行应用,就可以运行程序了。

模拟 JWT Token 授权

  • 生成 RSAKey 的公钥与私钥,放到 Resource 目录中,并在配置文件中配置相关的路径

    • jwt:
      private.key: classpath:app.key
      public.key: classpath:app.pub
  • 定义一个 JwtEncoder,用来进行 Jwt 加密 。

  • @Bean
    JwtEncoder jwtEncoder(){
    JWK jwk=new RSAKey.Builder(key).privateKey(priv).build();
    JWKSource<SecurityContext> jwkSource= new ImmutableJWKSet<>(new JWKSet(jwk));
    return new NimbusJwtEncoder(jwkSource);
    }
  • 做一个 TokenController,可以返回 Token

    • @RestController
      public class TokenController {
      @Autowired
      JwtEncoder jwtEncoder;
      @PostMapping("/token")
      public String token(Authentication authentication){
      Instant now = Instant.now();
      long expiry=36000L;
      String scope=authentication.getAuthorities().stream()
      .map(GrantedAuthority::getAuthority)
      .collect(Collectors.joining(" "));
      JwtClaimsSet claims=JwtClaimsSet.builder()
      .issuer("hero")
      .issuedAt(now)
      .expiresAt(now.plusSeconds(expiry))
      .subject(authentication.getName())
      .claim("scope",scope)
      .build();
      return this.jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
      }
      }
    • 以上是一个 Post

  • 进行测试的时候,会返回 403,因为 Spring 的 csrf 起作用了。

    • curl -I -XPOST user:password@localhost:8080/token
    • 上面的-I 是显示 header

  • 配置 Spring 对这个 URL 链接忽略 csrf 防护

    • @Bean
      public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
      http
      .authorizeRequests(authorize->authorize
      .anyRequest().authenticated()
      )
      .csrf(csrf->csrf.ignoringAntMatchers("/token")) //配置Spring对这个URL链接忽略csrf防护
      .httpBasic(Customizer.withDefaults())
      ;
      return http.build();
      }
    • 执行下面的链接获取 Token

    • curl -XPOST user:password@localhost:8080/token

实现 Resource-Server 配置

  • 配置 jwtDecoder

    • @Bean
      JwtDecoder jwtDecoder(){
      return NimbusJwtDecoder.withPublicKey(this.key).build();
      }
  • 配置 ResourceServer

    • http
      .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
    • 按照上面的配置,那么访问页面,有两个路径可以访问页面,一个是用浏览器打开输入用户名与秘密就可以了,一个是用 jwt 认证。

  • 完善 jwt,配置 session 无状态,以及不能通过输入用户名与密码登陆。

    • http
      .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
      .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .exceptionHandling((exceptions) -> exceptions
      .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
      .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
      )

4.4 单元测试

① 不加 Token 会抛出错误

@Test
void rootWhenUnauthenticatedThen401() throws Exception{
this.mockMvc.perform(get("/"))
.andExpect(status().isUnauthorized())
;
}
@Test
void tokenWhenBadCredentialsThen401() throws Exception{
this.mockMvc.perform(post("/token"))
.andExpect(status().isUnauthorized());
}

② 添加 Token 不会抛出错误

分两步,第一步获取 token;第二步使用 token

@Test
void rootWhenAuthenticatedThenSaysHelloUser() throws Exception{
MvcResult mvcResult= this.mockMvc.perform(post("/token")
.with(httpBasic("user","password")))
.andExpect(status().isOk())
.andReturn();
String token = mvcResult.getResponse().getContentAsString();
this.mockMvc.perform(get("/")
.header("Authorization", "Bearer " + token))
.andExpect(content().string("Hello, user-token!"));
}

③ 类注释的用法

下面这两个方法都行,推荐使用第一种

@SpringBootTest
@AutoConfigureMockMvc
public class HelloControllerTests {

指定特定的类

@WebMvcTest({ HelloController.class, TokenController.class })
@Import({SecurityConfig.class, JwtConfig.class})
public class HelloControllerTests {

5. OAuth2.0

官方给出的例子。

5.1 authorization-server

5.1.1 检测

① authorization_code 模式

得到 code

  • 在浏览器的地址栏中输入:

    • http://localhost:9000/oauth2/authorize?response_type=code&client_id=login-client&scope=openid&state=state&redirect_uri=http://127.0.0.1:8080/authorized
    • 注意事项

      • 只用 client_id,不需要密码
      • redirect_uri 一定要提前配置在系统中,并且与请求 token 时的保持一直
      • state 是客户端生成的随机码,用户保证服务器返回保持一致
  • 如果是第一次登陆,需要认证,请输入系统内置的用户名和密码:user password

  • 如果要确认 Scope,那么会弹出范围确认页面。

使用 Post 认证模式得到 token

curl --location --request POST 'http://localhost:9000/oauth2/token?grant_type=authorization_code&client_id=login-client&client_secret=openid-connect&redirect_uri=http://127.0.0.1:8080/authorized&code=ZHccPMG-odfrGRQZt5iU9i_I0xpn2S25CjsDonHJIWUO9piBGeNNlwIrlZ2t_jUhgKLnug3yrCsb53A6S-Zh5t2rcI3i9XEt5mQZIWLacmVgIlu5xboK16cWCNzHwXT6'

使用 Basic 认证模式得到 token

curl --location --request POST 'http://localhost:9000/oauth2/token?grant_type=authorization_code&redirect_uri=http://127.0.0.1:8080/authorized&code=3Opv5cyLcKG5gOB2BIkuhx8IUrIjPmzhq1PozNS-lF9nWFBQnatLwhzr2OXVKznmPCrdY3E1SHedNhiZUd5CsKdouSuxO5tkihPIo0R-VD6_dtVwoC5-jlSbTrmEMAFU' \
--header 'Authorization: Basic bG9naW4tY2xpZW50OnNlY3JldA=='

这里将用户名与密码进行编码后变成了bG9naW4tY2xpZW50OnNlY3JldA==

② Refresh Token
curl --location --request POST
'http://localhost:9000/oauth2/token?grant_type=refresh_token&client_id=login-client&client_secret=openid-connect&refresh_token=
5mxZe5wfyIzIRUuIQHR32pLRXdeooSHGPbw5IhXpR6zdBseG8jSbPf4EER-WkbugX4KEs-HOc5djtFjjo3zLRnjWhpoFzB1QDoDSqKyJm7dL_Mwztlpga6IoOZo7bm3J'
③ client_credentials 模式

该模式下,不需要登陆认证,就能直接得到 code。

curl --location --request POST
'http://localhost:9000/oauth2/token?grant_type=client_credentials&scope=message:read'
\ --header 'Authorization: Basic bWVzc2FnaW5nLWNsaWVudDpzZWNyZXQ='

也可以把用户名与密码放到参数中

curl --location --request POST
'http://localhost:9000/oauth2/token?grant_type=client_credentials&client_id=messaging-client&client_secret=secret&scope=message:read'
④ authorization_code+PKCE 模式

在线生成的页面地址: https://tonyxu-io.github.io/pkce-generator/

// Spring官方测试脚本中得到的例子
private static final String S256_CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
private static final String S256_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
S256
ParameterRequiredDescription
response_type必须您要执行的 OAuth 2.1 流程,code=Authorization Code ,这个可以使用PKCE
client_id必须您的应用程序的 ID
redirect_uri必须回调的地址,如果不填写,会报错,并且填写的内容与设置的一致
state推荐应用程序添加的一个数值,回调的时候会原封不动的返回。可以判断是不是服务器返回的。
code_challenge必须code_verifier运用hash算法s256加密得到一个code_challenge
code_challenge_method必须必须填写:S256 ,不然 Spring 提示错误
scope可选请求的范围

按照这个来得到 code

http://localhost:9000/oauth2/authorize?response_type=code&client_id=login-client&scope=openid&state=state&redirect_uri=http://127.0.0.1:8080/authorized&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256

得到 token

curl --location --request POST
'http://localhost:9000/oauth2/token?grant_type=authorization_code&client_id=login-client&redirect_uri=http://127.0.0.1:8080/authorized&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&code=C3PD5FrCuD9nIlqTOVkHm8CR1ASn0gd_Cu3DcgSPb80nLl8MzWWUVW822fUXsL_CrVwBOProla1mZL8GIWXrUGptWkqf7wv9D3v--d4F1K8MSogiiKDaa5KiwsdMq_Ki'

非常重要

  • 代码中一定要设置 ClientAuthenticationMethod.NONE

    .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // 为了PKCE

5.1.2 遇到错误怎么办?

看源代码

  • OAuth2ClientAuthenticationFilter : 客户端认证的 Filter 。
  • OAuth2TokenEndpointFilter:访问/oauth2/token 的 Filter 。
  • OAuth2AuthorizationCodeAuthenticationProvider:用来得到 OAuth2AccessToken OAuth2RefreshToken OidcIdToken
  • BearerTokenAccessDeniedHandler:拒绝 TokenAccess 的处理程序

看官方文档提供的大纲

5.1.3 修改配置文件

新建一个工程,引入依赖

implementation 'org.springframework.security:spring-security-oauth2-authorization-server:0.2.3'
// 这个就不用引用了 implementation 'org.springframework.boot:spring-boot-starter-security'

配置端口为 9000

server:
port: 9000

5.1.4 开发

开发一个简单的 SpringBootApplication

  • 定义一个普通的启动程序:OAuth2ASApplication

  • 定义安全配置

    • @Bean
      public SecurityFilterChain standardSecurityFilterChain(HttpSecurity http) throws Exception{
      http
      .authorizeRequests(authorize->authorize
      .anyRequest().authenticated()
      )
      .formLogin(Customizer.withDefaults());
      return http.build();
      }
  • 自定义一个测试用户

    • @Bean
      public UserDetailsService userDetailsService(){
      UserDetails userDetails= User.withUsername("user")
      .password("{noop}password")
      .roles("USER")
      .build();
      return new InMemoryUserDetailsManager(userDetails);
      }
  • 定义一个 IndexController,返回一个字符串:"Hello Author2AS!"

  • 然后执行程序,http://localhost:9000/ 弹出对话框,输入用户名密码后,显示"Hello Author2AS!"

添加认证服务器代码,有 4 个部分是必须的。

  • 一、指定加密算法:在整个系统运行的时候,会用到两个@Bean:JWKSource与JwtDecoder,这两个主要是利用非对称加密算法,对要加密的部分加密。所以要创建一个KeyPair类。

    • @Bean
      public JWKSource<SecurityContext> jwkSource(KeyPair keyPair) {
      RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
      RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
      // @formatter:off
      RSAKey rsaKey = new RSAKey.Builder(publicKey)
      .privateKey(privateKey)
      .keyID(UUID.randomUUID().toString())
      .build();
      // @formatter:on
      JWKSet jwkSet = new JWKSet(rsaKey);
      return new ImmutableJWKSet<>(jwkSet);
      }
      @Bean
      @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
      KeyPair generateRsaKey() {
      KeyPair keyPair;
      try {
      KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
      keyPairGenerator.initialize(2048);
      keyPair = keyPairGenerator.generateKeyPair();
      }
      catch (Exception ex) {
      throw new IllegalStateException(ex);
      }
      return keyPair;
      }
      @Bean
      public JwtDecoder jwtDecoder(KeyPair keyPair) {
      return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
      }
    • 在另外一个例子default-authorizationserver中没有JwtDecoder,因为在查看文档中,这个类是用在客户端解析用的。另外例子default-authorizationserver中的代码撰写的比较清晰。

  • 二、指定ProviderSettings

    • @Bean
      public ProviderSettings providerSettings() {
      return ProviderSettings.builder().issuer("http://localhost:9000").build();
      }
  • 三、配置SecurityFilterChain

    • @Bean
      @Order(1) //参数值越小优先级越高
      public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception{
      OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
      return http.formLogin(Customizer.withDefaults()).build();
      }
  • 四、配置客户端注册信息

    • @Bean
      public RegisteredClientRepository registeredClientRepository(){
      RegisteredClient loginClient =RegisteredClient.withId(UUID.randomUUID().toString())
      .clientId("login-client")
      .clientSecret("{noop}openid-connect")
      .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
      .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
      .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // 为了PKCE
      .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
      .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
      .redirectUri("http://127.0.0.1:8080/login/oauth2/code/login-client")
      .redirectUri("http://127.0.0.1:8080/authorized")
      .scope(OidcScopes.OPENID)
      .scope(OidcScopes.PROFILE)
      .scope("book:read")
      .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
      .build();
      RegisteredClient registeredClient= RegisteredClient.withId(UUID.randomUUID().toString())
      .clientId("messaging-client")
      .clientSecret("{noop}secret")
      .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
      .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
      .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
      .scope("message:read")
      .scope("message:write")
      .build();
      return new InMemoryRegisteredClientRepository(loginClient,registeredClient);
      }

5.1.5 测试

结合客户端进行测试比较方便,这里只能对 client_credentials 模式进行测试。这里使用了RequestPostProcessor

@SpringBootTest
@AutoConfigureMockMvc
public class ClientCredentialsITests {
private static final String CLIENT_ID="messaging-client";
private static final String CLIENT_SECRET="secret";
private final ObjectMapper objectMapper=new ObjectMapper();
@Autowired
private MockMvc mockMvc;
private static final class BasicAuthenticationRequestPostProcessor implements RequestPostProcessor{
private final String username;
private final String password;
private BasicAuthenticationRequestPostProcessor(String username,String password){
this.username=username;
this.password=password;
}
@Override
public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request){
HttpHeaders headers=new HttpHeaders();
headers.setBasicAuth(this.username,this.password);
request.addHeader("Authorization",headers.getFirst("Authorization"));
return request;
}
}
private BasicAuthenticationRequestPostProcessor basicAuth(String username,String password){
return new BasicAuthenticationRequestPostProcessor(username,password);
}
@Test
void performTokenRequestWhenValidClientCredentialsThenOk() throws Exception{
this.mockMvc.perform(post("/oauth2/token")
.param("grant_type","client_credentials")
.param("scope","message:read")
.with(basicAuth(CLIENT_ID,CLIENT_SECRET))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.access_token").isString())
.andExpect(jsonPath("$.expires_in").isNumber())
.andExpect(jsonPath("$.scope").value("message:read"))
.andExpect(jsonPath("$.token_type").value("Bearer"));
}
}

5.2 login

这个例子模拟了一个客户端,要运行这个例子,需要提前将 authorization-server 启动了。

redirect URI 默认的是http://127.0.0.1:8080/login/oauth2/code/login-client,也就是{baseUrl}/login/oauth2/code/{registrationId}。如果这个是运行在一个代理服务器后面,那么建议检查

Proxy Server Configuration来确认配置的正确性,同时也要看一看redirect-uriURI template variables

5.2.1 手工测试

输入:http://127.0.0.1:8080

跳转到:http://localhost:9000/login 输入:user password 后会跳转到权限确认页面。

然后调转到:http://127.0.0.1:8080/

点击 logout,会跳转到登陆页面,这个登陆页面是 Oauth2 特定的登陆页,与其他的不一样。

5.2.2 使用 9000 登陆

① 配置 application.yml
spring:
security:
oauth2:
client:
registration: <1>
login-client: <2>
provider: spring <3>
client-id: login-client
client-secret: openid-connect
client-authentication-method: client_secret_basic
authorization-grant-type: authorization_code
redirect-uri: http://127.0.0.1:8080/login/oauth2/code/login-client
scope: openid,profile <4>
client-name: Spring
provider: <5>
spring:
authorization-uri: http://localhost:9000/oauth2/authorize
token-uri: http://localhost:9000/oauth2/token
jwk-set-uri: http://localhost:9000/oauth2/jwks

<1> spring.security.oauth2.client.registration 是最基本的OAuth Client属性前缀。

<2> 在基本属性前缀之后是 ClientRegistration 的 ID,例如login-client 。 这个名字可以修改吗?可以,但是习惯上与client-id相同,为了好维护。

<3> provider 属性指定此 ClientRegistration 使用哪个提供程序配置。

<4> openid 要求Spring Authorization Server去按照authentication using OpenID Connect 1.0来执行。

<5> spring.security.oauth2.client.providerOAuth Provider 属性的基本前缀。

备注:配置文件中的下面属性要跟 Spring Authorization Server 一致

② 添加 OAuth2LoginApplication

添加一个最基本的@SpringBootApplication程序。

③ 添加一个 Controller 与 HTML 页面
@Controller
public class IndexController {
@GetMapping("/")
public String index(Model model, @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient,
@AuthenticationPrincipal OAuth2User oauth2User) {
model.addAttribute("userName", oauth2User.getName());
model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName());
model.addAttribute("userAttributes", oauth2User.getAttributes());
return "index";
}
}

HTML 页面 省略

④ 添加 LoopbackIpRedirectFilter

OAuth2AuthorizationCodeRequestAuthenticationProvider.isValidRedirectUri 会判断RedirectHost如果是localhost就返回false

String requestedRedirectHost = requestedRedirect.getHost();
if (requestedRedirectHost == null || requestedRedirectHost.equals("localhost")) {
// As per https://tools.ietf.org/html/draft-ietf-oauth-v2-1-01#section-9.7.1
// While redirect URIs using localhost (i.e.,
// "http://localhost:{port}/{path}") function similarly to loopback IP
// redirects described in Section 10.3.3, the use of "localhost" is NOT RECOMMENDED.
return false;
}

为了屏蔽这个功能,就要添加一个 Filter

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class LoopbackIpRedirectFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request
, HttpServletResponse response
, FilterChain filterChain)throws ServletException, IOException {
if(request.getServerName().equals("localhost")&& request.getHeader("host")!=null){
UriComponents uri= UriComponentsBuilder.fromHttpRequest(new ServletServerHttpRequest(request))
.host("127.0.0.1").build();
response.sendRedirect(uri.toUriString());
return;
}
filterChain.doFilter(request,response);
}
}

5.2.3 使用 github 登陆

① 注册 OAuth application
  • 登陆Register a new OAuth application
  • Authorization callback URL 设置成:http://127.0.0.1:8080/login/oauth2/code/github
  • 如果启动 device 流程,那么会向你的邮箱发送一个密码,输入密码后才可以登陆
  • 启动代理服务器后,系统一般执行不正常
② 配置 application.yml
spring:
security:
oauth2:
client:
registration: <1>
github: <2>
client-id: github-client-id
client-secret: github-client-secret

client-idclient-secret 属性中的值替换为您之前创建的 OAuth 2.0 凭据。

③ 启动服务

启动服务http://127.0.0.1:8080

中间有可能会没有直接跳转成功,显示下面这个页面:

5.2.4 使用 google 登陆

google 经常登陆不上,所以这里就不详细说明了。

5.2.4 自动化测试

① 怎么测试

测试 github 时,能不能不连接 github,模拟一个 github,主要测试下面的逻辑?

可以呀,这里要模拟一个为了得到 code 的请求,然后模拟一个 code,并做一个拦截器,把要获得的用户给模拟出来。

MockMvc 与 WebClient 的区别

Mvc 经常做单元测试,会模拟出一些登陆的用户。WebClient 一般做集成测试,会得到 HTML 页面上的元素,并模拟点击效果。

② MockMvc 测试

首先单元测试,可以通过 MockMvc 不通过认证服务器,就可以通过下面的方法模拟一个登陆

ResultActions resultActions=this.mvc.perform(get("/").with(oauth2Login()));

当然也可以指定 Client,认证方法,添加一些自定义属性

ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("test")
.authorizationGrantType(AuthorizationGrantType.PASSWORD)
.clientId("my-client-id")
.clientName("my-client-name")
.tokenUri("https://token-uri.example.org")
.build();
ResultActions resultActions=this.mvc.perform(get("/").with(oauth2Login()
.clientRegistration(clientRegistration)
.attributes(a->a.put("sub","spring-security")) ));
resultActions.andExpect(model().attribute("userName","spring-security"));
③ 集成测试

测试的内容如下:

  1. 访问首页跳转到登陆页
  2. 访问其他页面,也跳转到登陆页
  3. 在登陆页面点击 Github,然后模拟要请求 code 的链接,并判断链接是正确的。
  • requestAuthorizeGithubClientWhenLinkClickedThenStatusRedirectForAuthorization
  1. 在登陆页面点击 Github,如果连接地址不对,就返回 500 错误
  • requestAuthorizeClientWhenInvalidClientThenStatusInternalServerError
  1. 在登陆页面点击 Github,然后模拟 Github 授权,然后显示首页
  • requestAuthorizeCodeGrantWhenValidAuthorizationResponseThenDisplayIndexPage
  1. 在登陆页面点击 Github,模拟授权服务器返回 code 与 state,如果拿错误的 state,就报告错误。
  • requestAuthorizationCodeGrantWhenInvalidStateParamThenDisplayLoginPageWithError
  1. 使用 MockOAuth2Login 函数来模拟一个登陆用户,并显示首页。

这里面做了一个拦截器,用来 tokenEndpoint 中返回的内容,用自己的模拟出来,同时也模拟了 userInfoEndpoint

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http.authorizeRequests(authorize->authorize.anyRequest().authenticated())
.oauth2Login(oauth2->oauth2
.tokenEndpoint(token->token.accessTokenResponseClient(mockAccessTokenResponseClient()))
.userInfoEndpoint(userInfo->userInfo.userService(mockUserService()))
);
return http.build();
}

利用 mock when thenReturn 做了一个拦截器。

private OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> mockAccessTokenResponseClient(){
OAuth2AccessTokenResponse auth2AccessTokenResponse=OAuth2AccessTokenResponse.withToken("access-token-1234")
.tokenType(OAuth2AccessToken.TokenType.BEARER).expiresIn(60*1000).build();
//模拟系统中的OAuth2AccessTokenResponseClient,一旦发生了getTokenResponse,就返回自己的auth2AccessTokenResponse
OAuth2AccessTokenResponseClient tokenResponseClient=mock(OAuth2AccessTokenResponseClient.class);
when(tokenResponseClient.getTokenResponse(any())).thenReturn(auth2AccessTokenResponse);
return tokenResponseClient;
}

可以模拟一下一个 service

AService aService=mock(AService.class)
when(aService.fun1(any())).thenReturn(对象)
return aService;

同时可以看看代码中``webClient`的使用方法。

5.3 Resource Server

要使用Resource Server功能,需要引入spring-boot-starter-oauth2-resource-server

项目说明
hello-security一个标准的Resource Server,通过指定jwk-set-uriSpring Authorization Server做集成。
在测试代码中,可以看到如何集成mock Authorization Server进行测试,这部分其实在 jwe 例子中做了详细的说明,这里可以参考一下。
jwe演示了如何链接一个mock Authorization Server。使用了JWE-encrypted tokens,可以作为一个单独启动的服务,去展示你可以加密自己的 OAuth 2.0 Bearer Tokens
static不通过jwk-set-uriSpring Authorization Server做集成。而是将解密的密钥放在本地。
multi-tenancy多租户的例子
opaque不透明Tokens

5.3.1 hello-security

① http-client 测试

按照官网的帮助文档是通过脚本进行手工测试,在这个例子中使用了http-client类似于PostMan的进行测试。

### 获取Token POST {{AuthServerUri}}/oauth2/token Authorization: Basic
messaging-client secret Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=message:read > {%
client.log(response.body.access_token); client.test("Request executed
successfully", function() { client.assert(response.status === 200, "Response
status is not 200"); }); client.global.set("access_token",
response.body.access_token); %}

http-client 帮助文档

② 开发步骤
  • 第一步:建立一个普通的Application

  • 第二步:进行ResourceServer配置

    • 配置 application.yml

      • spring:
        security:
        oauth2:
        resourceserver:
        jwt:
        jwk-set-uri: http://localhost:9000/oauth2/jwks
    • 配额访问权限

      • @EnableWebSecurity
        public class OAuth2ResourceServerSecurityConfiguration {
        @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
        String jwkSetUri;
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http.authorizeRequests(authorize->authorize
        .antMatchers(HttpMethod.GET,"/message/**").hasAuthority("SCOPE_message:read")
        .antMatchers(HttpMethod.POST,"/message/**").hasAuthority("SCOPE_message:write")
        .anyRequest().authenticated()
        );
        //http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); 与下面的功能是一样的
        http.oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults()));
        return http.build();
        }
        /**
        * 在官方代码中有下面的函数,但是实际上不需要。可以注释了
        */
        // @Bean
        // JwtDecoder jwtDecoder(){
        // return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build();
        // }
        }
      • 关于如何配置 ResourceServer,有这么一个文档
  • 第三步:撰写@RestController

    • @RestController
      public class OAuth2ResourceServerController {
      @GetMapping("/")
      public String index(@AuthenticationPrincipal Jwt jwt){
      return String.format("Hello %s!",jwt.getSubject());
      }
      @GetMapping("/message")
      public String message(){
      return "secret message";
      }
      @PostMapping("/message")
      public String createMessage(@RequestBody String message){
      return String.format("Message was created. Content: %s",message);
      }
      }
③ 开发备注

如果参考官方文档,会有一种更简单的做法,使用了 issuer-uri ,这样就不用手工指定 jwk-set-uri

spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9000
# jwk-set-uri: http://localhost:9000/oauth2/jwks
④ 单元测试

在这个代码中,我犯了一个错误,由于测试的目录与实际的不一样,结果没有自动找到``SpringBootApplication`入口配置,所以最后把目录配置成一样了。

新的版本中import static org.hamcrest.CoreMatchers.is;这个包找不到了,估计要替换掉了。

MockMvcSecurityMockMvcRequestPostProcessors类很关键,包含了一些默认的方法,这里有一个文档描述了 RequestPostProcessors 作用。从下图,可以看出来这个类模拟大部分登陆的内容。

这个测试类就是为了使用这个类:

@Test
void indexGreetsAuthenticatedUser() throws Exception{
this.mockMvc.perform(get("/").with(jwt().jwt(jwt->jwt.subject("ch4mpy"))))
.andExpect(content().string("Hello ch4mpy!"));
this.mockMvc.perform(post("/message")
.content("Hello message")
.with(jwt().jwt(jwt->jwt.claim("scope","message:write")))
)
.andExpect(status().isOk());
}
⑤ 动态修改配置文件
  • 第一步:实现接口EnvironmentPostProcessor

    • @FunctionalInterface
      public interface EnvironmentPostProcessor {
      /**
      * Post-process the given {@code environment}.
      * @param environment the environment to post-process
      * @param application the application to which the environment belongs
      */
      void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application);
      }
  • 第二步:在META-INF/spring.factories中配置这个文件的路径

  • 补充:

    • 要提高优先级,请用@Order

    • 下面的代码,选自:SpringBootTestRandomPortEnvironmentPostProcessor

    • @Override
      public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
      MapPropertySource source = (MapPropertySource) environment.getPropertySources()
      .get(TestPropertySourceUtils.INLINED_PROPERTIES_PROPERTY_SOURCE_NAME);
      if (source == null || isTestServerPortFixed(source, environment) || isTestManagementPortConfigured(source)) {
      return;
      }
      Integer managementPort = getPropertyAsInteger(environment, MANAGEMENT_PORT_PROPERTY, null);
      if (managementPort == null || managementPort.equals(-1) || managementPort.equals(0)) {
      return;
      }
      Integer serverPort = getPropertyAsInteger(environment, SERVER_PORT_PROPERTY, 8080);
      if (!managementPort.equals(serverPort)) {
      source.getSource().put(MANAGEMENT_PORT_PROPERTY, "0");
      }
      else {
      source.getSource().put(MANAGEMENT_PORT_PROPERTY, "");
      }
      }
    • EnvironmentPostProcessor 怎么做单元测试?阿里 P7 告诉你

⑥ OkHttp3:mockwebserver

OkHttp3 系列(二)MockWebServer 使用

MockWebServer则是OkHttp3提供的一个快速创建 HTTP 服务端的工具。当我们的服务需要依赖外部 HTTP 应用时,可以按照预期功能快速构建外部 HTTP 应用,加快开发流程,快速进行单元测试,完善代码。搭配OkHttp3使用时,可以测试我们自己编写的OkHttp3客户端代码

MockWebServer server = new MockWebServer();
MockResponse mockResponse = new MockResponse().setBody("hello, world!")
server.enqueue(mockResponse);
try {
server.start(8080);
} catch (IOException e) {
e.printStackTrace();
}

正常的 HTTP 请求一般会对不同路径的请求返回不同的结果,MockWebServer通过Dispatcher支持该功能。

MockWebServer server = new MockWebServer();
final Dispatcher dispatcher = new Dispatcher() {
@Override
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
if (request.getPath().equals("/v1/login/auth/")){
return new MockResponse().setResponseCode(200);
} else if (request.getPath().equals("/v1/check/version")){
return new MockResponse().setResponseCode(200).setBody("version=9");
} else if (request.getPath().equals("/v1/profile/info")) {
return new MockResponse().setResponseCode(200).setBody("{\\\"info\\\":{\\\"name\":\"Lucas Albuquerque\",\"age\":\"21\",\"gender\":\"male\"}}");
}
return new MockResponse().setResponseCode(404);
}
};
server.setDispatcher(dispatcher);
try {
server.start(8099);
} catch (IOException e) {
e.printStackTrace();
System.exit(0);
}
⑦ 官方的集成测试

以前是使用了 9000 服务器,现在要模拟一个服务器。

官方例子代码思路如下:

  • 定义 application-test.yml,其中要模拟一个服务器地址:mockwebserver.url

    • jwk-set-uri: ${mockwebserver.url}/.well-known/jwks.json
    • 为了要做一个变量呢? 因为怕占用端口。追求高自动化测试。

  • 为了实现这个变量,需要定义如下的工作

    • 实现一个 EnvironmentPostProcessor 类,将 mockwebserver 变量注入到配置文件中。

    • 同时启动了一个 MockWebServer 服务器

官方代码的好处:

  • 可以自动获取端口,不用因为测试时候,端口被占用,引发测试失败。

官方代码的缺点:

  • 代码看起来过于复杂。因为引入了 EnvironmentPostProcessor 类
  • 将代码放入到 src 中,对源代码的侵入性比较大。
⑧ 优化后的集成测试

省去了 EnvironmentPostProcessor,将模拟的服务器端口写死,然后将 MockWebServer 放入到 Test 代码中

⑨ JSON Web Key Set Properties
{
"keys": [
{
"alg": "RS256",
"kty": "RSA",
"use": "sig",
"x5c": [
"MIIC+DCCAeCgAwIBAgIJBIGjYW6hFpn2MA0GCSqGSIb3DQEBBQUAMCMxITAfBgNVBAMTGGN1c3RvbWVyLWRlbW9zLmF1dGgwLmNvbTAeFw0xNjExMjIyMjIyMDVaFw0zMDA4MDEyMjIyMDVaMCMxITAfBgNVBAMTGGN1c3RvbWVyLWRlbW9zLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMnjZc5bm/eGIHq09N9HKHahM7Y31P0ul+A2wwP4lSpIwFrWHzxw88/7Dwk9QMc+orGXX95R6av4GF+Es/nG3uK45ooMVMa/hYCh0Mtx3gnSuoTavQEkLzCvSwTqVwzZ+5noukWVqJuMKNwjL77GNcPLY7Xy2/skMCT5bR8UoWaufooQvYq6SyPcRAU4BtdquZRiBT4U5f+4pwNTxSvey7ki50yc1tG49Per/0zA4O6Tlpv8x7Red6m1bCNHt7+Z5nSl3RX/QYyAEUX1a28VcYmR41Osy+o2OUCXYdUAphDaHo4/8rbKTJhlu8jEcc1KoMXAKjgaVZtG/v5ltx6AXY0CAwEAAaMvMC0wDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQUQxFG602h1cG+pnyvJoy9pGJJoCswDQYJKoZIhvcNAQEFBQADggEBAGvtCbzGNBUJPLICth3mLsX0Z4z8T8iu4tyoiuAshP/Ry/ZBnFnXmhD8vwgMZ2lTgUWwlrvlgN+fAtYKnwFO2G3BOCFw96Nm8So9sjTda9CCZ3dhoH57F/hVMBB0K6xhklAc0b5ZxUpCIN92v/w+xZoz1XQBHe8ZbRHaP1HpRM4M7DJk2G5cgUCyu3UBvYS41sHvzrxQ3z7vIePRA4WF4bEkfX12gvny0RsPkrbVMXX1Rj9t6V7QXrbPYBAO+43JvDGYawxYVvLhz+BJ45x50GFQmHszfY3BR9TPK8xmMmQwtIvLu1PMttNCs7niCYkSiUv2sc2mlq1i3IashGkkgmo="
],
"n": "yeNlzlub94YgerT030codqEztjfU_S6X4DbDA_iVKkjAWtYfPHDzz_sPCT1Axz6isZdf3lHpq_gYX4Sz-cbe4rjmigxUxr-FgKHQy3HeCdK6hNq9ASQvMK9LBOpXDNn7mei6RZWom4wo3CMvvsY1w8tjtfLb-yQwJPltHxShZq5-ihC9irpLI9xEBTgG12q5lGIFPhTl_7inA1PFK97LuSLnTJzW0bj096v_TMDg7pOWm_zHtF53qbVsI0e3v5nmdKXdFf9BjIARRfVrbxVxiZHjU6zL6jY5QJdh1QCmENoejj_ytspMmGW7yMRxzUqgxcAqOBpVm0b-_mW3HoBdjQ",
"e": "AQAB",
"kid": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg",
"x5t": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg"
}
]
}
Property nameDescription
alg与密钥一起使用的特定加密算法。
kty与密钥一起使用的密码算法系列。
use如何使用密钥; sig 代表签名。
x5cx.509 证书链. 数组中的第一个条目是用于令牌验证的证书;其他证书可用于验证第一个证书。
nRSA 公钥的模数。
eRSA 公钥的指数。
kid密钥的唯一标识符。
x5tx.509 证书的指纹(SHA-1 指纹)。

Navigating RS256 and JWKS

5.3.2 jwe

① 什么是 JWT,JWS 与 JWE

一篇文章带你分清楚 JWT,JWS 与 JWE

JWT=Json Web Token

现在网上大多数介绍JWT的文章实际介绍的都是JWS(JSON Web Signature),也往往导致了人们对于JWT的误解,但是JWT并不等于JWSJWS只是JWT的一种实现,除了JWS外,JWE(JSON Web Encryption)也是JWT的一种实现。

② JSON Web Signature(JWS)

JSON Web Signature 是一个有着简单的统一表达形式的字符串:

头部(Header)

头部用于描述关于该 JWT 的最基本的信息,例如其类型以及签名所用的算法等。 JSON 内容要经 Base64 编码生成字符串成为 Header。

载荷(PayLoad)

payload 的五个字段都是由 JWT 的标准所定义的。

  1. iss: 该 JWT 的签发者
  2. sub: 该 JWT 所面向的用户
  3. aud: 接收该 JWT 的一方
  4. exp(expires): 什么时候过期,这里是一个 Unix 时间戳
  5. iat(issued at): 在什么时候签发的

后面的信息可以按需补充。 JSON 内容要经 Base64 编码生成字符串成为 PayLoad。

签名(signature)

这个部分 header 与 payload 通过 header 中声明的加密方式,使用密钥 secret 进行加密,生成签名。

JWS 的主要目的是保证了数据在传输过程中不被修改,验证数据的完整性。但由于仅采用 Base64 对消息内容编码,因此不保证数据的不可泄露性。所以不适合用于传输敏感数据。

③ JSON Web Encryption(JWE)

相对于 JWS,JWE 则同时保证了安全性与数据完整性。 JWE 由五部分组成:

JWE 组成

具体生成步骤为:

  1. JOSE 含义与 JWS 头部相同。
  2. 生成一个随机的 Content Encryption Key (CEK)。使用 RSAES-OAEP 加密算法,用公钥加密 CEK,生成 JWE Encrypted Key。
  3. 生成 JWE 初始化向量。
  4. 使用 AES GCM 加密算法对明文部分进行加密生成密文 Ciphertext,算法会随之生成一个 128 位的认证标记 Authentication Tag。
  5. 对五个部分分别进行 base64 编码。

可见,JWE 的计算过程相对繁琐,不够轻量级,因此适合与数据传输而非 token 认证,但该协议也足够安全可靠,用简短字符串描述了传输内容,兼顾数据的安全性与完整性。

④ 代码分析
  • 可以 jws 与 jwe 一起用吗? 实验过程中是不能一起用的。代码逻辑是可以的。
  • 在 nimbus 的例子中,生成了 token,然后在这个例子中没有实验成功。

5.3.3 static

这里有两个密钥,分别是官方例子中自带的 simple.pub 与我自己用 OpenSSL 生成的 public.pem。为了让两个密钥都能演示,分别做了两个配置文件

  • application.yml
  • application-nimbus.yml 这里使用了我自带的密钥。
① 开发

开发的内容很简单,就是开发一个@RestController

@RestController
public class StaticResourceServerController {
@GetMapping("/")
public String index(@AuthenticationPrincipal Jwt jwt){
return String.format("Hello, %s!",jwt.getSubject());
}
@GetMapping("/message")
public String message(){
return "secret message";
}
}

然后编写 ResourceServer 的配置文件

@Configuration
public class StaticResourceServerConfiguration {
@Value("${spring.security.oauth2.resourceserver.jwt.key-value}")
RSAPublicKey key;
@Bean
public JwtDecoder jwtDecoder(){
return NimbusJwtDecoder.withPublicKey(key).build();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests(request->request
.antMatchers("/message/**").hasAuthority("SCOPE_message:read")
.anyRequest().authenticated()
);
//这里多余了这句话。可以使用默认的
http.oauth2ResourceServer(oauth2->oauth2
.jwt(Customizer.withDefaults())
);
return http.build();
}
}

上面代码的重点是:return NimbusJwtDecoder.withPublicKey(key).build();

② 测试

重点是使用了@ActiveProfiles 进行了测试

@SpringBootTest
@AutoConfigureMockMvc
// 是从官方jwe中复制过来的私钥与公钥。 正常的是自己生成的公钥
@ActiveProfiles("nimbus")
public class StaticResourceServerApplicationTest {
@Value("${test.noScopesToken}")
String noScopesToken;
@Value("${test.messageReadToken}")
String messageReadToken;

5.3.4 Opaque

① 开发

首先要添加一个 Application,然后添加一个 Controller

接下来配置 application.yml 文件

spring:
security:
oauth2:
resourceserver:
opaque:
introspection-uri: http://localhost:9000/oauth2/introspect
introspection-client-id: login-client
introspection-client-secret: openid-connect
server:
port: 8903

然后定义配置类

@EnableWebSecurity
public class OpaqueResourceServerSecurityConfiguration {
@Value("${spring.security.oauth2.resourceserver.opaque.introspection-uri}")
String introspectionUri;
@Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-id}")
String clientId;
@Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-secret}")
String clientSecret;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http.authorizeRequests(request->request
.mvcMatchers(HttpMethod.GET,"/message/**").hasAuthority("SCOPE_message:read")
.mvcMatchers(HttpMethod.POST,"message/**").hasAuthority("SCOPE_message:write")
.anyRequest().authenticated()
);
http.oauth2ResourceServer(oauth2->oauth2
.opaqueToken(opaque->opaque
.introspectionUri(this.introspectionUri)
.introspectionClientCredentials(this.clientId,this.clientSecret)
)
);
return http.build();
}
}
② 测试

官方的代码是使用了一个模拟的服务器。

这里,我使用了一个真实的服务器。9000 服务器。然后用 http client 进行了测试。

测试都通过了。

061Beb0w3vuYJY2Qlc0w3yJBEH1Beb00