本文概述
本文是有关如何使用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协议内部使用的访问和刷新令牌的格式。
由于以下功能, 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链。后一个接口的每个实例都表示身份验证(用户身份, 命名为主体), 资源和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, 实现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控制器
在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。
评论前必须登录!
注册