使用 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。