UserDetailsService
( SpringSecurity (2) UserDetailsService) 从数据库中获取用户信息, 以 Json Web Token 作为 access-token ( SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证), 并相应 SpringSecurity 的 CSRF 策略 ( SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理)
实际生产环境中, 一个用户往往存在多种角色, 而一个角色对应着一个权限集. 之前的做法是把用户角色写死在 SecurityConfiguration
中, 这样每次权限变化都需要重启服务, 我们肯定期望, SpringSecurity 权限控制策略能够响应权限的变化.
完整代码
项目结构
│ demo-spring-security-dynamic-authorization.iml
│ pom.xml
│ README.md
│
├─doc
│ └─diagram
│ demo-spring-security-dynamic-authorization-sequence.png
│ demo-spring-security-dynamic-authorization-sequence.vsdx
│
└─src
├─main
│ ├─java
│ │ └─cn
│ │ └─caplike
│ │ └─demo
│ │ └─spring
│ │ └─security
│ │ └─dynamic
│ │ └─authorization
│ │ │ DynamicAuthorizationApplication.java
│ │ │
│ │ ├─configuration
│ │ │ └─security
│ │ │ │ ApplicationConfiguration.java
│ │ │ │ CsrfTokenRedisRepository.java
│ │ │ │ CustomAccessDeniedHandler.java
│ │ │ │ CustomAuthenticationEntryPoint.java
│ │ │ │ CustomAuthenticationProvider.java
│ │ │ │ SecurityConfiguration.java
│ │ │ │
│ │ │ ├─exception
│ │ │ │ UserInfoIncompleteException.java
│ │ │ │
│ │ │ ├─filter
│ │ │ │ HttpServletRequestWrapFilter.java
│ │ │ │ JWTAuthenticationFilter.java
│ │ │ │ JWTAuthorizationFilter.java
│ │ │ │
│ │ │ └─popedom
│ │ │ DynamicAccessDecisionManager.java
│ │ │ DynamicFilterInvocationSecurityMetadataSource.java
│ │ │ FilterSecurityInterceptorPostProcessor.java
│ │ │
│ │ ├─controller
│ │ │ AdminController.java
│ │ │ AuthController.java
│ │ │ CustomErrorController.java
│ │ │ UserController.java
│ │ │
│ │ ├─domain
│ │ │ ├─builder
│ │ │ │ SecurityResponseDtoBuilder.java
│ │ │ │
│ │ │ ├─dto
│ │ │ │ CustomUserDetailsDto.java
│ │ │ │ RolePopedomDto.java
│ │ │ │ SecurityResponseDto.java
│ │ │ │ UserRoleDto.java
│ │ │ │
│ │ │ └─entity
│ │ │ Popedom.java
│ │ │ Role.java
│ │ │ User.java
│ │ │
│ │ ├─mapper
│ │ │ RoleMapper.java
│ │ │ UserMapper.java
│ │ │
│ │ ├─service
│ │ │ CustomUserDetailsService.java
│ │ │
│ │ └─util
│ │ AccessTokenUtils.java
│ │ BeanUtils.java
│ │ CsrfTokenUtils.java
│ │ RequestUtils.java
│ │ ResponseUtils.java
│ │ TimeUtils.java
│ │
│ └─resources
│ application.yml
│
└─test
└─java
├─cn
│ └─caplike
│ └─demo
│ └─spring
│ └─security
│ └─dynamic
│ └─authorization
│ ├─domain
│ │ └─builder
│ │ SecurityResponseDtoBuilderTest.java
│ │
│ └─mapper
│ RoleMapperTest.java
│ UserMapperTest.java
│
├─com
│ └─alibaba
│ └─fastjson
│ JSONTest.java
│
└─org
└─springframework
└─util
AntPathMatcherTest.java
在官方文档"访问控制 (Access-Control (Authorization) in Spring Security)"一章中看到一个名为 AbstractSecurityInterceptor
的抽象类, 这个类提供了用于处理 secure object (如 JoinPoint
, FilterInvocation
和 MethodInvocation
) 请求的工作流程: AbstractSecurityInterceptor
做的核心工作就是从当前请求中获取 ConfigAttribute
(由 SecurityMetadataSource
提供, 被用于保存相关的配置属性, 譬如我们的权限信息), 并将其随着 Authentication
, secure object 提交给 AccessDecisionManager
执行权限判断.
(每个 secure object
类型都有"专属"的继承于 AbstractSecurityInterceptor
的拦截器 (如 FilterInvocation
的 FilterSecurityInterceptor
))
所以要做的就是确定 security object, 写一个用于提供 ConfigAttribute
的 SecurityMetadataSource
以及用于执行访问控制判断的 AccessDecisionManager
.
并且可以确定, 我们要使用的 security object 就是 FilterInvocation
, 因为它持有与 HTTP 过滤器相关的对象.
查看 SecurityMetadataSource
的层级结构发现, 它有一个 标记接口1: FilterInvocationSecurityMetadataSource
, 所以自然地, 我们自己的 SecurityMetadataSource
应当实现于这个标记接口.
分析了如何实现, 以及官方提供的相关类. 在代码实现之前, 先来看看整体时序图:
有关 Csrf 部分和 JWT 部分请参看前面两篇文章 (SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理 & SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证).
本文主要着重的是动态权限的部分.
设定一个用户可以拥有多个角色, 而没一个角色代持有多个资源的访问权限, 它们是一对多对多的关系. 所以, 我们至少需要 5 张表来支撑整个权限部分, 它们分别是: 用户表, 角色表以及用户角色中间表, 还有权限表以及角色权限中间表.
❉ 用户表
❉ 角色表
❉ 权限表
权限决定了一个角色对于特定 URI 是否有访问的权限, 支持通配符.
❉ 用户 - 角色中间表 & 角色 - 权限中间表
代码层级, 提供用于查询"用户-角色对应关系"的方法以及"角色-权限对应关系"的两个方法:
public class UserRoleDto {
// ~ USER Fields
// -----------------------------------------------------------------------------------------------------------------
private String name;
private String password;
// ~ ROLE_USER Fields
private Set<String> roles;
}
@Mapper
@Repository
public interface UserMapper {
/**
* Description: 获取 用户-角色s 对象
*
* @param username 用户名
* @return cn.caplike.demo.spring.security.dynamic.authorization.domain.dto.UserRoleDto
* @author LiKe
* @date 2020-05-12 13:51:58
*/
@Select("SELECT * FROM USER where name = #{username}")
@Results({
@Result(property = "name", column = "name"),
@Result(property = "password", column = "password"),
@Result(property = "roles", column = "id", javaType = Set.class,
many = @Many(
select = "cn.caplike.demo.spring.security.dynamic.authorization.mapper.RoleMapper.queryRoleName",
fetchType = FetchType.EAGER
)
)
})
UserRoleDto getUserRoleDto(String username);
}
@Data
@NoArgsConstructor
public class RolePopedomDto {
private String roleName;
/**
* POPEDOM request_url
*/
private String requestUrl;
}
@Mapper
@Repository
public interface RoleMapper {
@Select("SELECT " +
" r.name " +
"FROM " +
" ROLE r " +
"WHERE " +
" r.id IN ( SELECT ru.role_id FROM ROLE_USER ru WHERE ru.user_id = #{userId} )")
Set<String> queryRoleName(int userId);
/**
* Description: 获取角色名称和权限 URL 的对应关系
*
* @return java.util.List
* @author LiKe
* @date 2020-05-07 14:13:36
*/
@Select("SELECT " +
" r.name as role_name, " +
" rpp.request_url " +
"FROM " +
" ROLE r" +
" LEFT JOIN ( SELECT rp.role_id, p.request_url FROM ROLE_POPEDOM rp LEFT JOIN POPEDOM p ON rp.popedom_id = p.id ) rpp ON r.id = rpp.role_id")
List<RolePopedomDto> queryRolePopedomDto();
}
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// ~ Constant
// -----------------------------------------------------------------------------------------------------------------
public static final String ACCESS_TOKEN = "access-token";
public static final String CSRF_TOKEN = "csrf-token";
public static final String LOGIN_URI = "/auth/login";
public static final String REGISTER_URI = "/auth/register";
// ~ Authentication configure
// -----------------------------------------------------------------------------------------------------------------
/**
* AuthenticationProvider
*/
private AuthenticationProvider authenticationProvider;
/**
* AccessDecisionManager
*/
private AccessDecisionManager accessDecisionManager;
// ~ HttpSecurity configure
// -----------------------------------------------------------------------------------------------------------------
/**
* SecurityMetadataSource
*/
private FilterInvocationSecurityMetadataSource securityMetadataSource;
/**
* {@link CsrfTokenRedisRepository}
*/
private CsrfTokenRepository csrfTokenRepository;
/**
* {@link RedisService}
*/
private RedisService redisService;
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(authenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ~ CSRF
// -----------------------------------------------------------------------------------------------------
.csrf().ignoringAntMatchers(LOGIN_URI, REGISTER_URI).csrfTokenRepository(csrfTokenRepository)
.and()
.addFilterBefore(new HttpServletRequestWrapFilter(), CsrfFilter.class)
.authorizeRequests()
// ~ 基础权限设定
// -----------------------------------------------------------------------------------------------------
.antMatchers("/auth/**").permitAll()
// ~ 动态权限设定
// -----------------------------------------------------------------------------------------------------
.anyRequest().authenticated()
.withObjectPostProcessor(new FilterSecurityInterceptorPostProcessor(accessDecisionManager, securityMetadataSource))
// ~ 禁用 Session: Spring Security will never create an HttpSession and it will never use it to obtain the SecurityContext
// -----------------------------------------------------------------------------------------------------
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// ~ 添加 JWTAuthenticationFilter 和 JWTAuthorizationFilter
// -----------------------------------------------------------------------------------------------------
.and()
.addFilterAt(new JWTAuthenticationFilter(authenticationManager(), redisService), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(new JWTAuthorizationFilter(redisService), JWTAuthenticationFilter.class)
// ~ 异常处理: 处理 AccessDeniedException 和 AuthenticationException
// -----------------------------------------------------------------------------------------------------
.exceptionHandling()
.accessDeniedHandler(new CustomAccessDeniedHandler())
.authenticationEntryPoint(new CustomAuthenticationEntryPoint());
}
// ~ Autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setAccessDecisionManager(@Qualifier("dynamicAccessDecisionManager") AccessDecisionManager accessDecisionManager) {
this.accessDecisionManager = accessDecisionManager;
}
@Autowired
public void setSecurityMetadataSource(@Qualifier("dynamicFilterInvocationSecurityMetadataSource") FilterInvocationSecurityMetadataSource securityMetadataSource) {
this.securityMetadataSource = securityMetadataSource;
}
@Autowired
public void setAuthenticationProvider(@Qualifier("customAuthenticationProvider") AuthenticationProvider authenticationProvider) {
this.authenticationProvider = authenticationProvider;
}
@Autowired
public void setRedisService(RedisService redisService) {
this.redisService = redisService;
}
@Autowired
public void setCsrfTokenRepository(@Qualifier("csrfTokenRedisRepository") CsrfTokenRepository csrfTokenRepository) {
this.csrfTokenRepository = csrfTokenRepository;
}
}
(依然从配置类切入, 包含 csrf 相关配置, 身份认证过滤器以及权限验证过滤器, 请参看之前文章获取相关支持)
主要关注 .withObjectPostProcessor(new FilterSecurityInterceptorPostProcessor(accessDecisionManager, securityMetadataSource))
这行代码. 我们指定了一个 ObjectPostProcessor
, 就是 SpringSecurity 的 Java 配置方式不会暴露出它配置的对象的全部属性, 如果需要配置额外的属性, 就当通过这个 ObjectPostProcessor
来进行. 它能修改通过 Java 配置方式配置的对象实例. 比如, 如果你想为 FilterSecurityInterceptor
配置 filterSecurityPublishAuthorizationSuccess
属性, 你可以这么做:
依据在文章一开始的分析, 我们知道 AbstractSecurityInterceptor
控制了整个权限检验的工作流程, 从核心源码中也可以看到, 它首先会从 SecurityMetadataSource
中获取 ConfigAttribute
的集合, 然后交给 AccessDecisionManager
判断是否放行请求. 简而言之, 我们需要一个 SecurityInterceptor 实现类, 一个 ConfigAttribute
提供源: SecurityMetadataSource
以及一个访问决策管理器: AccessDecisionManager
.
经过之前的分析我们也知道, 对于 WEB 请求, 可以确定, security object
类型为 FilterInvocation
, 拦截器为用于处理 FilterInvocation
的 FilterSecurityInterceptor
.
从官方说明中可以看到它是用于执行 HTTP 资源安全策略的过滤器的实现. 与这个拦截器配对的 SecurityMetadataSource
就是 FilterInvocationSecurityMetadataSource
总结一下, 方式就是用 ObjectPostProcessor
来配置 FilterSecurityInterceptor
(setAccessDecisionManager
& setSecurityMetadataSource
), FilterSecurityInterceptor
决定了 security object
为 FilterInvocation
, FilterSecurityInterceptor
的超类 AbstractSecurityInterceptor
调用 (匹配) 的 SecurityMetadataSource
(也就是 FilterInvocationSecurityMetadataSource
的实现类) 的 getAttributes 封装 ConfigAttribute
, 传给 AccessDecisionManager
进行权限验证.
下面上具体代码:
implements FilterInvocationSecurityMetadataSource
, 用于提供配置属性的类.
如之前所述, 这个类的主要职责就是将权限数据封装成 ConfigAttribute
, 供 AccessDecisionManager
使用.
(生产环境肯定应该将结果放到缓存中. 然后在前端做一个配置页面供高权限的用户更改权限后, 手动重新加载权限数据)
权限的数据结构为:
{
"roleName": "角色名",
"requestUrl": "资源路径"
}
本 Demo 暂时从数据库中获取权限数据.
public boolean supports(Class> clazz)
保证了只有特定的 security object
才会被当前的 SecurityMetadataSource
处理.
正如前面说的, public Collection
会被 AbstractSecurityInterceptor
调用, ConfigAttribute
的结果集传递给 AccessDecisionManager
, 接下来我们再来看看 AccessDecisionManager
.
(程序中还做了一个额外处理: 过滤掉权限数据中不匹配当前 requestUrl 的记录)
/**
* {@link FilterInvocationSecurityMetadataSource} 的实现.
* 为 {@link org.springframework.security.access.AccessDecisionManager} 提供 configAttributes
*
* @author LiKe
* @version 1.0.0
* @date 2020-05-06 14:09
*/
@Slf4j
@Component
public class DynamicFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
private static final String CLASS_NAME = "Dynamic filter invocation security metadata source";
private RoleMapper roleMapper;
private AntPathMatcher antPathMatcher;
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
// 因为 supports 方法只放行了 FilterInvocation 和其父类以及接口, 所以
// 总是 dynamic filter invocation security metadata source, getAttributes: org.springframework.security.web.FilterInvocation
// log.debug("{}#getAttributes: {}", CLASS_NAME, object.getClass().getCanonicalName());
FilterInvocation filterInvocation = (FilterInvocation) object;
// http://localhost:18903/dynamic-authorization/user/1 的 /user/1
final String requestUrl = filterInvocation.getRequestUrl();
log.debug("{}#getAttributes: {}", CLASS_NAME, requestUrl);
// 查询数据库获取角色和权限的对应关系
// TODO 这个对应关系应该放到缓存中. 如果有更改的需要, 可以在前端提供一个操作手动重新加载权限对应关系
final List<RolePopedomDto> rolePopedomDtos = roleMapper.queryRolePopedomDto();
final String[] matchedRoles = rolePopedomDtos.stream()
.filter(rolePopedomDto -> antPathMatcher.match(rolePopedomDto.getRequestUrl(), requestUrl))
.map(RolePopedomDto::getRoleName).collect(Collectors.toSet()).toArray(new String[]{});
// 如果返回 null 或者 空集合, 不会调用 AccessDecisionManager 的 decide 方法,
// ref AbstractSecurityInterceptor 199 行
if (matchedRoles.length > 0) {
return SecurityConfig.createList(matchedRoles);
}
// 如果当前请求的 URL 没有在 角色-权限对应关系中存在, 则表示匿名用户也可访问
log.debug("{}#getAttributes :: anonymous user.", CLASS_NAME);
return SecurityConfig.createList("ROLE_ANONYMOUS");
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
log.debug("{}#getAllConfigAttributes", CLASS_NAME);
// TODO 这个对应关系应该放到缓存中. 如果有更改的需要, 可以在前端提供一个操作手动重新加载权限对应关系
return SecurityConfig.createList(
roleMapper.queryRolePopedomDto().stream()
.map(RolePopedomDto::getRoleName).collect(Collectors.toSet()).toArray(new String[]{})
);
}
@Override
public boolean supports(Class<?> clazz) {
log.debug("{}#supports: {}", CLASS_NAME, clazz.getCanonicalName());
return FilterInvocation.class.isAssignableFrom(clazz);
}
// ~ Autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setRoleMapper(RoleMapper roleMapper) {
this.roleMapper = roleMapper;
}
@Autowired
public void setAntPathMatcher(AntPathMatcher antPathMatcher) {
this.antPathMatcher = antPathMatcher;
}
// ~ Bean
// -----------------------------------------------------------------------------------------------------------------
@Bean
public AntPathMatcher antPathMatcher() {
return new AntPathMatcher();
}
}
implements AccessDecideManager
, 在 “整体时序” 中知道, AbstractSecurityInterceptor#beforeInvocation(Object)
的调用时机实在 JWTAuthorizationFilter 之后, 所以这时的 SecurityContextHolder
肯定持有完整的 Authentication
对象. 从而, 近乎理所应当的, 我们当然可以用 AccessDecisionManager#decide(Authentication, Object, Collection
的参数 Authentication
中的权限数据和 SecurityMetadataSource
提供的权限数据比较.
(由于我们在 SecurityMetadataSource
中放行了类型为 FilterInvocation
的 security object
, 所以 decide 方法第二个参数的真实类型就是 FilterInvocation
)
/**
* {@link AccessDecisionManager} 的实现
*
* @author LiKe
* @version 1.0.0
* @date 2020-05-06 10:27
*/
@Slf4j
@Component
public class DynamicAccessDecisionManager implements AccessDecisionManager {
private static final String CLASS_NAME = "Dynamic access decision manager";
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
// log.debug("{}#authentication: {}", CLASS_NAME, JSON.toJSONString(authentication));
// In this case, object always be FilterInvocation
// log.debug("{}#object: {}", CLASS_NAME, object.getClass().getCanonicalName());
// 根据 requestUrl 过滤后的角色列表
// log.debug("{}#configAttributes: {}", CLASS_NAME, JSON.toJSONString(configAttributes));
final Set<String> requiredRolesByTargetResources = configAttributes.stream().map(ConfigAttribute::getAttribute).collect(Collectors.toSet());
final Collection<? extends GrantedAuthority> incomingRequestAuthorities = authentication.getAuthorities();
for (GrantedAuthority grantedAuthority : incomingRequestAuthorities) {
// 如果当前 Authentication 有 requestUrl 的权限, 则认证通过
// TODO 用 AntPathMatcher 匹配
if (requiredRolesByTargetResources.contains(grantedAuthority.getAuthority())) {
log.debug("{}#decide :: authenticate success.", CLASS_NAME);
return;
}
}
// TODO 需要查阅源码
// 如果不执行 SecurityContextHolder.clearContext() 即使抛出 AccessDeniedException,
// 最终 httpSecurity.exceptionHandling 也会捕获到 InsufficientAuthenticationException
SecurityContextHolder.clearContext();
throw new InsufficientAuthenticationException("权限不足");
}
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public boolean supports(Class<?> clazz) {
log.debug("{}#supports {}", CLASS_NAME, JSON.toJSONString(clazz));
return true;
}
}
最后, 如之前所述, 我们需要利用 ObjectPostProcessor
机制为没有暴露特定属性的 FilterSecurityInterceptor
配置 AccessDecisionManager
和 SecurityMetadataSource
:
/**
* {@link FilterSecurityInterceptor} PostProcessor
*
* @author LiKe
* @version 1.0.0
* @date 2020-05-08 18:14
*/
public class FilterSecurityInterceptorPostProcessor implements ObjectPostProcessor<FilterSecurityInterceptor> {
private final AccessDecisionManager accessDecisionManager;
private final FilterInvocationSecurityMetadataSource securityMetadataSource;
public FilterSecurityInterceptorPostProcessor(AccessDecisionManager accessDecisionManager, FilterInvocationSecurityMetadataSource securityMetadataSource) {
this.accessDecisionManager = accessDecisionManager;
this.securityMetadataSource = securityMetadataSource;
}
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O filterSecurityInterceptor) {
filterSecurityInterceptor.setAccessDecisionManager(accessDecisionManager);
filterSecurityInterceptor.setSecurityMetadataSource(securityMetadataSource);
return filterSecurityInterceptor;
}
}
这样, 我们动态权限的配置就完成了. 再来简单看看 (详细介绍请看之前的文章) 身份认证和权限验证的过滤器:
(实现思路和 SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证 一致)
这是 SpringSecurity 自带的过滤器, 它的执行时间早于 UsernamePasswordAuthenticationFilter
, 主要调用 CsrfTokenRepository
维护 csrf-token (详细介绍请看 SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理). 由于我们禁用 Session, 需要把 csrf-token 和 JWT 缓存, 所以, 需要自己实现 CsrfTokenRepository
, 原理和之前的文章一致:
这里需要注意的一个优化点是, 对于注册这种端点, 我们不需要后端返回 csrf-token, 所以需要避免过滤器执行 CsrfTokenRepository
的 saveToken 方法, 这里采用的措施是生成一个临时令牌, 由于注册端点 /auth/register 本身被配置为 csrf 策略忽略, 所以并不会用这个临时的令牌与缓存中的比较, 也不会为了一个无效的 csrf-token 额外的进行一次缓存操作.
@Component
public class CsrfTokenRedisRepository implements CsrfTokenRepository {
/**
* parameterName
*/
private static final String CSRF_PARAMETER_NAME = "_csrf";
/**
* headerName, csrf-token 在请求头中的 HEADER-NAME
*/
private static final String CSRF_HEADER_NAME = "X-CSRF-TOKEN";
private static final String CSRF_TOKEN = SecurityConfiguration.CSRF_TOKEN;
/**
* {@link CsrfTokenRedisRepository#saveToken(CsrfToken, HttpServletRequest, HttpServletResponse)} 白名单, 放行 /auth/register 注册端点
*/
private static final Set<String> IGNORING_SAVING_TOKEN_LIST = Stream.of(SecurityConfiguration.REGISTER_URI).collect(Collectors.toSet());
/**
* {@link RedisService}
*/
private RedisService redisService;
/**
* {@link User}#name, 用户名
*/
private String name;
/**
* 如果 generateToken 被调用了, 则该方法也会被调用
*/
@SneakyThrows
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
log.debug("csrf filter: redis csrf token repository: save token");
if (Objects.isNull(token)) {
redisService.delete(RedisKey.builder().prefix(name).suffix(CSRF_TOKEN).build());
return;
}
// 缓存 csrf-token
final String csrfToken = token.getToken();
redisService.setValue(RedisKey.builder().prefix(name).suffix(CSRF_TOKEN).build(), csrfToken);
if (StringUtils.equals(RequestUtils.getQualifiedURI(request), SecurityConfiguration.LOGIN_URI)) {
// 登录端点的请求在登录成功后设置 JWTAuthenticationFilter#successfulAuthentication
return;
}
// 将 csrf-token 放入响应头
response.setHeader(CSRF_TOKEN, csrfToken);
}
/**
* 首先被调用
*/
@SneakyThrows
@Override
public CsrfToken loadToken(HttpServletRequest request) {
log.debug("csrf filter: redis csrf token repository: load token");
final User user = JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, User.class);
if (Objects.nonNull(user)) {
this.name = user.getName();
} else {
try {
this.name = AccessTokenUtils.getSubject(
request.getHeader(AccessTokenUtils.AUTHORIZATION_HEADER).replaceFirst(AccessTokenUtils.BEARER_TOKEN_TYPE, StringUtils.EMPTY)
);
} catch (Exception ignored) {
throw new UserInfoIncompleteException("Cannot retrieve user's name from request (neither inputStream nor header Authorization)");
}
}
if (StringUtils.isBlank(this.name)) {
throw new UserInfoIncompleteException("Cannot retrieve user's name from request (neither inputStream nor header Authorization)");
}
// ~ 避免触发 saveToken
final String qualifiedURI = RequestUtils.getQualifiedURI(request);
if (IGNORING_SAVING_TOKEN_LIST.contains(qualifiedURI)) {
return generateToken(request);
}
// 返回正常的 Token
final String csrfToken = getCachedToken();
return StringUtils.isBlank(csrfToken) ? null : new DefaultCsrfToken(CSRF_HEADER_NAME, CSRF_PARAMETER_NAME, csrfToken);
}
/**
* 当 loadToken 返回 null 时, 会调用 generateToken 随后调用 saveToken
*/
@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(CSRF_HEADER_NAME, CSRF_PARAMETER_NAME, CsrfTokenUtils.create());
}
/**
* Description: 从缓存中获取 csrf-token
*
* @return java.lang.String
* @author LiKe
* @date 2020-05-13 09:54:16
*/
private String getCachedToken() {
final String csrfToken = redisService.getValue(RedisKey.builder().prefix(name).suffix(CSRF_TOKEN).build(), String.class);
if (StringUtils.isNoneBlank(csrfToken)) {
return csrfToken;
}
return StringUtils.EMPTY;
}
// ~ Autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setRedisService(RedisService redisService) {
this.redisService = redisService;
}
}
在认证成功的时候在请求头中置入 JWT 和 csrf-token. 认证失败的时候执行清理工作.
ResponseUtils
封装了响应头的方法, 用于给前端返回统一格式的数据.
用户登陆后, 会通过该过滤器对用户信息进行校验, 如果校验通过, 会在响应头中置入 JWT 和 csrf-token (由 CsrfFilter
操作 CsrfTokenRespository
生成), 然后停止后续执行, 返回.
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private static final String AUTHENTICATION_SUCCESS_MESSAGE = "Login success.";
private static final String AUTHENTICATION_INCOMPLETE_MESSAGE = "User credential is null.";
private static final String USERNAME_PARAMETER_NAME = "name";
private static final String CSRF_TOKEN = SecurityConfiguration.CSRF_TOKEN;
private static final String ACCESS_TOKEN = SecurityConfiguration.ACCESS_TOKEN;
/**
* {@link AuthenticationManager }
*/
private final AuthenticationManager authenticationManager;
/**
* {@link RedisService }
*/
private final RedisService redisService;
private User user;
public JWTAuthenticationFilter(AuthenticationManager authenticationManager, RedisService redisService) {
this.authenticationManager = authenticationManager;
this.redisService = redisService;
// 当 requestUrl 是 /auth/login 时会经过这个过滤器
super.setFilterProcessesUrl(SecurityConfiguration.LOGIN_URI);
super.setUsernameParameter(USERNAME_PARAMETER_NAME);
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 数据是通过 requestBody 传输
this.user = JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, User.class);
if (Objects.isNull(user)) {
ResponseUtils.forbiddenResponse(response, AUTHENTICATION_INCOMPLETE_MESSAGE);
return null;
}
try {
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(user.getName(), user.getPassword())
);
} catch (AuthenticationException authenticationException) {
unsuccessfulAuthentication(request, response, authenticationException);
// 终止后续执行
return null;
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
log.debug("Successful authentication.");
SecurityContextHolder.getContext().setAuthentication(authResult);
final CustomUserDetailsDto customUserDetailsDto = (CustomUserDetailsDto) authResult.getPrincipal();
// ~ 登陆成功后, 给响应头置入 access-token 和 csrf-token
response.setHeader(
ACCESS_TOKEN,
// 缓存 access-token
redisService.setValue(
RedisKey.builder().prefix(customUserDetailsDto.getName()).suffix(ACCESS_TOKEN).build(),
AccessTokenUtils.create(customUserDetailsDto),
AccessTokenUtils.LIFE_TIME
)
);
response.setHeader(
CSRF_TOKEN,
redisService.getValue(
RedisKey.builder().prefix(customUserDetailsDto.getName()).suffix(CSRF_TOKEN).build(),
String.class
)
);
ResponseUtils.okResponse(response, AUTHENTICATION_SUCCESS_MESSAGE);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
log.debug("Unsuccessful authentication : delete cached csrf-token.");
// 如果登陆失败, 清楚在 CsrfFilter 阶段已经缓存的无效的 csrf-token, 不会有 csrf-token 返回
redisService.delete(RedisKey.builder().prefix(user.getName()).suffix(SecurityConfiguration.CSRF_TOKEN).build());
ResponseUtils.forbiddenResponse(response, failed.getMessage());
}
}
权限验证过滤器的执行时机晚于身份认证过滤器, 承担着判断请求头中是否存在有效的 JWT, 封装 Authentication
对象, 并延长 JWT 过期时间的职责.
@Slf4j
public class JWTAuthorizationFilter extends OncePerRequestFilter {
private static final String UNAUTHORIZED_MESSAGE = "Unauthorized access.";
/**
* access-token 过期 或者 不是有效的 access-token
*/
private static final String ACCESS_TOKEN_EXPIRED_MESSAGE = "Invalid access-token.";
/**
* 白名单, 放行 /auth/register 注册端点
*/
private static final Set<String> WHITE_LIST = Stream.of(SecurityConfiguration.REGISTER_URI).collect(Collectors.toSet());
/**
* {@link RedisService }
*/
private final RedisService redisService;
public JWTAuthorizationFilter(RedisService redisService) {
this.redisService = redisService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(AccessTokenUtils.AUTHORIZATION_HEADER);
// ~ without access-token
// -------------------------------------------------------------------------------------------------------------
if (WHITE_LIST.contains(RequestUtils.getQualifiedURI(request))) {
filterChain.doFilter(request, response);
return;
}
if (StringUtils.isBlank(authorization)) {
ResponseUtils.unauthorizedResponse(response, UNAUTHORIZED_MESSAGE);
return;
}
// ~ with access-token
// -------------------------------------------------------------------------------------------------------------
final String jwt = authorization.replaceFirst(AccessTokenUtils.BEARER_TOKEN_TYPE, StringUtils.EMPTY);
final CustomUserDetailsDto customUserDetailsDto = AccessTokenUtils.getCustomUserDetails(jwt);
// 如果 access-token 已经失效
final RedisKey accessTokenKey = RedisKey.builder().prefix(customUserDetailsDto.getName()).suffix(SecurityConfiguration.ACCESS_TOKEN).build();
if (Objects.isNull(redisService.getValue(accessTokenKey, String.class))) {
ResponseUtils.unauthorizedResponse(response, ACCESS_TOKEN_EXPIRED_MESSAGE);
return;
}
// 每次授权的访问, 都延长 access-token 的过期时间, 返回新的 token
redisService.expire(accessTokenKey, AccessTokenUtils.LIFE_TIME);
// 认证
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
customUserDetailsDto.getName(),
customUserDetailsDto.getPassword(),
customUserDetailsDto.getAuthorities()
));
filterChain.doFilter(request, response);
}
}
我们还需要确保无论是正常的响应, 还是异常的响应, 都给前端返回统一的 JSON. 约定前后端交互的 JSON 格式为:
{
"timestamp": "格式为 yyyy-MM-dd HH:mm:ss 的时间",
"status": 200,
"message": "消息",
"data": "数据 (如果有), 默认 {}"
}
根据 SpringBoot 用 @ControllerAdvice 和 BasicErrorController 处理异常并返回结构一致的 JSON 中的思路, 利用 BasicErrorController
来处理 @ControllerAdvice
以外的异常:
/**
* {@link BasicErrorController }
* Ref: https://stackoverflow.com/questions/34366964/spring-boot-controlleradvice-exception-handler-not-firing
*
* @author LiKe
* @version 1.0.0
* @date 2020-05-13 17:09
*/
@RestController
public class CustomErrorController extends BasicErrorController {
private static final String EXCEPTION = "javax.servlet.error.exception";
// private static final String STATUS_CODE_KEY = "javax.servlet.error.status_code";
private static final String EXCEPTION_MESSAGE = "message";
private static final String TIMESTAMP = "timestamp";
public CustomErrorController(ErrorAttributes errorAttributes) {
super(errorAttributes, new CustomErrorProperties());
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
return responseEntity(request);
}
private ResponseEntity<Map<String, Object>> responseEntity(HttpServletRequest request) {
final Map<String, Object> body = getErrorAttributes(request, false);
final Object exception = request.getAttribute(EXCEPTION);
// ~ UserInfoIncompleteException responseEntity
if (exception instanceof UserInfoIncompleteException) {
return new ResponseEntity<>(
BeanUtils.toMap(
SecurityResponseDtoBuilder.of().with(builder -> {
builder.timestamp = TimeUtils.fromDate((Date) MapUtils.getObject(body, TIMESTAMP));
builder.httpStatus = HttpStatus.FORBIDDEN;
builder.message = MapUtils.getString(body, EXCEPTION_MESSAGE);
}).build().toString()
),
HttpStatus.FORBIDDEN
);
}
return super.error(request);
}
/**
* {@link ErrorProperties}
*/
private static class CustomErrorProperties extends ErrorProperties {
public CustomErrorProperties() {
// 在 HttpServletRequest 中加入属性 javax.servlet.error.exception, 指明发生的具体异常
super.setIncludeException(true);
}
}
}
同时封装一个响应工具类 ResponseUtils, 保证响应信息体结构的一致性:
@Component
public final class ResponseUtils {
private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=UTF-8";
private static void preHandle(HttpServletResponse response) {
response.setContentType(RESPONSE_CONTENT_TYPE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
}
private static void wrapResponse(HttpStatus httpStatus, HttpServletResponse response, String message) throws IOException {
preHandle(response);
response.setStatus(httpStatus.value());
try (final PrintWriter writer = response.getWriter()) {
writer.write(
SecurityResponseDtoBuilder.of().with(securityResponseDtoBuilder -> {
securityResponseDtoBuilder.httpStatus = httpStatus;
securityResponseDtoBuilder.message = message;
}).build().toString()
);
writer.flush();
}
}
/**
* Description: 包装 HttpStatus 为 200 的 Response
*
* @param response {@link HttpServletResponse}
* @param message 消息
* @return void
* @author LiKe
* @date 2020-05-09 10:27:59
*/
public static void okResponse(HttpServletResponse response, String message) throws IOException {
wrapResponse(HttpStatus.OK, response, message);
}
/**
* Description: 包装 HttpStatus 为 401 的 Response
*
* @param response {@link HttpServletResponse}
* @param message 消息
* @return void
* @author LiKe
* @date 2020-05-08 18:31:22
*/
public static void unauthorizedResponse(HttpServletResponse response, String message) throws IOException {
wrapResponse(HttpStatus.UNAUTHORIZED, response, message);
}
/**
* Description: 包装 HttpStatus 为 403 的 Response
*
* @param response {@link HttpServletResponse}
* @param message 消息
* @return void
* @author LiKe
* @date 2020-05-08 18:31:09
*/
public static void forbiddenResponse(HttpServletResponse response, String message) throws IOException {
wrapResponse(HttpStatus.FORBIDDEN, response, message);
}
private ResponseUtils() {
}
}
核心代码和思路基本上介绍完了, 剩下的请查阅文章开头分享的代码地址.
❉ 登陆
针对端点 /auth/login, 我们在请求体重置入用户信息数据:
{
"name": "like",
"password": "root"
}
登陆成功的响应信息:
{
"timestamp": "2020-05-26 10:10:42",
"status": 200,
"message": "Login success.",
"data": "{}"
}
同时, 在响应头中, 后端也如约返回了 JWT 和 csrf-token, redis 中也有缓存数据:
❉ 访问权限内资源, 动态改变权限
由于当前用户有 ROLE_USER 的角色, 在请求头中携带 JWT 和 csrf-token 访问端点 /user/1, 可以看到 redis 中 JWT 的过期时间更新了, 同时服务也返回给前端新的 csrf-token:
手动修改 ROLE_USER 表的对应关系, 将 ID 为 1 的用户的角色 ID 改为 ROLE_ADMIN 的 ID (1), 重新登陆后再次访问, 可以看到服务端正确的返回了 403 的状态信息, 响应头中也被置入了新的 csrf-token:
{
"timestamp": "2020-05-26 10:33:40",
"status": 401,
"message": "权限不足",
"data": "{}"
}
动态权限就生效了, 如果直接更改 ROLE_POPEDOM 数据, 是不用重新登陆的.
(这里有一个优化的地方就是如果我们改变了角色后需要重新登陆, 这里可以做一个刷新的操作, 或者不要把权限信息放到 JWT 中)
❉ 异常的访问
如果请求中没有携带 csrf-token, 后端也会返回一致的提示信息:
{
"timestamp": "2020-05-26 10:38:44",
"status": 403,
"message": "Invalid csrf-token.",
"data": "{}"
}
这是由于在 CsrfFilter#doFilterInternal
中在 csrf-token 缺失或是无效时, 都会调用 accessDeniedHandler, 而在 SecurityConfiguration 中, 我们显示指定了 accessDeniedHandler.
如果登陆请求中用户信息不完整, 后端响应为:
{
"data": "{}",
"message": "Cannot retrieve user's name from request (neither inputStream nor header Authorization)",
"timestamp": "2020-05-26 10:43:08",
"status": 403
}
这是因为我们在 CsrfTokenRepository
中显示抛出了 UserInfoIncompleteException 异常, 同时在自定义的 BasicErrorController
中处理了该异常, 将本应该返回 500 的错误更改为符合业务逻辑的 403 错误, 并转换成约定的数据格式.
本文介绍了动态权限的原理及思路以及如何实现. 由于是基于前后端分离以及以 JWT 作为令牌类型的形式, 整个服务禁用 Session, 所以把令牌交给了 redis 管理. 结合本系列前面几篇文章, 算是一个小小的整合. 作为一个基于 SpringSecurity + SpringBoot 的 Auth 模块, 初具雏形.
目前可以想到的优化也就只有 SecurityMetadataSource
中的权限数据缓存和 JWT 中的角色信息缓存.
限于篇幅 (其实已经很长了) 并未粘贴全部代码, 源码请见文章开头.
下一篇, 开始 OAuth 2.0 的整合 …
- END -
标记接口是一种计算机科学中的设计模式. 它提供了一种关联 class 的元数据的方式. 标记了 class 运行时的额外特性. 如 Serializable 接口. ↩︎