Spring Boot Security (三)
Spring Boot Security (三)
之前的随笔(https://www.cnblogs.com/zolmk/p/14074227.html)简单的使用了Spring Boot Security,没有深入。
一、主要内容
这篇主要的应用场景为前后端分离,前端Vue,后端Spring Boot(WebFlux)。
本文主要实现了以下几点:
1.使用JdbcUserDetailsManager
或者InMemoryUserDetailsManager
实现用户认证和用户账户修改
2.实现登入登出控制
3.实现资源权限管理
4.解决跨域(cors)问题
注意区分这两个单词:Authentication
和Authorization
(认证和授权),前者通过账号密码进行认证,后者通过token对请求进行授权。
二、实现步骤
2.1 添加依赖
在pom.xml中添加如下依赖项
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
2.2 Security 配置
这里需要在配置文件中开启 WebFluxSecurity、ReactiveMethodSecurity等、对登录登出的处理、vue跨域问题等。具体配置文件如下:
SecurityConfiguration.java
//your package;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.fy.shopexample.utils.ResponseCode;
import com.fy.shopexample.utils.ResponseUtil;
import com.fy.shopexample.utils.ServerHttpResponseUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Collections;
import java.util.Objects;
@Slf4j
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfiguration {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, AuthenticationRepository repository,
UserDetailServiceImpl userDetailService) {
// 设置用户信息Service和密码更新Service
UserDetailsRepositoryReactiveAuthenticationManager authenticationManager =
new UserDetailsRepositoryReactiveAuthenticationManager(userDetailService);
// 给UserDetailServiceImpl设置ReactiveAuthenticationManager,来响应更新密码
userDetailService.setAuthenticationManager(authenticationManager);
authenticationManager.setUserDetailsPasswordService(userDetailService);
authenticationManager.setPasswordEncoder(new PasswordEncoderImpl());
/* 鉴权成功后的处理器 */
final ServerAuthenticationSuccessHandler successHandler = (webFilterExchange, authentication) -> {
String token = IdUtil.simpleUUID();
repository.put(token ,authentication);
return ServerHttpResponseUtil.print(webFilterExchange.getExchange().getResponse(), ResponseUtil.success(token));
};
/* 鉴权失败后的处理器 */
final ServerAuthenticationFailureHandler failureHandler =
(webFilterExchange, exception) ->
ServerHttpResponseUtil.print(webFilterExchange.getExchange().getResponse(),
ResponseUtil.of(ResponseCode.AUTHENTICATION_FAIL));
/* 登出成功处理器 */
final ServerLogoutSuccessHandler logoutHandler = (exchange, authentication) -> {
String token = exchange.getExchange().getRequest().getHeaders().getFirst("token");
if (StrUtil.isNotBlank(token)) {
repository.delete(token);
}
return ServerHttpResponseUtil.print(exchange.getExchange().getResponse(), ResponseUtil.success());
};
/* 权限拒绝处理器 */
final ServerAccessDeniedHandler deniedHandler = (exchange, denied) ->
ServerHttpResponseUtil.print(exchange.getResponse(),
ResponseUtil.of(ResponseCode.AUTHENTICATION_FAIL));
/* Security上下文仓库
* 它的意义是根据token将当前用户的授权信息提取出来,供当前请求上下文使用。
* */
final ServerSecurityContextRepository securityContextRepository = new ServerSecurityContextRepository() {
@Override
public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
return Mono.empty();
}
@Override
public Mono<SecurityContext> load(ServerWebExchange exchange) {
String token = exchange.getRequest().getHeaders().getFirst("token");
if (StrUtil.isNotBlank(token)) {
Authentication authentication = repository.get(token);
if (Objects.nonNull(authentication)) {
SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
return Mono.just(securityContext);
}
}
return Mono.empty();
}
};
/* 解决跨域问题 */
http.cors(corsSpec -> {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOriginPatterns(Collections.singletonList("*"));
corsConfiguration.addAllowedHeader(CorsConfiguration.ALL);
corsConfiguration.addAllowedMethod(CorsConfiguration.ALL);
corsConfiguration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
corsSpec.configurationSource(source);
})
.csrf()
.disable()
/* 登录和登出状态管理 */
.formLogin(s -> {
s.loginPage("/login");
s.authenticationSuccessHandler(successHandler);
s.authenticationFailureHandler(failureHandler);
})
.logout(s -> {
s.logoutSuccessHandler(logoutHandler);
})
.passwordManagement()
.and()
.authorizeExchange(authorize -> {
authorize.pathMatchers("/signup", "/login", "/logout").permitAll()
.pathMatchers("/user/**").hasAnyRole("USER", "ADMIN", "DBA")
.pathMatchers("/admin/**").hasAnyRole("ADMIN", "DBA")
.pathMatchers("/db/**")
.hasRole("DBA")
.anyExchange().denyAll();
})
.exceptionHandling(s -> {
s.accessDeniedHandler(deniedHandler);
})
.authenticationManager(authenticationManager)
.securityContextRepository(securityContextRepository);
return http.build();
}
}
其中ResponseCode和ResponseUtil是我自己对响应的封装,不重要,ServerHttpResponseUtil内容如下,主要就是将响应对象写入到ServerHttpResponse中。
import cn.hutool.json.JSONUtil;
import com.fy.shopexample.dto.Response;
import org.springframework.http.server.reactive.ServerHttpResponse;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
public class ServerHttpResponseUtil {
public static Mono<Void> print(ServerHttpResponse httpResponse, Response<?,?> response) {
return httpResponse.writeWith(Mono.just(httpResponse.bufferFactory().wrap(JSONUtil.toJsonStr(response).getBytes(StandardCharsets.UTF_8))));
}
}
Security配置部分就这些内容,主要是在ServerHttpSecurity
类上进行操作,配置认证与鉴权的规则和一些处理事件。
2.3 用户认证流程
- vue前端通过axios发起post请求到
/login
接口,认证管理器对账号密码做校验,如果验证正确,则调用ServerAuthenticationSuccessHandler
中的方法,如果失败,调用ServerAuthenticationFailureHandler
中的方法 - 认证成功后,我们可以给用户生成一个token,服务器对token和用户的授权信息进行保存后,将其返回给前端。
- 前端拿到该token后进行本地保存,之后的每次请求都在headers上附带token,服务器根据该token对用户进行授权。
服务器对token和用户授权信息保存的相关类如下:
AuthenticationRepository.java
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Repository;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 用来储存登录令牌
**/
@Repository
public class AuthenticationRepository {
private final static Map<String, Authentication> MAP = new ConcurrentHashMap<>();
private final static Map<String, String> USERNAME_TOKEN = new ConcurrentHashMap<>();
public void put(String key, Authentication authentication) {
MAP.put(key, authentication);
Object user = authentication.getPrincipal();
String username;
if (user instanceof String) {
username = (String) user;
} else if (user instanceof UserDetails) {
username = ((UserDetails) user).getUsername();
} else {
throw new RuntimeException("用户不存在");
}
USERNAME_TOKEN.put(username, key);
}
public Authentication get(String key) {
return MAP.get(key);
}
public String getToken(String username) {
return USERNAME_TOKEN.get(username);
}
public void delete(String key) {
MAP.remove(key);
}
public void deleteByUsername(String username) {
MAP.remove(USERNAME_TOKEN.get(username));
}
}
这里简单省事直接将其保存在了内存中,最好的办法是将其保存在redis等缓存中间件中。类中的方法很简单,不再赘述。
一般而言,后端服务器数据库中不允许保存用户的明文密码,只允许保存加密后的用户密码,因此就需要一个密码编码器。密码验证流程为:1.服务器收到前端传来的密码 2.服务器通过密码编码器对密码进行编码 3.验证数据库中保存的密码和编码后的密码是否匹配 4.处理比对结果。
密码编码器如下:
PasswordEncoderImpl.java
import cn.hutool.core.codec.Base64;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.Objects;
/**
**/
@Slf4j
@Component
public class PasswordEncoderImpl implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return Base64.encode(rawPassword);
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return Objects.equals(encodedPassword, encode(rawPassword));
}
@Override
public boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
UserDetailsService类,该类实现了ReactiveUserDetailsService
和ReactiveUserDetailsPasswordService
接口,提供密码修改和使用用户名加载用户的功能。内容如下:
UserDetailServiceImpl.java
import cn.hutool.core.codec.Base64;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
import static cn.hutool.core.codec.Base64.encode;
@Service
@Slf4j
public class UserDetailServiceImpl implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {
// 这里暂时使用了该类来管理
private final Map<String, UserDetails> map;
private final PasswordEncoder passwordEncoder;
private ReactiveAuthenticationManager authenticationManager;
private AuthenticationRepository authenticationRepository;
public UserDetailServiceImpl(PasswordEncoder passwordEncoder, AuthenticationRepository authenticationRepository) {
this.map = new HashMap<>();
this.passwordEncoder = passwordEncoder;
this.authenticationRepository = authenticationRepository;
// 先初始化一个用户
// 这里由于前端传入的就是经过Base64编码后的密码,然后在PasswordEncoder又进行来编码,所以这里要进行两次编码
UserDetails userDetails = User.withUsername("admin").password(encode(encode("1234"))).roles("DBA").build();
this.map.put(userDetails.getUsername(), userDetails);
}
@Override
public Mono<UserDetails> updatePassword(UserDetails user, String newPassword) {
String encodePassword = passwordEncoder.encode(newPassword);
UserDetails userDetails = User.withUserDetails(user).password(encodePassword).build();
this.map.put(user.getUsername(), userDetails);
Mono<UserDetails> res = Mono.just(userDetails);
if (this.authenticationManager != null) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user.getUsername(), newPassword);
Mono<Authentication> mono = this.authenticationManager.authenticate(token);
res = mono.map(authentication -> (UserDetails)authentication.getPrincipal());
}
// 最后修改鉴权存储器
return res.doOnSuccess(u -> {
this.authenticationRepository.deleteByUsername(u.getUsername());
});
}
@Override
public Mono<UserDetails> findByUsername(String username) {
return Mono.just(map.get(username));
}
public void setAuthenticationManager(ReactiveAuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
}
2.4 用户授权
用户授权过程如下:
1.前端发送请求,并在headers中附带token,通过在SecurityConfiguration配置文件中设置ServerSecurityContextRepository
2.当请求来临时,通过抽取headers中的token,然后根据token从AuthenticationRepository
中获取相应的Authentication
3.然后生成上下文SecurityContextImpl securityContext = new SecurityContextImpl(authentication)
,从而对当前请求进行授权。
2.5 修改密码
简单起见,我直接卸载了Controller层,具体代码如下:
// 这个在UserDetailsService中有实现
@Autowired
private ReactiveUserDetailsPasswordService passwordService;
@Autowired
private PasswordEncoder passwordEncoder;
@PostMapping(path = "/change-password")
public Mono<Response<?,?>> changePassword(@RequestBody Map<String,String> map, Authentication authentication) {
String newPassword = map.get("newPassword");
if (StrUtil.isBlank(newPassword)) {
return Mono.just(ResponseUtil.fail());
}
// 获取用户信息
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
//将用户权限设置到当前的上下文中(不知道为什么直接通过SecurityContextHolder.getContext().getAuthentication()为空)
SecurityContextHolder.getContext().setAuthentication(authentication);
return passwordService.updatePassword(userDetails, newPassword).map(u -> {
if (StrUtil.equals(passwordEncoder.encode(newPassword), u.getPassword())) {
return ResponseUtil.success();
}
return ResponseUtil.fail();
});
}
2.6 方法级权限控制
这部分主要用注解来进行控制,示例代码如下:
import com.fy.shopexample.dto.Response;
import org.springframework.security.access.prepost.PreAuthorize;
import reactor.core.publisher.Mono;
import java.util.List;
public interface UserService {
@PreAuthorize("hasAnyRole('DBA')")
Mono<Response<?,?>> listUsers(int pageNumber, int pageSize);
}
这里使用了PreAuthorize
注解,它的参数在IDEA中有自动完成提示,大概有以下几种:
- hasAnyRole(roleList):该方法需要具有相应的角色才能调用(只需满足其中一个)
- hasRole(role):同上
- hasPermission():当前用户需要具有对应权限
- hasAuthority(authorityList):当前用户需要获得对应授权(只需满足其中一个)
- hasAnyAuthority(authority):同上
三、问题汇总
3.1 SecurityContextHolder.getContext().getAuthentication()获取不到当前用户
在Controller层,可以通过下面的方式获得:
@PostMapping(path = "/change-password")
public Mono<Response<?,?>> changePassword(@RequestBody Map<String,String> map,
Authentication authentication) {
}
3.2 使用数据库实现UserDetails的最快方式
Spring Boot Security提供了JdbcUserDetailsManager
类,该类实现了UserDetailsManager
和UserDetailsService
接口,可以很方便的存取和修改用户。相应的表结构,懒得去找,如果要用,在JdbcUserDetailsManager
类中能得到。