①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳✕✓✔✖
Spring 的文档越来越友好了,给出了相应的例子。
Authentication
UserDetailsService
, 不常用,重载了UserDetailsService
UserDetailsManager
类AuthenticationManager
UserDetails
OAuth2.0
SAML 2.0
JWT
官方提供了三个例子:
添加Spring Security
其实很简单。添加依赖包就可以了
添加以依赖包
dependencies {implementation 'org.springframework.boot:spring-boot-starter-security'testImplementation 'org.springframework.security:spring-security-test'}
如果要明确配置Explicit Config
,需要添加配置类。
@Configuration@EnableWebSecuritypublic class SecurityConfiguration {@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {// @formatter:offhttp.authorizeRequests((authorize) -> authorize.anyRequest().authenticated()).httpBasic(withDefaults()).formLogin(withDefaults());// @formatter:onreturn http.build();}// @formatter:off@Beanpublic InMemoryUserDetailsManager userDetailsService() {UserDetails user = User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build();return new InMemoryUserDetailsManager(user);}// @formatter:on}
添加了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
测试的代码可以实现那些功能呢?
@SpringBootTest@AutoConfigureMockMvcpublic class HelloApplicationTests {@Autowiredprivate MockMvc mockMvc;@Testvoid indexThenOk() throws Exception {// @formatter:offthis.mockMvc.perform(get("/")).andExpect(status().isOk());// @formatter:on}@Test@WithMockUservoid indexWhenAuthenticatedThenOk() throws Exception {// @formatter:offthis.mockMvc.perform(get("/")).andExpect(status().isOk());// @formatter:on}}
记住我,在一般的程序中可以用,但是在单点登陆就不好用了。官方的例子是基于 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
来进行模拟的测试
@SpringBootTestpublic class RememberMeTests {@Testvoid loginWhenRemembermeThenAuthenticated(WebApplicationContext context) throws Exception {// @formatter:offMockMvc 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:onCookie rememberMe = mvcResult.getResponse().getCookie("remember-me");// @formatter:offmockMvc.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
自定义Login
页面,并且在SecurityConfiguration
中配置.
.formLogin((form) -> form.loginPage("/login").permitAll());
实际上有很多细节要处理,例如 logout 后如何在首页显示出已经 logout 了。
@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {// @formatter:offhttp.authorizeRequests((authorize) -> authorize// 这个必须添加不然返回:/login?logout会认为没有权限,然后直接给跳转到/login,这样参数就没有了。.antMatchers(HttpMethod.GET,"/login").permitAll().anyRequest().authenticated())// .formLogin(withDefaults()).formLogin((form) -> form.loginPage("/login").permitAll());// @formatter:onreturn http.build();}
也可以用下面的来实现相同的效果 :
.exceptionHandling(exceptionHandling->{exceptionHandling.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));}).formLogin(withDefaults())
Post 到/logout 会执行退出,但是这个要求添加_csrf
。
Get 到/logout 会显示退出确认页
.formLogin(withDefaults())
,系统会自动添加一个DefaultLogoutPageGeneratingFilter
,来生成一个页面。DefaultLoginPageConfigurer
代码中,会将DefaultLogoutPageGeneratingFilter
添加到FilterChain
会自动启用 CSRF,并在每个 Form 中添加一个 CSRF Token,这是怎么做到呢?
自动 CSRF 令牌包含
- Spring Security 的 CSRF 支持通过其 CsrfRequestDataValueProcessor 提供与 Spring 的 RequestDataValueProcessor 的集成。这意味着如果您利用 Spring 的表单标签库、Thymeleaf 或任何其他与 RequestDataValueProcessor 集成的视图技术,那么具有不安全 HTTP 方法(即 post)的表单将自动包含实际的 CSRF 令牌。
参考网址
这个代码很奇怪,在 Chrome 中不行,但是在 firefox 中可以执行完整。
UserDetailService
这个接口,其他都是辅助类。官方例子中的 custom-user 中有详细的说明。
类名 | 说明 |
---|---|
CustomUserRepository | 接口,数据存储类,里面定义了 findCustomUserByEmail 函数。 |
MapCustomUserRepository | 使用 map 来实现了上面的接口。 |
CustomUserRepositoryUserDetailService | 继承了UserDetailService ,这是整个系统的核心。1:实现了 UserDetails loadUserByUsername(...) 这个方法。2:定义了一个子类: CustomUserDetail 这个类继承了CustomUserDetail 类,并实现了UserDetail 接口 |
CustomUser | 类似与自定义的一个数据库类,里面有用户名、密码等信息。 |
类名 | 说明 |
---|---|
MfaApplication | 1:通常的App 启动程序2:配置了一个 MapCustomUserRepository ,初始化了用户数据。 |
SecurityConfig | 1:配置安全信息。 1.1 /second-factor , /third-factor 使用自定义的mfaAuthoriztionManager 1.2 重载了 formLogin 的successHandler 与failureHandler ,使用了自己的mfaAuthenticationHandler 1.3 exceptionHandling 中使用了自己的MfaTrustResolver |
MfaAuthentication | 继承了AbstractAuthenticationToken 1:重载父类的 getPrincipal 与getCredentials 与eraseCredentials 与 isAuthenticated 方法。2:实现了自己的方法: getFirst =得到当前登陆输入的用户名密码的那个Authentication |
MfaAuthenticationHandler | 实现了AuthenticationSuccessHandler 与AuthenticationFailureHandler 接口。1:重载了 onAuthenticationFailure 方法,如果登陆失败,就新建匿名Authentication ,并设置到 Context 中,并进行跳转。2:重载了 onAuthenticationSuccess 方法,将得到的authentication 设置到 Context 中,并进行跳转。主要功能:进行跳转的 Handler 1:在 SecurityConfig 配置类中,将formLogin 的successHandler 与failureHandler 当登陆成功后,跳转到/second-factor 2:在 MfaController 中的/second-factor Post 方法中,指定如果成功后,跳转到/third-factor |
MfaController | 进行页面跳转的类: 构造函数:得到一些 Bean:加密解密类,密码加密类,系统默认 successHandler 与failureHandler 。这些 Bean 都定义在SecurityConfig 类中1: requestSecondFactor 显示第二页面2: processSecondFactor 处理第二页面请求3: requestThirdFactor 显示第三页面4: processThirdFactor 处理第三页面请求 |
MfaService | 检测输入的 code 是否正确。在MfaController 中用到。 |
MfaTrustResolver | 异常处理时,可以使用的类AuthenticationTrustResolver SecurityConfig 中exceptionHandling 配置了这个类 |
要实现 SpringBoot 的登陆设计,就必须要实现UserDetailService
与UserDetail
这两个类。
设计出了一个Repository
接口,让后用Map
来实现,今后可以换成 JDBC 等实现的方式。
实现了UserDetailService
,这样系统在登陆的时候,就可以直接调用了这个类,这个类又注入了Repository
来检索出用户。
CustomUserDetail
是先继承了一个 Bean 类,然后再实现了UserDetails
接口。
在 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()))));
在 Spring 默认的UserDetails
只有用户名、密码、是否有效、是否过期、是否被锁定这些基本信息。但是在实际业务中,需要添加下面的属性:
ID、email
email
检索用户,实际上是把email
来顶替了用户名。先把程序运行起来
UserDetailsServiceApplication
@RestController
,就返回一个字符串。完成与 User 存储与查询
CustomUser
类,就是一个简单的get set
类,用来保存数据。CustomUserRepository
,里面有一个方法:findCustomUserByEmail
MapCustomUserRepository
,来实现这个方法。真正组合来了:实现SpringSecurity
内部的UserDetailsService
与UserDetails
接口
CustomUserRepositoryUserDetailsService
,实现了UserDetailsService
。@Service
,那么SpringSecurity
会自定加载这个类。CustomUserRepository
,这里用到了Spring
的@Autowired
。为了让Spring
能找到这个类,那么需要定义一个@Bean
。UserDetailsServiceApplication
中定义一个@Bean
返回CustomUserRepository
对象,这个对象里面包含了初始化的用户:user-password
loadUserByUsername
,在函数体内,使用customUserRepository.findCustomUserByEmail
来查询出用户。并返回一个UserDetails
对象。SpringSecurity
返回的都是UserDetails
对象,所以要定义一个CustomUserDetails
,将CustomUser
与UserDetails
关联起来。UserDetails
是一个接口,继承就是,然后把默认的函数都返回true
CustomUser
类,这样就结合在一起了。CustomUser
,然后就包装成一个UserDetails
类了。优化获取当前登陆用户的方法
annotation CurrentUser
,通过(@CurrentUser CustomUser user)
来让Spring
的@Autowired
自动填充对象。@RestController
返回当前登陆用的信息。@RestControllerpublic class UserController {@GetMapping("/user")public CustomUser user(@CurrentUser CustomUser user){return user;}}
按照官方的例子,写了几个单元测试的例子,其中有些包找不到引发编译错误,感觉由于版本升级,测试模块还不稳定。
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
类变量
@Autowiredprivate MockMvc mockMvc;
撰写登陆成功,并解析 json
@Test@WithUserDetailspublic 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);}
在官方的测试用例中,描述了很多功能
自定义一个页面,可以使用 github 进行的登陆。
在 github 中的 Settings >Developer settings>
中设置 oauth2 登陆:
主要需要这个:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
server:port: 8080spring:thymeleaf:cache: falsesecurity:oauth2:client:registration:github:client-id: cadd65bbbc65a1111client-secret: 62b27f0df2fcebb881111
@Beanpublic 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();}
antMatchers(HttpMethod.GET,"/oauth2/authorization/github").permitAll()
放开权限。注意不能使用下面的方法,使用自定义 login 界面。
.formLogin((form) -> form.loginPage("/login").permitAll())
CustomSavedRequestAwareAuthenticationSuccessHandler
在上一步被传入了http.oauth2Login()
@Componentpublic class CustomSavedRequestAwareAuthenticationSuccessHandlerextends SavedRequestAwareAuthenticationSuccessHandler {@Overridepublic 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);}}
可以参考这个网站的说明:
微信登陆与现有的 oauth2 不同,要做特殊处理:
client_id
是 appid
参考文档
主的要的类
Customizing Authorization and Token Requests with Spring Security 5.1 Client
如果要定制 Authorization Request,需要了解 OAuth2AuthorizationRequestResolver 接口。
public interface OAuth2AuthorizationRequestResolver {OAuth2AuthorizationRequest resolve(HttpServletRequest request);OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId);}
系统默认的实现类是DefaultOAuth2AuthorizationRequestResolver
,这个类是完全按照 OAuth2 的标准实现,如果发现你的服务器不是标准的服务器,那么需要自己定义类,并设置到 http 中。
//添加authorization request customizerhttp.oauth2Login(oauth2->oauth2.authorizationEndpoint(authorization->authorization.authorizationRequestResolver(你定义的OAuth2AuthorizationRequestResolver类)));
自定实现 OAuth2AuthorizationRequestResolver 接口有两个途径:
DefaultOAuth2AuthorizationRequestResolver
,可以通过这个类的setAuthorizationRequestCustomizer
函数来实现个性化定制。系统通过接口OAuth2AccessTokenResponseClient
来获取 Token
@FunctionalInterfacepublic interface OAuth2AccessTokenResponseClient<T extends AbstractOAuth2AuthorizationGrantRequest> {OAuth2AccessTokenResponse getTokenResponse(T authorizationGrantRequest);}
Spring 默认的实现是DefaultAuthorizationCodeTokenResponseClient
,可以通过下面的方法进行定制化
@Configurationpublic class SecurityConfig extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {http.tokenEndpoint().accessTokenResponseClient(accessTokenResponseClient())//...}@Beanpublic 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 设置两项内容:
OAuth2LoginAuthenticationFilter
这个是官方的例子:Login - Spring Boot
这是一个入门的例子,不需要 Authentication 认证服务器就可以了。
按照正常的 JWT 逻辑:
在本例子中做了特殊处理:
实验一下是否有返回值,如果没有,添加-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!
先开发一个普通的 Application。
定义一个普通的启动程序:JwtLoginApplication
定义一个@RestController
@RestControllerpublic class HelloController {@GetMapping("/")public String hell(Authentication authentication){return "Hello, "+ authentication.getName() + "!";}}
定义一个 SecurityConfig
覆盖默认的用户
@Beanpublic UserDetailsService users(){return new InMemoryUserDetailsManager(User.withUsername("user").password("{noop}password").authorities("app").build());}
设置所有 URL 都必须受到权限控制
@Beanpublic 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.keypublic.key: classpath:app.pub
定义一个 JwtEncoder,用来进行 Jwt 加密 。
@BeanJwtEncoder jwtEncoder(){JWK jwk=new RSAKey.Builder(key).privateKey(priv).build();JWKSource<SecurityContext> jwkSource= new ImmutableJWKSet<>(new JWKSet(jwk));return new NimbusJwtEncoder(jwkSource);}
做一个 TokenController,可以返回 Token
@RestControllerpublic class TokenController {@AutowiredJwtEncoder 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 防护
@Beanpublic 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
@BeanJwtDecoder 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()))
@Testvoid rootWhenUnauthenticatedThen401() throws Exception{this.mockMvc.perform(get("/")).andExpect(status().isUnauthorized());}@Testvoid tokenWhenBadCredentialsThen401() throws Exception{this.mockMvc.perform(post("/token")).andExpect(status().isUnauthorized());}
分两步,第一步获取 token;第二步使用 token
@Testvoid 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@AutoConfigureMockMvcpublic class HelloControllerTests {
指定特定的类
@WebMvcTest({ HelloController.class, TokenController.class })@Import({SecurityConfig.class, JwtConfig.class})public class HelloControllerTests {
官方给出的例子。
得到 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
注意事项
如果是第一次登陆,需要认证,请输入系统内置的用户名和密码: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==
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'
该模式下,不需要登陆认证,就能直接得到 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'
在线生成的页面地址: 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
Parameter | Required | Description |
---|---|---|
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
看源代码
/oauth2/token
的 Filter 。新建一个工程,引入依赖
implementation 'org.springframework.security:spring-security-oauth2-authorization-server:0.2.3'// 这个就不用引用了 implementation 'org.springframework.boot:spring-boot-starter-security'
配置端口为 9000
server:port: 9000
开发一个简单的 SpringBootApplication
定义一个普通的启动程序:OAuth2ASApplication
定义安全配置
@Beanpublic SecurityFilterChain standardSecurityFilterChain(HttpSecurity http) throws Exception{http.authorizeRequests(authorize->authorize.anyRequest().authenticated()).formLogin(Customizer.withDefaults());return http.build();}
自定义一个测试用户
@Beanpublic 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
类。
@Beanpublic JWKSource<SecurityContext> jwkSource(KeyPair keyPair) {RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();// @formatter:offRSAKey rsaKey = new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build();// @formatter:onJWKSet 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;}@Beanpublic JwtDecoder jwtDecoder(KeyPair keyPair) {return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();}
在另外一个例子default-authorizationserver
中没有JwtDecoder
,因为在查看文档中,这个类是用在客户端解析用的。另外例子default-authorizationserver
中的代码撰写的比较清晰。
二、指定ProviderSettings
@Beanpublic 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();}
四、配置客户端注册信息
@Beanpublic 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);}
结合客户端进行测试比较方便,这里只能对 client_credentials 模式进行测试。这里使用了RequestPostProcessor
@SpringBootTest@AutoConfigureMockMvcpublic class ClientCredentialsITests {private static final String CLIENT_ID="messaging-client";private static final String CLIENT_SECRET="secret";private final ObjectMapper objectMapper=new ObjectMapper();@Autowiredprivate 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;}@Overridepublic 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);}@Testvoid 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"));}}
这个例子模拟了一个客户端,要运行这个例子,需要提前将 authorization-server 启动了。
redirect URI 默认的是http://127.0.0.1:8080/login/oauth2/code/login-client
,也就是{baseUrl}/login/oauth2/code/{registrationId}
。如果这个是运行在一个代理服务器后面,那么建议检查
Proxy Server Configuration来确认配置的正确性,同时也要看一看redirect-uri
的URI template variables 。
跳转到:http://localhost:9000/login 输入:user password 后会跳转到权限确认页面。
然后调转到:http://127.0.0.1:8080/
点击 logout,会跳转到登陆页面,这个登陆页面是 Oauth2 特定的登陆页,与其他的不一样。
spring:security:oauth2:client:registration: <1>login-client: <2>provider: spring <3>client-id: login-clientclient-secret: openid-connectclient-authentication-method: client_secret_basicauthorization-grant-type: authorization_coderedirect-uri: http://127.0.0.1:8080/login/oauth2/code/login-clientscope: openid,profile <4>client-name: Springprovider: <5>spring:authorization-uri: http://localhost:9000/oauth2/authorizetoken-uri: http://localhost:9000/oauth2/tokenjwk-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.provider
是 OAuth Provider
属性的基本前缀。
备注:配置文件中的下面属性要跟 Spring Authorization Server 一致
- client-id client-secret
- 替换 http://localhost:9000 来与你的服务器一致。
添加一个最基本的@SpringBootApplication
程序。
@Controllerpublic 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 页面 省略
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 {@Overrideprotected 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);}}
http://127.0.0.1:8080/login/oauth2/code/github
spring:security:oauth2:client:registration: <1>github: <2>client-id: github-client-idclient-secret: github-client-secret
将 client-id
和 client-secret
属性中的值替换为您之前创建的 OAuth 2.0 凭据。
启动服务http://127.0.0.1:8080
中间有可能会没有直接跳转成功,显示下面这个页面:
google 经常登陆不上,所以这里就不详细说明了。
测试 github 时,能不能不连接 github,模拟一个 github,主要测试下面的逻辑?
可以呀,这里要模拟一个为了得到 code 的请求,然后模拟一个 code,并做一个拦截器,把要获得的用户给模拟出来。
MockMvc 与 WebClient 的区别
Mvc 经常做单元测试,会模拟出一些登陆的用户。WebClient 一般做集成测试,会得到 HTML 页面上的元素,并模拟点击效果。
首先单元测试,可以通过 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"));
测试的内容如下:
这里面做了一个拦截器,用来 tokenEndpoint 中返回的内容,用自己的模拟出来,同时也模拟了 userInfoEndpoint
@Beanpublic 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,就返回自己的auth2AccessTokenResponseOAuth2AccessTokenResponseClient 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`的使用方法。
要使用Resource Server
功能,需要引入spring-boot-starter-oauth2-resource-server
。
项目 | 说明 |
---|---|
hello-security | 一个标准的Resource Server ,通过指定jwk-set-uri 与Spring Authorization Server 做集成。 在测试代码中,可以看到如何集成 mock Authorization Server 进行测试,这部分其实在 jwe 例子中做了详细的说明,这里可以参考一下。 |
jwe | 演示了如何链接一个mock Authorization Server 。使用了JWE-encrypted tokens ,可以作为一个单独启动的服务,去展示你可以加密自己的 OAuth 2.0 Bearer Tokens 。 |
static | 不通过jwk-set-uri 与Spring Authorization Server 做集成。而是将解密的密钥放在本地。 |
multi-tenancy | 多租户的例子 |
opaque | 不透明Tokens |
按照官网的帮助文档是通过脚本进行手工测试,在这个例子中使用了http-client
类似于PostMan
的进行测试。
### 获取Token POST {{AuthServerUri}}/oauth2/token Authorization: Basicmessaging-client secret Content-Type: application/x-www-form-urlencodedgrant_type=client_credentials&scope=message:read > {%client.log(response.body.access_token); client.test("Request executedsuccessfully", function() { client.assert(response.status === 200, "Responsestatus is not 200"); }); client.global.set("access_token",response.body.access_token); %}
第一步:建立一个普通的Application
第二步:进行ResourceServer
配置
配置 application.yml
spring:security:oauth2:resourceserver:jwt:jwk-set-uri: http://localhost:9000/oauth2/jwks
配额访问权限
@EnableWebSecuritypublic class OAuth2ResourceServerSecurityConfiguration {@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")String jwkSetUri;@Beanpublic 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();// }}
第三步:撰写@RestController
@RestControllerpublic 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;
这个包找不到了,估计要替换掉了。
MockMvc
中SecurityMockMvcRequestPostProcessors
类很关键,包含了一些默认的方法,这里有一个文档描述了 RequestPostProcessors 作用。从下图,可以看出来这个类模拟大部分登陆的内容。
这个测试类就是为了使用这个类:
@Testvoid 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
@FunctionalInterfacepublic 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
@Overridepublic 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, "");}}
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() {@Overridepublic 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,将模拟的服务器端口写死,然后将 MockWebServer 放入到 Test 代码中
{"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 name | Description |
---|---|
alg | 与密钥一起使用的特定加密算法。 |
kty | 与密钥一起使用的密码算法系列。 |
use | 如何使用密钥; sig 代表签名。 |
x5c | x.509 证书链. 数组中的第一个条目是用于令牌验证的证书;其他证书可用于验证第一个证书。 |
n | RSA 公钥的模数。 |
e | RSA 公钥的指数。 |
kid | 密钥的唯一标识符。 |
x5t | x.509 证书的指纹(SHA-1 指纹)。 |
JWT=Json Web Token
现在网上大多数介绍JWT
的文章实际介绍的都是JWS(JSON Web Signature)
,也往往导致了人们对于JWT
的误解,但是JWT
并不等于JWS
,JWS
只是JWT
的一种实现,除了JWS
外,JWE(JSON Web Encryption)
也是JWT
的一种实现。
JSON Web Signature 是一个有着简单的统一表达形式的字符串:
头部(Header)
头部用于描述关于该 JWT 的最基本的信息,例如其类型以及签名所用的算法等。 JSON 内容要经 Base64 编码生成字符串成为 Header。
载荷(PayLoad)
payload 的五个字段都是由 JWT 的标准所定义的。
后面的信息可以按需补充。 JSON 内容要经 Base64 编码生成字符串成为 PayLoad。
签名(signature)
这个部分 header 与 payload 通过 header 中声明的加密方式,使用密钥 secret 进行加密,生成签名。
JWS 的主要目的是保证了数据在传输过程中不被修改,验证数据的完整性。但由于仅采用 Base64 对消息内容编码,因此不保证数据的不可泄露性。所以不适合用于传输敏感数据。
相对于 JWS,JWE 则同时保证了安全性与数据完整性。 JWE 由五部分组成:
JWE 组成
具体生成步骤为:
可见,JWE 的计算过程相对繁琐,不够轻量级,因此适合与数据传输而非 token 认证,但该协议也足够安全可靠,用简短字符串描述了传输内容,兼顾数据的安全性与完整性。
这里有两个密钥,分别是官方例子中自带的 simple.pub 与我自己用 OpenSSL 生成的 public.pem。为了让两个密钥都能演示,分别做了两个配置文件
开发的内容很简单,就是开发一个@RestController
@RestControllerpublic 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 的配置文件
@Configurationpublic class StaticResourceServerConfiguration {@Value("${spring.security.oauth2.resourceserver.jwt.key-value}")RSAPublicKey key;@Beanpublic JwtDecoder jwtDecoder(){return NimbusJwtDecoder.withPublicKey(key).build();}@Beanpublic 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;
首先要添加一个 Application,然后添加一个 Controller
接下来配置 application.yml 文件
spring:security:oauth2:resourceserver:opaque:introspection-uri: http://localhost:9000/oauth2/introspectintrospection-client-id: login-clientintrospection-client-secret: openid-connectserver:port: 8903
然后定义配置类
@EnableWebSecuritypublic 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;@Beanpublic 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