使用 Spring Boot 進行 OAuth2 和 JWT REST 保護
已發表: 2022-03-11本文是有關如何設置 JSON Web 令牌 (JWT) 的服務器端實現的指南 - 使用 Spring Boot 和 Maven 的 OAuth2 授權框架。
建議初步掌握 OAuth2,可以通過閱讀上面鏈接的草稿或在網絡上搜索此類或此類有用信息來獲得。
OAuth2 是一個授權框架,取代了 2006 年創建的第一個版本 OAuth。它定義了客戶端和一個或多個 HTTP 服務之間的授權流程,以便訪問受保護的資源。
OAuth2 定義了以下服務器端角色:
- 資源所有者:負責控制資源訪問的服務
- 資源服務器:實際提供資源的服務
- 授權服務器:服務處理授權過程,充當客戶端和資源所有者之間的中間人
JSON Web Token,或 JWT,是用於表示在兩方之間傳輸的聲明的規範。 聲明被編碼為 JSON 對象,用作加密結構的有效負載,從而使聲明能夠進行數字簽名或加密。
包含結構可以是 JSON Web Signature (JWS) 或 JSON Web Encryption (JWE)。
可以選擇 JWT 作為在 OAuth2 協議中使用的訪問和刷新令牌的格式。
由於以下特性,OAuth2 和 JWT 在過去幾年中大受歡迎:
- 為無狀態 REST 協議提供無狀態授權系統
- 非常適合多個資源服務器可以共享一個授權服務器的微服務架構
- 由於 JSON 格式,令牌內容易於在客戶端管理
但是,如果以下注意事項對項目很重要,OAuth2 和 JWT 並不總是最佳選擇:
- 無狀態協議不允許服務器端的訪問撤銷
- 令牌的固定生命週期為管理長時間運行的會話增加了額外的複雜性,而不影響安全性(例如刷新令牌)
- 對客戶端令牌安全存儲的要求
預期的協議流
雖然 OAuth2 的主要功能之一是引入了授權層,以便將授權過程與資源所有者分開,但為了簡單起見,本文的結果是構建一個模擬所有資源所有者、授權服務器和資源服務器角色。 因此,通信將僅在服務器和客戶端這兩個實體之間流動。
這種簡化應該有助於關注本文的目標,即在 Spring Boot 環境中設置這樣的系統。
簡化流程描述如下:
- 使用密碼授權授予從客戶端向服務器(充當資源所有者)發送授權請求
- 訪問令牌返回給客戶端(連同刷新令牌)
- 然後在每個請求受保護的資源訪問時,訪問令牌從客戶端發送到服務器(充當資源服務器)
- 服務器響應所需的受保護資源
Spring Security 和 Spring Boot
首先簡單介紹一下本項目選用的技術棧。
選擇的項目管理工具是 Maven,但由於項目的簡單性,切換到 Gradle 等其他工具應該不難。
在本文的後續部分,我們僅關注 Spring Security 方面,但所有代碼摘錄均取自一個完全正常工作的服務器端應用程序,該應用程序的源代碼可在公共存儲庫中獲得,而客戶端則使用其 REST 資源。
Spring Security 是一個為基於 Spring 的應用程序提供幾乎聲明式安全服務的框架。 它的根源是從 Spring 的第一個開始,由於涵蓋了大量不同的安全技術,它被組織成一組模塊。
讓我們快速瀏覽一下 Spring Security 架構(更詳細的指南可以在這裡找到)。
安全性主要是關於身份驗證,即身份驗證和授權,即授予對資源的訪問權限。
Spring security 支持大量的身份驗證模型,無論是由第三方提供還是本地實現。 可以在此處找到列表。
關於授權,確定了三個主要領域:
- Web 請求授權
- 方法級授權
- 訪問域對象實例授權
驗證
基本接口是AuthenticationManager
,它負責提供一種認證方法。 UserDetailsService
是與用戶信息收集相關的接口,在標準的 JDBC 或 LDAP 方法的情況下,可以直接在內部實現或使用。
授權
主界面是AccessDecisionManager
; 上面列出的所有三個領域的實現都委託給AccessDecisionVoter
鏈。 後一個接口的每個實例表示Authentication
(用戶身份,命名主體)、資源和ConfigAttribute
集合之間的關聯,該集合描述資源所有者如何允許訪問資源本身,可能通過用戶角色的使用。
Web 應用程序的安全性是使用上面描述的 servlet 過濾器鏈中的基本元素來實現的,並且WebSecurityConfigurerAdapter
類公開為表達資源訪問規則的聲明性方式。
方法安全性首先通過存在@EnableGlobalMethodSecurity(securedEnabled = true)
註釋來啟用,然後通過使用一組專門的註釋來應用於每個要保護的方法,例如@Secured
、 @PreAuthorize
和@PostAuthorize
。
Spring Boot 為所有這些添加了自以為是的應用程序配置和第三方庫的集合,以便在保持高質量標準的同時簡化開發。
帶有 Spring Boot 的 JWT OAuth2
現在讓我們繼續解決最初的問題,使用 Spring Boot 設置一個實現 OAuth2 和 JWT 的應用程序。
雖然 Java 世界中存在多個服務器端 OAuth2 庫(可以在此處找到列表),但基於 spring 的實現是自然的選擇,因為我們希望發現它很好地集成到 Spring Security 架構中,因此無需處理太多其使用的低級細節。
所有與安全相關的庫依賴項都由 Maven 在 Spring Boot 的幫助下處理,這是唯一需要在 Maven 的配置文件pom.xml中顯式版本的組件(即庫版本由 Maven 自動推斷,選擇最新的與插入的 Spring Boot 版本兼容的版本)。
在下面找到 maven 配置文件pom.xml的摘錄,其中包含與 Spring Boot 安全性相關的依賴項:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <version>2.1.0.RELEASE</version> </dependency>
該應用程序既充當 OAuth2 授權服務器/資源所有者,又充當資源服務器。
受保護的資源(作為資源服務器)發佈在/api/路徑下,而身份驗證路徑(作為資源所有者/授權服務器)映射到/oauth/token ,遵循建議的默認值。
應用程序結構:
- 包含安全配置的
security
包 - 包含錯誤處理的
errors
包 users
, REST 資源的glee
包,包括模型、存儲庫和控制器
接下來的段落將介紹上述三個 OAuth2 角色中的每一個角色的配置。 相關的類在security
包內:
-
OAuthConfiguration
,擴展AuthorizationServerConfigurerAdapter
-
ResourceServerConfiguration
,擴展ResourceServerConfigurerAdapter
-
ServerSecurityConfig
,擴展WebSecurityConfigurerAdapter
-
UserService
,實現UserDetailsService
資源所有者和授權服務器的設置
授權服務器行為由@EnableAuthorizationServer
註釋的存在啟用。 它的配置與與資源所有者行為相關的配置合併,兩者都包含在類AuthorizationServerConfigurerAdapter
中。
此處應用的配置與:
- 客戶端訪問(使用
ClientDetailsServiceConfigurer
)- 通過
inMemory
或jdbc
方法選擇使用內存或基於 JDBC 的存儲來存儲客戶端詳細信息 - 使用
clientId
和clientSecret
(使用所選PasswordEncoder
bean 編碼)屬性的客戶端基本身份驗證 - 使用
accessTokenValiditySeconds
和refreshTokenValiditySeconds
屬性訪問和刷新令牌的有效時間 - 使用
authorizedGrantTypes
屬性允許的授予類型 - 使用
scopes
方法定義訪問範圍 - 識別客戶的可訪問資源
- 通過
- 授權服務器端點(使用
AuthorizationServerEndpointsConfigurer
)- 使用
accessTokenConverter
定義 JWT 令牌的使用 - 定義使用
UserDetailsService
和AuthenticationManager
接口來執行身份驗證(作為資源所有者)
- 使用
package net.reliqs.gleeometer.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; @Configuration @EnableAuthorizationServer public class OAuthConfiguration extends AuthorizationServerConfigurerAdapter { private final AuthenticationManager authenticationManager; private final PasswordEncoder passwordEncoder; private final UserDetailsService userService; @Value("${jwt.clientId:glee-o-meter}") private String clientId; @Value("${jwt.client-secret:secret}") private String clientSecret; @Value("${jwt.signing-key:123}") private String jwtSigningKey; @Value("${jwt.accessTokenValidititySeconds:43200}") // 12 hours private int accessTokenValiditySeconds; @Value("${jwt.authorizedGrantTypes:password,authorization_code,refresh_token}") private String[] authorizedGrantTypes; @Value("${jwt.refreshTokenValiditySeconds:2592000}") // 30 days private int refreshTokenValiditySeconds; public OAuthConfiguration(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, UserDetailsService userService) { this.authenticationManager = authenticationManager; this.passwordEncoder = passwordEncoder; this.userService = userService; } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(clientId) .secret(passwordEncoder.encode(clientSecret)) .accessTokenValiditySeconds(accessTokenValiditySeconds) .refreshTokenValiditySeconds(refreshTokenValiditySeconds) .authorizedGrantTypes(authorizedGrantTypes) .scopes("read", "write") .resourceIds("api"); } @Override public void configure(final AuthorizationServerEndpointsConfigurer endpoints) { endpoints .accessTokenConverter(accessTokenConverter()) .userDetailsService(userService) .authenticationManager(authenticationManager); } @Bean JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); return converter; } }
下一節將描述應用於資源服務器的配置。
資源服務器的設置
資源服務器行為是通過使用@EnableResourceServer
註釋啟用的,其配置包含在類ResourceServerConfiguration
中。
這裡唯一需要的配置是資源標識的定義,以便匹配上一個類中定義的客戶端訪問。
package net.reliqs.gleeometer.security; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; @Configuration @EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId("api"); } }
最後一個配置元素是關於 Web 應用程序安全性的定義。
網絡安全設置
Spring web 安全配置包含在類ServerSecurityConfig
中,通過使用@EnableWebSecurity
註解啟用。 @EnableGlobalMethodSecurity
允許在方法級別上指定安全性。 設置它的屬性proxyTargetClass
是為了讓它適用於RestController
的方法,因為控制器通常是類,不實現任何接口。
它定義了以下內容:
- 要使用的身份驗證提供程序,定義 bean
authenticationProvider
- 要使用的密碼編碼器,定義 bean
passwordEncoder
- 身份驗證管理器 bean
- 使用
HttpSecurity
的已發布路徑的安全配置 - 使用自定義
AuthenticationEntryPoint
來處理標準 Spring REST 錯誤處理程序ResponseEntityExceptionHandler
之外的錯誤消息
package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.errors.CustomAccessDeniedHandler; import net.reliqs.gleeometer.errors.CustomAuthenticationEntryPoint; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) public class ServerSecurityConfig extends WebSecurityConfigurerAdapter { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final UserDetailsService userDetailsService; public ServerSecurityConfig(CustomAuthenticationEntryPoint customAuthenticationEntryPoint, @Qualifier("userService") UserDetailsService userDetailsService) { this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; this.userDetailsService = userDetailsService; } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setPasswordEncoder(passwordEncoder()); provider.setUserDetailsService(userDetailsService); return provider; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/signin/**").permitAll() .antMatchers("/api/glee/**").hasAnyAuthority("ADMIN", "USER") .antMatchers("/api/users/**").hasAuthority("ADMIN") .antMatchers("/api/**").authenticated() .anyRequest().authenticated() .and().exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(new CustomAccessDeniedHandler()); } }
下面的代碼摘錄是關於UserDetailsService
接口的實現,以提供資源所有者的身份驗證。

package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.users.User; import net.reliqs.gleeometer.users.UserRepository; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class UserService implements UserDetailsService { private final UserRepository repository; public UserService(UserRepository repository) { this.repository = repository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = repository.findByEmail(username).orElseThrow(() -> new RuntimeException("User not found: " + username)); GrantedAuthority authority = new SimpleGrantedAuthority(user.getRole().name()); return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Arrays.asList(authority)); } }
下一節是關於 REST 控制器實現的描述,以便了解如何映射安全約束。
休息控制器
在 REST 控制器中,我們可以找到兩種方法來為每個資源方法應用訪問控制:
- 使用 Spring 傳入的
OAuth2Authentication
實例作為參數 - 使用
@PreAuthorize
或@PostAuthorize
註釋
package net.reliqs.gleeometer.users; import lombok.extern.slf4j.Slf4j; import net.reliqs.gleeometer.errors.EntityNotFoundException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.validation.ConstraintViolationException; import javax.validation.Valid; import javax.validation.constraints.Size; import java.util.HashSet; @RestController @RequestMapping("/api/users") @Slf4j @Validated class UserController { private final UserRepository repository; private final PasswordEncoder passwordEncoder; UserController(UserRepository repository, PasswordEncoder passwordEncoder) { this.repository = repository; this.passwordEncoder = passwordEncoder; } @GetMapping Page<User> all(@PageableDefault(size = Integer.MAX_VALUE) Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmail(auth, pageable); } return repository.findAll(pageable); } @GetMapping("/search") Page<User> search(@RequestParam String email, Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmailContainsAndEmail(email, auth, pageable); } return repository.findByEmailContains(email, pageable); } @GetMapping("/findByEmail") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == #email)") User findByEmail(@RequestParam String email, OAuth2Authentication authentication) { return repository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException(User.class, "email", email)); } @GetMapping("/{id}") @PostAuthorize("!hasAuthority('USER') || (returnObject != null && returnObject.email == authentication.principal)") User one(@PathVariable Long id) { return repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); } @PutMapping("/{id}") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void update(@PathVariable Long id, @Valid @RequestBody User res) { User u = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); res.setPassword(u.getPassword()); res.setGlee(u.getGlee()); repository.save(res); } @PostMapping @PreAuthorize("!hasAuthority('USER')") User create(@Valid @RequestBody User res) { return repository.save(res); } @DeleteMapping("/{id}") @PreAuthorize("!hasAuthority('USER')") void delete(@PathVariable Long id) { if (repository.existsById(id)) { repository.deleteById(id); } else { throw new EntityNotFoundException(User.class, "id", id.toString()); } } @PutMapping("/{id}/changePassword") @PreAuthorize("!hasAuthority('USER') || (#oldPassword != null && !#oldPassword.isEmpty() && authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void changePassword(@PathVariable Long id, @RequestParam(required = false) String oldPassword, @Valid @Size(min = 3) @RequestParam String newPassword) { User user = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); if (oldPassword == null || oldPassword.isEmpty() || passwordEncoder.matches(oldPassword, user.getPassword())) { user.setPassword(passwordEncoder.encode(newPassword)); repository.save(user); } else { throw new ConstraintViolationException("old password doesn't match", new HashSet<>()); } } }
結論
Spring Security 和 Spring Boot 允許以幾乎聲明式的方式快速設置完整的 OAuth2 授權/身份驗證服務器。 可以通過直接從application.properties/yml
文件配置 OAuth2 客戶端的屬性來進一步縮短設置,如本教程中所述。
所有源代碼都可以在這個 GitHub 存儲庫中找到:spring-glee-o-meter。 可以在此 GitHub 存儲庫中找到使用已發布資源的 Angular 客戶端:glee-o-meter。