使用Spring Boot进行OAuth2和JWT REST保护

本文概述

  • 预期协议流
  • Spring Security和Spring Boot
  • 带有Spring Boot的JWT OAuth2
  • 总结
本文是有关如何使用Spring Boot和Maven设置服务器端实现JSON Web令牌(JWT)-OAuth2授权框架的指南。
建议你初步了解OAuth2, 并可以阅读上面链接的草稿或在网络上搜索此类内容, 以获取有用的信息。
OAuth2是一个授权框架, 它取代了它的第一版OAuth(创建于2006年)。它定义了客户端与一个或多个HTTP服务之间的授权流, 以便获得对受保护资源的访问。
OAuth2定义了以下服务器端角色:
  • 资源所有者:负责控制资源访问的服务
  • 资源服务器:实际提供资源的服务
  • 授权服务器:服务处理授权过程, 充当客户端和资源所有者之间的中间人
JSON Web令牌或JWT是表示要在两方之间转移的声明的规范。声明被编码为JSON对象, 用作加密结构的有效负载, 从而使声明可以进行数字签名或加密。
包含的结构可以是JSON Web签名(JWS)或JSON Web加密(JWE)。
可以选择JWT作为OAuth2协议内部使用的访问和刷新令牌的格式。
【使用Spring Boot进行OAuth2和JWT REST保护】由于以下功能, OAuth2和JWT在过去几年中获得了极大的普及:
  • 为无状态REST协议提供无状态授权系统
  • 非常适合微服务架构, 其中多个资源服务器可以共享一个授权服务器
  • 由于JSON格式, 令牌内容易于在客户端进行管理
但是, 如果以下注意事项对于项目很重要, 则OAuth2和JWT并不总是最佳选择:
  • 无状态协议不允许服务器端进行访问撤销
  • 令牌的固定生命周期为管理长时间运行的会话增加了额外的复杂性, 而又不损害安全性(例如刷新令牌)
  • 客户端上令牌的安全存储要求
预期协议流 尽管OAuth2的主要功能之一是引入了一个授权层, 以便将授权过程与资源所有者分开, 但为简单起见, 本文的结果是构建了一个模拟所有资源所有者, 授权服务器和应用程序的应用程序。资源服务器角色。因此, 通信将仅在两个实体(服务器和客户端)之间流动。
这种简化应有助于将重点放在本文的目的上, 即在Spring Boot的环境中设置这样的系统。
简化的流程如下所述:
  1. 使用密码授权授予将授权请求从客户端发送到服务器(充当资源所有者)
  2. 访问令牌返回给客户端(以及刷新令牌)
  3. 然后, 在每个受保护的资源访问请求中, 访问令牌都从客户端发送到服务器(充当资源服务器)
  4. 服务器响应所需的受保护资源
使用Spring Boot进行OAuth2和JWT REST保护

文章图片
Spring Security和Spring Boot 首先, 简要介绍为此项目选择的技术堆栈。
首选的项目管理工具是Maven, 但是由于项目的简单性, 因此切换到Gradle等其他工具应该不难。
在本文的续篇中, 我们仅关注Spring Security方面, 但是所有代码摘录均取材于功能完备的服务器端应用程序, 该源代码可在公共存储库中使用, 而客户端则使用其REST资源。
Spring Security是一个框架, 可为基于Spring的应用程序提供几乎声明性的安全服务。它的根源于Spring的第一期, 由于涵盖了许多不同的安全技术, 因此按一组模块进行组织。
让我们快速看一下Spring Security架构(可以在这里找到更详细的指南)。
安全性主要是关于身份验证, 即身份验证和授权, 对资源的访问权限的授予。
Spring Security支持由第三方提供或本机实现的多种身份验证模型。可以在这里找到列表。
关于授权, 确定了三个主要领域:
  1. Web请求授权
  2. 方法级别授权
  3. 访问域对象实例授权
认证方式
基本接口是AuthenticationManager, 负责提供身份验证方法。 UserDetailsS??ervice是与用户信息收集相关的接口, 对于标准JDBC或LDAP方法, 可以直接实现该接口或在内部使用该信息。
授权书
主界面是AccessDecisionManager;上面列出的所有三个区域的实现都委托给AccessDecisionVoter链。后一个接口的每个实例都表示身份验证(用户身份, 命名为主体), 资源和ConfigAttribute集合之间的关联, 该规则集描述了资源所有者如何允许访问资源本身, 可能是通过使用用户角色。
使用Servlet过滤器链中的上述基本元素来实现Web应用程序的安全性, 并且将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。
应用程序的结构:
  • 包含安全配置的安全软件包
  • 包含错误处理的错误包
  • 用户, 用于REST资源(包括模型, 存储库和控制器)的lee软件包
接下来的段落介绍了上述三个OAuth2角色中每个角色的配置。相关类在安全包中:
  • OAuthConfiguration, 扩展AuthorizationServerConfigurerAdapter
  • ResourceServerConfiguration, 扩展了ResourceServerConfigurerAdapter
  • ServerSecurityConfig, 扩展了WebSecurityConfigurerAdapter
  • UserService, 实现UserDetailsS??ervice
资源所有者和授权服务器的设置
通过@EnableAuthorizationServer批注启用授权服务器行为。它的配置与与资源所有者行为有关的配置合并在一起, 并且两者都包含在类AuthorizationServerConfigurerAdapter中。
此处应用的配置与以下内容有关:
  • 客户端访问(使用ClientDetailsS??erviceConfigurer)
    • 使用inMemory或jdbc方法选择使用基于内存的存储或基于JDBC的客户端详细信息
    • 使用clientId和clientSecret(使用所选的PasswordEncoder bean编码)属性进行客户端的基本身份验证
    • 使用accessTokenValiditySeconds和refreshTokenValiditySeconds属性访问和刷新令牌的有效时间
    • 使用authorizedGrantTypes属性允许的授予类型
    • 使用scopes方法定义访问范围
    • 识别客户可访问的资源
  • 授权服务器端点(使用AuthorizationServerEndpointsConfigurer)
    • 使用accessTokenConverter定义JWT令牌的使用
    • 定义使用UserDetailsS??ervice和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()); }}

下面的代码摘录是关于UserDetailsS??ervice接口的实现, 以便提供资源所有者的身份验证。
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控制器
在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。

    推荐阅读