Coding & Life

求知若饥,虚心若愚

0%

1. 前言

中提到的第 32 个 Filter 不知道你是否有印象。它决定了访问特定路径应该具备的权限,访问的用户的角色,权限是什么?访问的路径需要什么样的角色和权限? 它就是 FilterSecurityInterceptor ,正是我们需要的那个轮子。

2. FilterSecurityInterceptor

过滤器排行榜第 32 位!肩负对接口权限认证的重要职责。我们来看它的过滤逻辑:

1
2
3
4
5
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}

初始化了一个 FilterInvocation 然后被 invoke 方法处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}

InterceptorStatusToken token = super.beforeInvocation(fi);

try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}

super.afterInvocation(token, null);
}
}

每一次请求被 Filter 过滤都会被打上标记 FILTER_APPLIED,没有被打上标记的 走了父类的 beforeInvocation 方法然后再进入过滤器链,看上去是走了一个前置的处理。那么前置处理了什么呢?

首先会通过 this.obtainSecurityMetadataSource().getAttributes(Object object) 拿受保护对象(就是当前请求的URI)所有的映射角色(ConfigAttribute 直接理解为角色的进一步抽象) 。然后使用访问决策管理器 AccessDecisionManager 进行投票决策来确定是否放行。 我们来看一下这两个接口。

安全拦截器和“安全对象”模型参考:

3. FilterInvocationSecurityMetadataSource

这个接口是 FilterSecurityInterceptor 的属性,UML图如下:

FilterInvocationSecurityMetadataSource 是一个标记接口,其抽象方法继承自 SecurityMetadataSourceAopInfrastructureBean 。它的作用是来获取我们上一篇文章所描述的资源角色元数据。

  • Collection getAttributes(Object object) 根据提供的受保护对象的信息,其实就是URI,获取该URI 配置的所有角色
  • Collection getAllConfigAttributes() 这个就是获取全部角色
  • boolean supports(Class<?> clazz) 对特定的安全对象是否提供 ConfigAttribute 支持

3.1 自定义实现 FilterInvocationSecurityMetadataSource 的思路分析

所有的思路仅供参考,实际以你的业务为准!

Collection<ConfigAttribute> getAttributes(Object object) 方法的实现:肯定是获取请求中的 URI 来和 所有的 资源配置中的 Ant Pattern 进行匹配以获取对应的资源配置, 这里需要将资源查询接口查询的资源配置封装为 AntPathRequestMatcher以方便进行 Ant Match

这里需要特别提一下如果你使用 Restful 风格,这里 增删改查 将非常方便你来对资源的管控。参考的实现:

1
2
3
4
5
6
@Bean
public RequestMatcherCreator requestMatcherCreator() {
return metaResources -> metaResources.stream()
.map(metaResource -> new AntPathRequestMatcher(metaResource.getPattern(), metaResource.getMethod()))
.collect(Collectors.toSet());
}

HttpRequest 匹配到对应的资源配置后就能根据资源配置去取对应的角色集合。这些角色将交给访问决策管理器 AccessDecisionManager 进行投票表决以决定是否放行。

4. AccessDecisionManager

决策管理器,用来投票决定是否放行请求。

1
2
3
4
5
6
7
8
9
10
public interface AccessDecisionManager {
// 决策 主要通过其持有的 AccessDecisionVoter 来进行投票决策
void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
InsufficientAuthenticationException;
// 以确定AccessDecisionManager是否可以处理传递的ConfigAttribute
boolean supports(ConfigAttribute attribute);
//以确保配置的AccessDecisionManager支持安全拦截器将呈现的安全 object 类型。
boolean supports(Class<?> clazz);
}

AccessDecisionManager 有三个默认实现:

  • AffirmativeBased 基于肯定的决策器。 用户持有一个同意访问的角色就能通过。
  • ConsensusBased 基于共识的决策器。 用户持有同意的角色数量多于禁止的角色数。
  • UnanimousBased 基于一致的决策器。 用户持有的所有角色都同意访问才能放行。

投票决策模型参考:

4.1 自定义 AccessDecisionManager

动态控制权限就需要我们实现自己的访问决策器。我们上面说了默认有三个实现,这里我选择基于肯定的决策器 AffirmativeBased,只要用户持有一个持有一个角色包含想要访问的资源就能访问该资源。接下来就是投票器 AccessDecisionVoter 的定义了,其实我们可以选择内置的

5. AccessDecisionVoter

AccessDecisionVoter 将安全配置属性 ConfigAttribute 以特定的逻辑进行解析并基于特定的策略来进行投票,投赞成票时总票数 +1 ,反对票总票数 -1 ,弃权时总票数 +0 , 然后由 AccessDecisionManager 根据具体的计票策略来决定是否放行。

5.1 角色投票器 RoleVoter

Spring Security 提供的最常用的投票器是角色投票器 RoleVoter,它将安全配置属性 ConfigAttribute 视为简单的角色名称,并在用户被分配了该角色时授予访问权限。
如果任何 ConfigAttribute 以前缀 ROLE_ 开头,它将投票。如果有一个 GrantedAuthority 返回一个字符串(通过 getAuthority() 方法)正好等于一个或多个从前缀 ROLE_ 开始的 ConfigAttributes,它将投票授予访问权限。如果没有任何以 ROLE_开头的 ConfigAttributes匹配,则 RoleVoter 将投票拒绝访问。如果没有 ConfigAttributeROLE_为前缀,将弃权。

这正是我们想要的投票器。

5.2 角色分层投票器 RoleHierarchyVoter

通常要求应用程序中的特定角色应自动“包含”其他角色。例如,在具有 ROLE_ADMINROLE_USER 角色概念的应用中,您可能希望管理员能够执行普通用户可以执行的所有操作。你不得不进行各种复杂的逻辑嵌套来满足这一需求。现在幸好有了 RoleHierarchyVoter 可以帮你减少这种负担。

它由上面的 RoleVoter 派生,通过配置了一个 RoleHierarchy就可以实现 ROLE_ADMIN ⇒ ROLE_STAFF ⇒ ROLE_USER ⇒ ROLE_GUEST 这种层次包含结构,左边的一定能访问右边可以访问的资源。具体的配置规则为:角色从左到右、从高到低以 > 相连(注意两个空格),以换行符 \n 为分割线。举个例子

1
2
3
ROLE_ADMIN > ROLE_STAFF
ROLE_STAFF > ROLE_USER
ROLE_USER > ROLE_GUEST

请注意动态配置中你需要自行实现角色分层的逻辑。DEMO 中并未对该风格进行实现。

6. 配置

配置需要两个方面

6.1 自定义组件的配置

我们需要将元数据加载器 和 访问决策器注入 Spring IoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 动态权限组件配置
*
* @author Felordcn
*/
@Configuration
public class DynamicAccessControlConfiguration {
/**
* RequestMatcher 生成器
* @return RequestMatcher
*/
@Bean
public RequestMatcherCreator requestMatcherCreator() {
return metaResources -> metaResources.stream()
.map(metaResource -> new AntPathRequestMatcher(metaResource.getPattern(), metaResource.getMethod()))
.collect(Collectors.toSet());
}

/**
* 元数据加载器
*
* @return dynamicFilterInvocationSecurityMetadataSource
*/
@Bean
public FilterInvocationSecurityMetadataSource dynamicFilterInvocationSecurityMetadataSource() {
return new DynamicFilterInvocationSecurityMetadataSource();
}

/**
* 角色投票器
* @return roleVoter
*/
@Bean
public RoleVoter roleVoter() {
return new RoleVoter();
}

/**
* 基于肯定的访问决策器
*
* @param decisionVoters AccessDecisionVoter类型的 Bean 会自动注入到 decisionVoters
* @return affirmativeBased
*/
@Bean
public AccessDecisionManager affirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
return new AffirmativeBased(decisionVoters);
}

}

Spring SecurityJava Configuration 不会公开它配置的每个 object 的每个 property。这简化了大多数用户的配置。

虽然有充分的理由不直接公开每个 property,但用户可能仍需要像本文一样的取实现个性化需求。为了解决这个问题,Spring Security 引入了 ObjectPostProcessor 的概念,它可用于修改或替换 Java Configuration 创建的许多 Object 实例。 FilterSecurityInterceptor 的替换配置正是通过这种方式来进行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
@Configuration
@ConditionalOnClass(WebSecurityConfigurerAdapter.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class CustomSpringBootWebSecurityConfiguration {
private static final String LOGIN_PROCESSING_URL = "/process";

/**
* Json login post processor json login post processor.
*
* @return the json login post processor
*/
@Bean
public JsonLoginPostProcessor jsonLoginPostProcessor() {
return new JsonLoginPostProcessor();
}

/**
* Pre login filter pre login filter.
*
* @param loginPostProcessors the login post processors
* @return the pre login filter
*/
@Bean
public PreLoginFilter preLoginFilter(Collection<LoginPostProcessor> loginPostProcessors) {
return new PreLoginFilter(LOGIN_PROCESSING_URL, loginPostProcessors);
}

/**
* Jwt 认证过滤器.
*
* @param jwtTokenGenerator jwt 工具类 负责 生成 验证 解析
* @param jwtTokenStorage jwt 缓存存储接口
* @return the jwt authentication filter
*/
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtTokenGenerator jwtTokenGenerator, JwtTokenStorage jwtTokenStorage) {
return new JwtAuthenticationFilter(jwtTokenGenerator, jwtTokenStorage);
}

/**
* The type Default configurer adapter.
*/
@Configuration
@Order(SecurityProperties.BASIC_AUTH_ORDER)
static class DefaultConfigurerAdapter extends WebSecurityConfigurerAdapter {

@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private PreLoginFilter preLoginFilter;
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource;
@Autowired
private AccessDecisionManager accessDecisionManager;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
super.configure(auth);
}

@Override
public void configure(WebSecurity web) {
super.configure(web);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.cors()
.and()
// session 生成策略用无状态策略
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
.and()
// 动态权限配置
.authorizeRequests().anyRequest().authenticated().withObjectPostProcessor(filterSecurityInterceptorObjectPostProcessor())
.and()
.addFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)
// jwt 必须配置于 UsernamePasswordAuthenticationFilter 之前
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 登录 成功后返回jwt token 失败后返回 错误信息
.formLogin().loginProcessingUrl(LOGIN_PROCESSING_URL).successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler)
.and().logout().addLogoutHandler(new CustomLogoutHandler()).logoutSuccessHandler(new CustomLogoutSuccessHandler());

}

/**
* 自定义 FilterSecurityInterceptor ObjectPostProcessor 以替换默认配置达到动态权限的目的
*
* @return ObjectPostProcessor
*/
private ObjectPostProcessor<FilterSecurityInterceptor> filterSecurityInterceptorObjectPostProcessor() {
return new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O object) {
object.setAccessDecisionManager(accessDecisionManager);
object.setSecurityMetadataSource(filterInvocationSecurityMetadataSource);
return object;
}
};
}

}
}

然后你编写一个 Controller 方法就将其在数据库注册为一个资源进行动态的访问控制了。无须注解或者更详细的 Java Config 配置。

7. 总结

从最开始到现在一共10个 DEMO 。我们循序渐进地从如何学习 Spring Security 到目前实现了基于 RBAC、动态的权限资源访问控制。如果你能坚持到现在那么已经能满足了一些基本开发定制的需要。当然 Spring Security 还有很多局部的一些概念,我也会在以后抽时间进行讲解。

本节代码在day10分支

转载自@felord.cn

1. 前言

截止目前已经对 基于配置基于注解 的角色访问控制进行了讲解。对于一些小项目来说基本是够用的。然而如果希望运营管理人员能够动态的配置和分配权限,以上两种方式显然是满足不了需求的。接下来我们来一起探讨一下思路。

2. 动态的权限控制同样依赖 RBAC 模型

我们依然应该在 RBAC 及其变种的基础上构建动态的权限控制系统。所有被访问的目标,无论是 API、静态资源都应该是关联了角色的东西统称为 资源(Resource)。我们需要建立起角色和资源之间的关系。

2.1 资源映射到角色

下面是一个资源到角色的映射关系图:

模型大致如上所示,每一个资源对应一个可能无重复的角色集(Set 集合);你可以注意到一个细节 Role 1 既指向 Resource 1 又指向 Resource 2 中,这是可以理解的,毕竟有可能对同一资源的访问权可能分散到多个角色中去;当然也可以互斥这取决于你的业务。

我们选择资源映射到角色是因为当请求时,资源是唯一的而角色可能是多个,如果进行反转的话解析的效率低一些。

3. 请求认证过程

这里有很多搞法,但是总体的思路是我们的请求肯定是带下面两个东西(起码在走到进行访问决策这一步是必须有的):

  • URI 访问资源必然要用 URI 来定位,我们同样通过 URI 来和资源接口进行匹配;最好是 Ant match,因为/user/1/user/2 有可能访问的是同一个资源接口。如果你想避免这种情况,要么在开发规约中禁止这种风格,这样的好处是配置人员可以不必熟悉 Ant 风格;要么必须让配置人员掌握 Ant 风格。

  • Principal,Spring Security 中为 Authentication (认证主体),之前讲过的一个比较绕的概念,Spring Security 中的用户身份有两种 一种是 认证用户 另一种是 匿名用户 ,它们都包含角色。拿到角色到角色集进行匹配。

然后我画了一个下面的图来更加清晰的展示一下流程:

4. 如何结合安全框架

虽然本文是 Spring Security 系列的,但是我们如果使用其它安全框架或者自己研发安全框架都可以依据上面的思路。如果需要用编程语言总结一下就是我们需要两个接口来协同:

  • 获取资源角色关系这些元数据的接口 这是我们动态权限控制的基石,只有将角色和资源的映射关系接口化才能动态的进行权限控制。 这里没有唯一标准,根据你的业务来设计。
  • 对Request进行解析并和提取的元数据进行匹配的接口 这是我们动态权限控制的最终逻辑实现。 这里的规则同样也没有唯一标准

抓住了这两点之后我们就非常了然了,无非实现一个具有这两种功能的 Filter ,注入安全框架的过滤链中的合适位置中。要么你可以自己造个轮子,要么你使用现在有的轮子。那么有没有现成的轮子呢? 我一般建议如果你在造轮子前先看看你选型的安全框架有没有现成的轮子可用。当现成有轮子可用并且能够满足你的需要时往往能够事半功倍。如果没有合适的就造一个!

5. 总结

本篇主要理清一下动态权限所需要的一些要点,并对请求认证的过程进行了分析。最后对结合安全框架定制也提供了一些个人的见解。实现也写了大部分,之所以拆分成上下篇,因为理论和实现放在一篇的话实在有点篇幅过长,分成上篇理论、下篇实践更加合适。

转载自@felord.cn

1. 前言

今天我们来讲一下如何在接口访问中检索当前认证用户信息。
我们先讲一下具体的场景。通常我们在认证后访问需要认证的资源时需要获取当前认证用户的信息。比如 “查询我的个人信息”。如果你直接在接口访问时显式的传入你的 UserID 肯定是不合适的。因为你认证通过后访问资源,系统是知道你是谁的。而且显式的暴露用户的检索接口也不安全。所以我们需要一个业务中可以检索当前认证用户的工具。 接下来我们来看看 Spring Security 是如何解决这个痛点的。

2. 安全上下文 SecurityContext

不知道你有没有留意Spring Security12 - 使用 JWT 认证访问接口中是如何实现 JWT 认证拦截器 JwtAuthenticationFilter 。当服务端对 JWT Token 认证通过后,会将认证用户的信息封装到 UsernamePasswordAuthenticationToken 中 并使用工具类放入安全上下文 SecurityContext 中,当服务端响应用户后又使用同一个工具类将 UsernamePasswordAuthenticationTokenSecurityContextclear 掉。

我们来简单了解 SecurityContext 具体是个什么东西。

1
2
3
4
5
6
7
8
9
10
package org.springframework.security.core.context;

import java.io.Serializable;
import org.springframework.security.core.Authentication;

public interface SecurityContext extends Serializable {
Authentication getAuthentication();

void setAuthentication(Authentication var1);
}

从源码上来看很简单就是一个 存储 Authentication 的容器。而 Authentication 是一个用户凭证接口用来作为用户认证的凭证使用,通常常用的实现有 认证用户 UsernamePasswordAuthenticationToken匿名用户 AnonymousAuthenticationToken。其中 UsernamePasswordAuthenticationToken 包含了 UserDetails , AnonymousAuthenticationToken 只包含了一个字符串 anonymousUser 作为匿名用户的标识。我们通过 SecurityContext 获取上下文时需要来进行类型判断。接下来我们来聊聊操作 SecurityContext 的工具类。

3. SecurityContextHolder

这个工具类就是 SecurityContextHolder 。 它提供了两个有用的方法:

  • setContext 设置当前的 SecurityContext
  • getContext 获取当前的 SecurityContext , 进而你可以获取到当前认证用户。
  • clearContext 清除当前的 SecurityContext

平常我们通过这三个方法来操作安全上下文 SecurityContext 。你可以直接在代码中使用工具类 SecurityContextHolder 获取用户信息,像下面一样:

1
2
3
4
5
6
7
8
9
public String getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication instanceof AnonymousAuthenticationToken){
return "anonymousUser";
}
UserDetails principal = (UserDetails) authentication.getPrincipal();
return principal.getUsername();
}

通过上面的自定义方法就可以解析到 UserDetails 的用户信息,你可以扩展 UserDetails 使得信息符合你的业务需要。上面方法中的判断是必须的,如果是匿名用户(AnonymousAuthenticationToken)返回的 Principal 类型是一个字符串 anonymousUser

3.1 扩展知识:SecurityContextHolder 存储策略

这里也扩展一下知识面,简单讲一下 SecurityContextHolder 是如何存储 SecurityContext 的。SecurityContextHolder 默认有三种存储 SecurityContext 的策略:

  • MODE_THREADLOCAL 利用ThreadLocal机制来保存每个使用者的SecurityContext缺省策略,平常我们使用这个就行了。
  • MODE_INHERITABLETHREADLOCAL利用InheritableThreadLocal机制来保存每个使用者的SecurityContext。多用于多线程环境环境下。
    • MODE_GLOBAL 静态机制,作用域为全局。目前不太常用。

4. 总结

SecurityContextSpring Security 中的一个非常重要类,今天不但介绍 SecurityContext 是什么、有什么作用,也对以前讲过的一些知识进行回顾。也对如何使用 SecurityContextHolder 操作 SecurityContext 进行了讲解。最后也简单讲述了 SecurityContextHolder 三种存储 SecurityContext 的策略和使用场景 。希望对你学习 Spring Security 有帮助。

转载自@felord.cn

1. 前言

在上一篇文章我们讲解了如何通过 javaConfig 的方式配置接口的角色访问控制。其实还有一种更加灵活的配置方式 基于注解。今天我们就来探讨一下。DEMO 获取方式在文末。

2. Spring Security方法安全

Spring Security 基于注解的安全认证是通过在相关的方法上进行安全注解标记来实现的。

2.1 开启全局方法安全

我们可以在任何 @Configuration实例上使用 @EnableGlobalMethodSecurity 注解来启用全局方法安全注解功能。该注解提供了三种不同的机制来实现同一种功能,所以我们单独开一章进行探讨。

3. @EnableGlobalMethodSecurity 注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(value = { java.lang.annotation.ElementType.TYPE })
@Documented
@Import({ GlobalMethodSecuritySelector.class })
@EnableGlobalAuthentication
@Configuration
public @interface EnableGlobalMethodSecurity {

/**
* 基于表达式进行方法访问控制
*/
boolean prePostEnabled() default false;

/**
* 基于 @Secured 注解
*/
boolean securedEnabled() default false;

/**
* 基于 JSR-250 注解
*/
boolean jsr250Enabled() default false;

boolean proxyTargetClass() default false;

int order() default Ordered.LOWEST_PRECEDENCE;
}

@EnableGlobalMethodSecurity 源码中提供了 prePostEnabledsecuredEnabledjsr250Enabled 三种方式。当你开启全局基于注解的方法安全功能时,也就是使用 @EnableGlobalMethodSecurity 注解时我们需要选择使用这三种的一种或者其中几种。我们接下来将分别介绍它们。

4. 使用 prePostEnabled

如果你在 @EnableGlobalMethodSecurity 设置 prePostEnabledtrue ,则开启了基于表达式的方法安全控制。通过表达式运算结果的布尔值来决定是否可以访问(true 开放, false 拒绝 )。
有时您可能需要执行开启 prePostEnabled 复杂的操作。对于这些实例,您可以扩展 GlobalMethodSecurityConfiguration,确保子类上存在@EnableGlobalMethodSecurity(prePostEnabled = true) 。例如,如果要提供自定义 MethodSecurityExpressionHandler :

1
2
3
4
5
6
7
8
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
// ... create and return custom MethodSecurityExpressionHandler ...
return expressionHandler;
}
}

上面示例属于高级操作,一般没有必要。无论是否继承GlobalMethodSecurityConfiguration 都将会开启四个注解。 @PreAuthorize@PostAuthorize 侧重于方法调用的控制;而 @PreFilter@PostFilter 侧重于数据的控制。

4.1 @PreAuthorize

在标记的方法调用之前,通过表达式来计算是否可以授权访问。接下来我来总结以下常用的表达式。

  • 基于 SecurityExpressionOperations 接口的表达式,也就是我们在上一文的 javaConfig 配置。示例: @PreAuthorize("hasRole('ADMIN')") 必须拥有 ROLE_ADMIN 角色。
  • 基于 UserDetails 的表达式,此表达式用以对当前用户的一些额外的限定操作。示例:@PreAuthorize("principal.username.startsWith('Felordcn')") 用户名开头为 Felordcn 的用户才能访问。
  • 基于对入参的SpEL表达式处理。关于SpEL表达式可参考官方文档。或者通过关注公众号:Felordcn 来获取相关资料。 示例: @PreAuthorize("#id.equals(principal.username)") 入参 id 必须同当前的用户名相同。

4.2 @PostAuthorize

在标记的方法调用之后,通过表达式来计算是否可以授权访问。该注解是针对 @PreAuthorize 。区别在于先执行方法。而后进行表达式判断。如果方法没有返回值实际上等于开放权限控制;如果有返回值实际的结果是用户操作成功但是得不到响应。

4.3 @PreFilter

基于方法入参相关的表达式,对入参进行过滤。分页慎用!该过程发生在接口接收参数之前。 入参必须为 java.util.Collection 且支持 remove(Object) 的参数。如果有多个集合需要通过 filterTarget=<参数名> 来指定过滤的集合。内置保留名称 filterObject 作为集合元素的操作名来进行评估过滤。

样例:

1
2
3
4
5
6
7
// 入参为Collection<String> ids   测试数据 ["Felordcn","felord","jetty"]

// 过滤掉 felord jetty 为 Felordcn
@PreFilter(value = "filterObject.startsWith('F')",filterTarget = "ids")
// 如果 当前用户持有 ROLE_AD 角色 参数都符合 否则 过滤掉不是 f 开头的
// DEMO 用户不持有 ROLE_AD 角色 故而 集合只剩下 felord
@PreFilter("hasRole('AD') or filterObject.startsWith('f')")

4.4 @PostFilter

@PreFilter不同的是, 基于返回值相关的表达式,对返回值进行过滤。分页慎用!该过程发生接口进行数据返回之前。 相关测试与@PreFilter相似,参见文末提供的 DEMO。

5. 使用 securedEnabled

如果你在 @EnableGlobalMethodSecurity 设置 securedEnabledtrue ,就开启了角色注解 @Secured ,该注解功能要简单的多,默认情况下只能基于角色(默认需要带前缀 ROLE_)集合来进行访问控制决策。

该注解的机制是只要其声明的角色集合(value)中包含当前用户持有的任一角色就可以访问。也就是 用户的角色集合和 @Secured 注解的角色集合要存在非空的交集。 不支持使用 SpEL 表达式进行决策。

6. 使用 jsr250Enabled

启用 JSR-250 安全控制注解,这属于 JavaEE 的安全规范(现为 jakarta 项目)。一共有五个安全注解。如果你在 @EnableGlobalMethodSecurity 设置 jsr250Enabledtrue ,就开启了 JavaEE 安全注解中的以下三个:

  • @DenyAll 拒绝所有的访问
  • @PermitAll 同意所有的访问
  • @RolesAllowed 用法和 5. 中的 @Secured 一样。

7. 总结

今天讲解了 Spring Security 另一种基于注解的静态配置。相比较基于 javaConfig 的方式要灵活一些、粒度更细、基于 SpEL 表达式可以实现更加强大的功能。但是这两种的方式还是基于编程的静态方式,具有一定的局限性。更加灵活的方式应该是动态来处理用户的角色和资源的映射关系,这是以后我们将要解决的问题。

本次代码在day09分支

转载自@felord.cn

1. 前言

对于受限的访问资源,并不是对所有认证通过的用户开放的。比如 A 用户的角色是会计,那么他就可以访问财务相关的资源。B 用户是人事,那么他只能访问人事相关的资源。我们在上一文中也对基于角色的访问控制的相关概念进行了探讨。在实际开发中我们如何对资源进行角色粒度的管控呢?今天我来告诉你 Spring Security 是如何来解决这个问题的。

2. 将角色写入 UserDetails

我们使用 UserDetailsService 加载 UserDetails 时也会把用户的 GrantedAuthority 权限集写入其中。你可以将角色持久化并在这个点进行注入然后配置访问策略,后续的问题交给 Spring Security

3. 在 HttpSecurity 中进行配置角色访问控制

我们可以通过配置 WebSecurityConfigurerAdapter 中的 HttpSecurity 来控制接口的角色访问。

3.1 通过判断用户是否持有角色来进行访问控制

1
httpSecurity.authorizeRequests().antMatchers("/foo/test").hasRole("ADMIN")

表示持有 ROLE_ADMIN 角色的用户才能访问 /foo/test 接口。注意:hasRole(String role) 方法入参不能携带前缀 ROLE_ 。我们来查看 SecurityExpressionRoot 中相关源码:

1
2
3
public final boolean hasRole(String role) {
return hasAnyRole(role);
}

很明显 hasRole 方法源于 hasAnyRole (持有任何其中角色之一,就能满足访问条件,用于一个接口开放给多个角色访问时) :

1
2
3
public final boolean hasAnyRole(String... roles) {
return hasAnyAuthorityName(defaultRolePrefix, roles);
}

如果一个接口开放给多个角色,比如 /foo/test 开放给了 ROLE_APPROLE_ADMIN 可以这么写:

1
httpSecurity.authorizeRequests().antMatchers("/foo/test").hasAnyRole("APP","ADMIN")

hasAnyRole 方法最终的实现为 hasAnyAuthorityName(String prefix, String... roles):

1
2
3
4
5
6
7
8
9
10
11
12
private boolean hasAnyAuthorityName(String prefix, String... roles) {
Set<String> roleSet = getAuthoritySet();

for (String role : roles) {
String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
if (roleSet.contains(defaultedRole)) {
return true;
}
}

return false;
}

上面才是根本的实现, 需要一个 prefix 和每一个 role 进行拼接,然后用户的角色集合 roleSet 中包含了就返回true放行,否则就 false 拒绝。默认的 prefixdefaultRolePrefix= ROLE_

3.2 通过判断用户的 GrantedAuthority 来进行访问控制

我们也可以通过 hasAuthorityhasAnyAuthority 来判定。 其实底层实现和 hasAnyRole 方法一样,只不过 prefixnull 。也就是你写入的 GrantedAuthority 是什么样子的,这里传入参数的就是什么样子的,不再受 ROLE_ 前缀的制约。

4. 匿名访问

匿名身份验证的用户和未经身份验证的用户之间没有真正的概念差异。Spring Security 的匿名身份验证只是为您提供了一种更方便的方式来配置访问控制属性。所有的匿名用户都持有角色 ROLE_ANONYMOUS 。所以你可以使用 2.12.2 章节的方法来配置匿名访问:

1
httpSecurity.authorizeRequests().antMatchers("/foo/test").hasAuthority("ROLE_ANONYMOUS")

你也可以通过以下方式进行配置:

1
httpSecurity.authorizeRequests().antMatchers("/foo/test").anonymous()

5. 开放请求

开放请求可以这么配置:

1
httpSecurity.authorizeRequests().antMatchers("/foo/test").permitAll()

6. permitAll 与 anonymous 的一些探讨

开放请求 其实通常情况下跟 匿名请求 有交叉。它们的主要区别在于: 当前的 AuthenticationnullpermitAll 是放行的,而 anonymous 需要 AuthenticationAnonymousAuthenticationToken 。这里是比较难以理解的,下面是来自 Spring 文档中的一些信息:

通常,采用“默认拒绝”的做法被认为是一种良好的安全做法,在该方法中,您明确指定允许的内容,并禁止其他所有内容。定义未经身份验证的用户可以访问的内容的情况与此类似,尤其是对于Web应用程序。许多站点要求用户必须通过身份验证才能使用少数几个URL(例如,主页和登录页面)。在这种情况下,最简单的是为这些特定的URL定义访问配置属性,而不是为每个受保护的资源定义访问配置属性。换句话说,有时很高兴地说默认情况下需要ROLE_SOMETHING,并且只允许该规则的某些例外,例如应用程序的登录,注销和主页。您还可以从过滤器链中完全忽略这些页面,从而绕过访问控制检查,
这就是我们所说的匿名身份验证。

使用 permitAll() 将配置授权,以便在该特定路径上允许所有请求(来自匿名用户和已登录用户),anonymous() 主要是指用户的状态(是否登录)。基本上,直到用户被“认证”为止,它就是“匿名用户”。就像每个人都有“默认角色”一样。

7. 总结

基于配置来解决基于角色的访问控制是常用的方案之一。也是最容易入门的 Spring Security 访问控制技术。下一期我们将介绍基于注解的访问控制

转载自@felord.cn

1. 前言

截止到上一篇我们已经能够简单做到用户主体认证到接口的访问控制了,但是依然满足不了实际生产的需要。 如果我们需要一个完整的权限管理系统就必须了解一下RBAC(Role-Based Access Control基于角色的访问控制) 的权限控制模型。

2. 为什么需要RBAC?

在正式讨论RBAC模型之前,我们要思考一个问题,为什么我们要做角色权限系统? 答案很明显,一个系统肯定具有不同访问权限的用户。比如付费用户和非付费用户的权限,如果你是 QQ音乐的会员那么你能听高音质的歌曲,如果不是就不能享受某些便利的、优质的服务。那么这是一成不变的吗?又时候为了流量增长或者拉新的需要,我们又可能把一些原来充钱才能享受的服务下放给免费用户。如果你有了会员等级那就更加复杂了,VIP1VIP2具有的功能肯定又有所差别了。主流的权限管理系统都是 RBAC 模型的变形和运用,只是根据不同的业务和设计方案,呈现不同的显示效果。

下图展示了用户和角色以及资源的简单关系:

那为什么不直接给用户分配权限,还多此一举的增加角色这一环节呢?当然直接给用户具体的资源访问控制权限也不是不可以。只是这样做的话就少了一层关系,扩展性弱了许多。如果你的系统足够简单就不要折腾RBAC了,怎么简单就怎么玩。如果你的系统需要考虑扩展性和权限控制的多样性就必须考虑RBAC

如果你有多个具有相同权限的用户,再分配权限的时候你就需要重复为用户去 Query (查询) 和 Add (赋予) 权限,如果你要修改,比如上面的 VIP1 增加一个很 Cool 的功能,你就要遍历 VIP1 用户进行修改。有了角色后,我们只需要为该角色制定好权限后,将相同权限的用户都指定为同一个角色即可,便于权限管理。

对于批量的用户权限调整,只需调整该用户关联的角色权限,无需遍历,既大幅提升权限调整的效率,又降低了漏调权限的概率。这样用户和资源权限解除了耦合性,这就是RBAC模型的优势所在。

3. RBAC模型的分类

RBAC模型可以分为:RBAC0RBAC1RBAC2RBAC3 四种。其中 RBAC0 是基础,其它三种都是在 RBAC0 基础上的变种。大部分情况下,使用 RBAC0 模型就可以满足常规的权限管理系统设计了。不过一定不要拘泥于模型,要以业务需要为先导。接下来简单对四种模型进行简单的介绍一下。

3.1 RBAC0

RBAC0是基础,定义了能构成RBAC权限控制系统的最小的集合,RBAC0由四部分构成:

  • 用户(User) 权限的使用主体
  • 角色(Role) 包含许可的集合
  • 会话(Session)绑定用户和角色关系映射的中间通道。而且用户必须通过会话才能给用户设置角色。
  • 许可(Pemission) 对特定资源的特定的访问许可。

3.2 RBAC1

RBAC1RBAC0的基础之上引入了角色继承的概念,有了继承那么角色就有了上下级或者等级关系。父角色拥有其子角色所有的许可。通俗讲就是来说: 你能干的,你的领导一定能干,反过来就不一定能行。

3.3 RBAC2

在体育比赛中,你不可能既是运动员又是裁判员!

这是很有名的一句话。反应了我们经常出现的一种职务(其实也就是角色)冲突。有些角色产生的历史原因就是为了制约另一个角色,裁判员就是为了制约运动员从而让运动员按照规范去比赛。如果一个人兼任这两个角色,比赛必然容易出现不公正的情况从而违背竞技公平性准则。还有就是我们每个人在不同的场景都会充当不同的角色,在公司你就是特定岗位的员工,在家庭中你就是一名家庭成员。随着场景的切换,我们的角色也在随之变化。

所以RBAC2RBAC0的基础上引入了静态职责分离(Static Separation of Duty,简称SSD)和动态职责分离(Dynamic Separation of Duty,简称DSD)两个约束概念。他们两个作用的生命周期是不同的;

  • SSD作用于约束用户和角色绑定时。 1.互斥角色:就像上面的例子你不能既是A又是B,互斥的角色只能二选一 ; 2. 数量约束:用户的角色数量是有限的不能多于某个基数; 3. 条件约束:只能达到某个条件才能拥有某个角色。经常用于用户等级体系,只有你充钱成为VIP才能一刀999。
  • DSD作用于会话和角色交互时。当用户持有多个角色,在用户通过会话激活角色时加以条件约束,根据不同的条件执行不同的策略。

3.4 RBAC3

我全都要!

RBAC1RBAC2各有神通。当你拿着这两个方案给产品经理看时,他给了你一个坚定的眼神:我全都要!于是RBAC3就出现了。也就是说RBAC3 = RBAC1 + RBAC2

4. RBAC 中一些概念的理解

四个模型说完,我们来简单对其中的一些概念进行进一步的了解。

4.1 用户(User)

对用户的理解不应该被局限于单个用户,也可以是用户组(类似 linux 的 User Group), 或许还有其它的名字比如部门或者公司;也可以是虚拟的账户,客户,甚至说第三方应用也可以算用户 。所以对用户的理解要宽泛一些,只要是有访问资源需求的主体都可以纳入用户范畴。

4.2 角色(Role)

角色是特定许可的集合以及载体。一个角色可以包含多个用户,一个用户同样的也可以属于多个角色;同样的一个角色可以包含多个用户组,一个用户组也可以具有多个角色,所以角色和用户是多对多的关系。角色是可以细分的,也就是可以继承、可以分组的。

4.3 许可(Permission)

许可一般称它为权限。通常我们将访问的目标统称为资源,不管是数据还是静态资源都是资源。我们访问资源基本上又通过api接口来访问。所以一般权限都体现在对接口的控制上。再细分的话我将其划分为菜单控制,具体数据增删改查功能控制(前台体现为按钮)。另外许可具有原子性,不可再分。我们将许可授予角色时就是粒度最小的单元。

5. 总结

基于角色的访问控制(RBAC)已成为高级访问控制的主要方法之一。通过RBAC,您可以控制最终用户在广义和精细级别上可以做什么。您可以指定用户是管理员,专家用户还是最终用户,并使角色和访问权限与组织中员工的职位保持一致。仅根据需要为员工完成工作的足够访问权限来分配权限。通过上面的介绍相信一定会让你有所收获。其实不管你使用什么安全框架, RBAC 都是必须掌握的

转载自@felord.cn

1. 前言

之前我讲解了如何编写一个自己的 Jwt 生成器以及如何在用户认证通过后返回 Json Web Token 。今天我们来看看如何在请求中使用 Jwt 访问鉴权。DEMO 获取方法在文末。

2. 常用的Http认证方式

我们要在 Http 请求中使用 Jwt 我们就必须了解 常见的 Http 认证方式。

2.1 HTTP Basic Authentication

HTTP Basic Authentication 又叫基础认证,它简单地使用 Base64 算法对用户名、密码进行加密,并将加密后的信息放在请求头 Header 中,本质上还是明文传输用户名、密码,并不安全,所以最好在 Https 环境下使用。其认证流程如下:

客户端发起 GET 请求 服务端响应返回 401 Unauthorizedwww-Authenticate 指定认证算法,realm 指定安全域。然后客户端一般会弹窗提示输入用户名称和密码,输入用户名密码后放入 Header 再次请求,服务端认证成功后以 200 状态码响应客户端。

2.2 HTTP Digest Authentication

为弥补 BASIC 认证存在的弱点就有了 HTTP Digest Authentication 。它又叫摘要认证。它使用随机数加上 MD5 算法来对用户名、密码进行摘要编码,流程类似 Http Basic Authentication ,但是更加复杂一些:

步骤1:跟基础认证一样,只不过返回带 WWW-Authenticate 首部字段的响应。该字段内包含质问响应方式认证所需要的临时咨询码(随机数,nonce)。 首部字段 WWW-Authenticate 内必须包含 realmnonce 这两个字段的信息。客户端就是依靠向服务器回送这两个值进行认证的。nonce 是一种每次随返回的 401 响应生成的任意随机字符串。该字符串通常推荐由 Base64 编码的十六进制数的组成形式,但实际内容依赖服务器的具体实现

步骤2:接收到 401 状态码的客户端,返回的响应中包含 DIGEST 认证必须的首部字段 Authorization 信息。首部字段 Authorization 内必须包含 username、realm、nonce、uriresponse 的字段信息,其中,realmnonce 就是之前从服务器接收到的响应中的字段。

步骤3:接收到包含首部字段 Authorization 请求的服务器,会确认认证信息的正确性。认证通过后则会返回包含 Request-URI 资源的响应。

并且这时会在首部字段 Authorization-Info 写入一些认证成功的相关信息。

2.3 SSL 客户端认证

SSL 客户端认证就是通常我们说的 HTTPS 。安全级别较高,但需要承担 CA 证书费用。SSL 认证过程中涉及到一些重要的概念,数字证书机构的公钥、证书的私钥和公钥、非对称算法(配合证书的私钥和公钥使用)、对称密钥、对称算法(配合对称密钥使用)。相对复杂一些这里不过多讲述。

2.4 Form 表单认证

Form 表单的认证方式并不是HTTP规范。所以实现方式也呈现多样化,其实我们平常的扫码登录,手机验证码登录都属于表单登录的范畴。表单认证一般都会配合 CookieSession 的使用,现在很多 Web 站点都使用此认证方式。用户在登录页中填写用户名和密码,服务端认证通过后会将 sessionId 返回给浏览器端,浏览器会保存 sessionId 到浏览器的 Cookie 中。因为 HTTP 是无状态的,所以浏览器使用 Cookie 来保存 sessionId。下次客户端会在发送的请求中会携带 sessionId 值,服务端发现 sessionId 存在并以此为索引获取用户存在服务端的认证信息进行认证操作。认证过则会提供资源访问。

我们在Spring Security10 - 登录后返回 JWT Token 一文其实也是通过 Form 提交来获取 Jwt 其实 JwtsessionId 同样的作用,只不过 Jwt 天然携带了用户的一些信息,而 sessionId 需要去进一步获取用户信息。

2.5 Json Web Token 的认证方式 Bearer Authentication

我们通过表单认证获取 Json Web Token ,那么如何使用它呢? 通常我们会把 Jwt 作为令牌使用 Bearer Authentication 方式使用。Bearer Authentication 是一种基于令牌的 HTTP 身份验证方案,用户向服务器请求访问受限资源时,会携带一个 Token 作为凭证,检验通过则可以访问特定的资源。最初是在 RFC 6750 中作为 OAuth 2.0 的一部分,但有时也可以单独使用。
我们在使用 Bear Token 的方法是在请求头的 Authorization 字段中放入 Bearer <token> 的格式的加密串(Json Web Token)。请注意 Bearer 前缀与 Token 之间有一个空字符位,与基本身份验证类似,Bearer Authentication 只能在HTTPS(SSL)上使用。

3. Spring Security 中实现接口 Jwt 认证

接下来我们是我们该系列的重头戏 ———— 接口的 Jwt 认证。

3.1 定义 Json Web Token 过滤器

无论上面提到的哪种认证方式,我们都可以使用 Spring Security 中的 Filter 来处理。 Spring Security 默认的基础配置没有提供对 Bearer Authentication 处理的过滤器, 但是提供了处理 Basic Authentication 的过滤器:

1
org.springframework.security.web.authentication.www.BasicAuthenticationFilter

BasicAuthenticationFilter 继承了 OncePerRequestFilter 。所以我们也模仿 BasicAuthenticationFilter 来实现自己的 JwtAuthenticationFilter 。 完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
package cn.felord.spring.security.filter;

import cn.felord.spring.security.exception.SimpleAuthenticationEntryPoint;
import cn.felord.spring.security.jwt.JwtTokenGenerator;
import cn.felord.spring.security.jwt.JwtTokenPair;
import cn.felord.spring.security.jwt.JwtTokenStorage;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Objects;

/**
* jwt 认证拦截器 用于拦截 请求 提取jwt 认证
*
* @author dax
* @since 2019/11/7 23:02
*/
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHENTICATION_PREFIX = "Bearer ";
/**
* 认证如果失败由该端点进行响应
*/
private AuthenticationEntryPoint authenticationEntryPoint = new SimpleAuthenticationEntryPoint();
private JwtTokenGenerator jwtTokenGenerator;
private JwtTokenStorage jwtTokenStorage;


public JwtAuthenticationFilter(JwtTokenGenerator jwtTokenGenerator, JwtTokenStorage jwtTokenStorage) {
this.jwtTokenGenerator = jwtTokenGenerator;
this.jwtTokenStorage = jwtTokenStorage;
}


@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 如果已经通过认证
if (SecurityContextHolder.getContext().getAuthentication() != null) {
chain.doFilter(request, response);
return;
}
// 获取 header 解析出 jwt 并进行认证 无token 直接进入下一个过滤器 因为 SecurityContext 的缘故 如果无权限并不会放行
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(header) && header.startsWith(AUTHENTICATION_PREFIX)) {
String jwtToken = header.replace(AUTHENTICATION_PREFIX, "");


if (StringUtils.hasText(jwtToken)) {
try {
authenticationTokenHandle(jwtToken, request);
} catch (AuthenticationException e) {
authenticationEntryPoint.commence(request, response, e);
}
} else {
// 带安全头 没有带token
authenticationEntryPoint.commence(request, response, new AuthenticationCredentialsNotFoundException("token is not found"));
}

}
chain.doFilter(request, response);
}

/**
* 具体的认证方法 匿名访问不要携带token
* 有些逻辑自己补充 这里只做基本功能的实现
*
* @param jwtToken jwt token
* @param request request
*/
private void authenticationTokenHandle(String jwtToken, HttpServletRequest request) throws AuthenticationException {

// 根据我的实现 有效token才会被解析出来
JSONObject jsonObject = jwtTokenGenerator.decodeAndVerify(jwtToken);

if (Objects.nonNull(jsonObject)) {
String username = jsonObject.getStr("aud");

// 从缓存获取 token
JwtTokenPair jwtTokenPair = jwtTokenStorage.get(username);
if (Objects.isNull(jwtTokenPair)) {
if (log.isDebugEnabled()) {
log.debug("token : {} is not in cache", jwtToken);
}
// 缓存中不存在就算 失败了
throw new CredentialsExpiredException("token is not in cache");
}
String accessToken = jwtTokenPair.getAccessToken();

if (jwtToken.equals(accessToken)) {
// 解析 权限集合 这里
JSONArray jsonArray = jsonObject.getJSONArray("roles");

String roles = jsonArray.toString();

List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(roles);
User user = new User(username, "[PROTECTED]", authorities);
// 构建用户认证token
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user, null, authorities);
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 放入安全上下文中
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
} else {
// token 不匹配
if (log.isDebugEnabled()){
log.debug("token : {} is not in matched", jwtToken);
}

throw new BadCredentialsException("token is not matched");
}
} else {
if (log.isDebugEnabled()) {
log.debug("token : {} is invalid", jwtToken);
}
throw new BadCredentialsException("token is invalid");
}
}
}

具体看代码注释部分,逻辑有些地方根据你业务进行调整。匿名访问必然是不能带 Token 的!

3.2 配置 JwtAuthenticationFilter

首先将过滤器 JwtAuthenticationFilter 注入 Spring IoC 容器 ,然后一定要将 JwtAuthenticationFilter 顺序置于 UsernamePasswordAuthenticationFilter 之前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.cors()
.and()
// session 生成策略用无状态策略
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
.and()
.authorizeRequests().anyRequest().authenticated()
.and()
.addFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)
// jwt 必须配置于 UsernamePasswordAuthenticationFilter 之前
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 登录 成功后返回jwt token 失败后返回 错误信息
.formLogin().loginProcessingUrl(LOGIN_PROCESSING_URL).successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler)
.and().logout().addLogoutHandler(new CustomLogoutHandler()).logoutSuccessHandler(new CustomLogoutSuccessHandler());

}

4. 使用 Jwt 进行请求验证

编写一个受限接口 ,我们这里是 http://localhost:8080/foo/test 。直接请求会被 401 。 我们通过下图方式获取 Token :

然后在Postman中使用JWT:

最终会认证成功并访问到资源。

5. 刷新 Jwt Token

我们已经实现了 Json Web Token 都是成对出现的逻辑。accessToken 用来接口请求, refreshToken 用来刷新 accessToken 。我们可以同样定义一个 Filter 可参照 上面的 JwtAuthenticationFilter 。只不过 这次请求携带的是 refreshToken,我们在过滤器中拦截 URI跟我们定义的刷新端点进行匹配。同样验证 Token ,通过后像登录成功一样返回 Token对 即可。这里不再进行代码演示。

6. 总结

这是系列原创文章,总有不仔细看的同学抓不着头脑颇有微词。饭需要一口一口的吃,没有现成的可以吃,都是这么过来的,急什么。原创不易,关注才是动力。每一篇都有不同的知识点,而且它们都是相互有联系的。有不懂得地方多回头看。

代码在day08分支

转载自@felord.cn

1. 前言

今天分享Spring Security中需要对认证授权异常的处理

2. Spring Security 中的异常

Spring Security 中的异常主要分为两大类:一类是认证异常,另一类是授权相关的异常。

2.1 AuthenticationException

AuthenticationException 是在用户认证的时候出现错误时抛出的异常。主要的子类如图:

根据该图的信息,系统用户不存在,被锁定,凭证失效,密码错误等认证过程中出现的异常都由 AuthenticationException 处理。

2.2 AccessDeniedException

AccessDeniedException 主要是在用户在访问受保护资源时被拒绝而抛出的异常。同AuthenticationException 一样它也提供了一些具体的子类。如下图:

AccessDeniedException 的子类比较少,主要是 CSRF 相关的异常和授权服务异常。

3. Http 状态对认证授权的规定

Http 协议对认证授权的响应结果也有规定。

3.1 401未授权状态

HTTP 401 错误 - 未授权(Unauthorized) 一般来说该错误消息表明您首先需要登录(输入有效的用户名和密码)。 如果你刚刚输入这些信息,立刻就看到一个 401 错误,就意味着,无论出于何种原因您的用户名和密码其中之一或两者都无效(输入有误,用户名暂时停用,账户被锁定,凭证失效等) 。总之就是认证失败了。其实正好对应我们上面的 AuthenticationException

3.2 403 被拒绝状态

HTTP 403 错误 - 被禁止(Forbidden) 出现该错误表明您在访问受限资源时没有得到许可。服务器理解了本次请求但是拒绝执行该任务,该请求不该重发给服务器。并且服务器想让客户端知道为什么没有权限访问特定的资源,服务器应该在返回的信息中描述拒绝的理由。一般实践中我们会比较模糊的表明原因。 该错误对应了我们上面的 AccessDeniedException

4. Spring Security 中的异常处理

我们在 Spring Security5 - 自定义配置类入口WebSecurityConfigurerAdapter 一文中提到 HttpSecurity 提供的 exceptionHandling() 方法用来提供异常处理。该方法构造出 ExceptionHandlingConfigurer 异常处理配置类。该配置类提供了两个实用接口:

  • AuthenticationEntryPoint 该类用来统一处理 AuthenticationException 异常
  • AccessDeniedHandler 该类用来统一处理 AccessDeniedException 异常

我们只要实现并配置这两个异常处理类即可实现对 Spring Security 认证授权相关的异常进行统一的自定义处理。

4.1 实现 AuthenticationEntryPoint

json 信息响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;

/**
* @author dax
* @since 2019/11/6 22:11
*/
public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

//todo your business
HashMap<String, String> map = new HashMap<>(2);
map.put("uri", request.getRequestURI());
map.put("msg", "认证失败");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("utf-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ObjectMapper objectMapper = new ObjectMapper();
String resBody = objectMapper.writeValueAsString(map);
PrintWriter printWriter = response.getWriter();
printWriter.print(resBody);
printWriter.flush();
printWriter.close();
}
}

4.2 实现 AccessDeniedHandler

同样以 json 信息响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;

/**
* @author dax
* @since 2019/11/6 22:19
*/
public class SimpleAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
//todo your business
HashMap<String, String> map = new HashMap<>(2);
map.put("uri", request.getRequestURI());
map.put("msg", "认证失败");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding("utf-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ObjectMapper objectMapper = new ObjectMapper();
String resBody = objectMapper.writeValueAsString(map);
PrintWriter printWriter = response.getWriter();
printWriter.print(resBody);
printWriter.flush();
printWriter.close();
}
}

4.3 个人实践建议

其实我个人建议 Http 状态码 都返回 200 而将 401 状态在 元信息 Map 中返回。因为异常状态码在浏览器端会以 error 显示。我们只要能捕捉到 401403就能认定是认证问题还是授权问题。

4.4 配置

实现了上述两个接口后,我们只需要在 WebSecurityConfigurerAdapterconfigure(HttpSecurity http) 方法中配置即可。相关的配置片段如下:

1
2
3
http.exceptionHandling()
.accessDeniedHandler(new SimpleAccessDeniedHandler())
.authenticationEntryPoint(new SimpleAuthenticationEntryPoint())

5. 总结

今天我们对 Spring Security 中的异常处理进行了讲解。分别实现了自定义的认证异常处理和自定义的授权异常处理。相关的DEMO代码在day07分支

转载自@felord.cn

1. 前言

我们实现了 JWT 工具。本篇我们将一起探讨如何将 JWTSpring Security 结合起来,在认证成功后不再跳转到指定页面而是直接返回 JWT Token 。 本文的DEMO 可通过文末的方式获取

2. 流程

JWT 适用于前后端分离。我们在登录成功后不在跳转到首页,将会直接返回 JWT Token 对(DEMO中为JwtTokenPair),登录失败后返回认证失败相关的信息。

3. 实现登录成功/失败返回逻辑

3.1 AuthenticationSuccessHandler 返回 JWT Token

AuthenticationSuccessHandler 用于处理登录成功后的逻辑,我们编写实现并注入 Spring IoC 容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 处理登录成功后返回 JWT Token 对.
*
* @param jwtTokenGenerator the jwt token generator
* @return the authentication success handler
*/
@Bean
public AuthenticationSuccessHandler authenticationSuccessHandler(JwtTokenGenerator jwtTokenGenerator) {
return (request, response, authentication) -> {
if (response.isCommitted()) {
log.debug("Response has already been committed");
return;
}
Map<String, Object> map = new HashMap<>(5);
map.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
map.put("flag", "success_login");
User principal = (User) authentication.getPrincipal();

String username = principal.getUsername();
Collection<GrantedAuthority> authorities = principal.getAuthorities();
Set<String> roles = new HashSet<>();
if (CollectionUtil.isNotEmpty(authorities)) {
for (GrantedAuthority authority : authorities) {
String roleName = authority.getAuthority();
roles.add(roleName);
}
}

JwtTokenPair jwtTokenPair = jwtTokenGenerator.jwtTokenPair(username, roles, null);

map.put("access_token", jwtTokenPair.getAccessToken());
map.put("refresh_token", jwtTokenPair.getRefreshToken());

ResponseUtil.responseJsonWriter(response, RestBody.okData(map, "登录成功"));
};
}

3.2 AuthenticationFailureHandler 返回认证失败信息

AuthenticationFailureHandler 处理认证失败后的逻辑,前端根据此返回进行跳转处理逻辑,我们也实现它并注入 Spring IoC 容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 失败登录处理器 处理登录失败后的逻辑 登录失败返回信息 以此为依据跳转
*
* @return the authentication failure handler
*/
@Bean
public AuthenticationFailureHandler authenticationFailureHandler() {
return (request, response, exception) -> {
if (response.isCommitted()) {
log.debug("Response has already been committed");
return;
}
Map<String, Object> map = new HashMap<>(2);

map.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
map.put("flag", "failure_login");
ResponseUtil.responseJsonWriter(response, RestBody.build(HttpStatus.UNAUTHORIZED.value(), map, "认证失败","-9999"));
};
}

4. 配置

把上面写好的两个 Handler Bean 写入 登录配置,相关片断如下,详情参见文末 DEMO:

1
httpSecurity.formLogin().loginProcessingUrl(LOGIN_PROCESSING_URL).successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler)

5. 验证

我们依然通过Spring Security6 - 玩转自定义登录 一文中章节 6.4 测试 来运行。结果如下:

5.1 登录成功结果

1
2
3
4
5
6
7
8
9
10
11
{
"httpStatus": 200,
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGwiLCJhdWQiOiJGZWxvcmRjbiIsInJvbGVzIjoiW10iLCJpc3MiOiJmZWxvcmQuY24iLCJleHAiOiIyMDE5LTExLTI3IDExOjMxOjMyIiwiaWF0IjoiMjAxOS0xMC0yOCAxMTozMTozMiIsImp0aSI6IjdmYTBlOWFiYjk5OTRjZGRhNGM5NjI4YzExNGM3YTk4In0.PvVsc8w10_0C5UIifJS1S5dEia5PQoVc_6wMfLAZOf574kt-VopHBVEp2zkjC1CNN3ltchy5rx6samaBDQvqWgoeFLXbRgNOa9Qhdf0wMLf-pUqoKRHuhBZV9HsvXSyQCFjZWlIguv4FSPZhbEff6D_8QUXmdWjlF_XEG2BPMr4",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGwiLCJhdWQiOiJGZWxvcmRjbiIsInJvbGVzIjoiW10iLCJpc3MiOiJmZWxvcmQuY24iLCJleHAiOiIyMDIwLTAxLTI2IDExOjMxOjMyIiwiaWF0IjoiMjAxOS0xMC0yOCAxMTozMTozMiIsImp0aSI6IjdmYTBlOWFiYjk5OTRjZGRhNGM5NjI4YzExNGM3YTk4In0.Caj4AAothdUwZAFl8IjcAZmmXHgTt76z8trVG1sf_WHZucFVcHR8FWjShhITpArsQpmokP6GBTMsCvWDl08fUVZBpOWc1CdPUAIIEdArHCFzO64HXc_DLSyg9v0C-qYfxaTlf0npL5QxpBBr9sJcyzxZF3CnpfZpAxm8WZzXG6o",
"time": "2019-10-28 11:32:11",
"flag": "success_login"
},
"msg": "登录成功",
"identifier": ""
}

我们取 access_token 使用官网jwt.io 提供的解码功能进行解码如下:

5.2 登录失败结果

1
2
3
4
5
6
7
8
9
{
"httpStatus": 401,
"data": {
"time": "2019-10-28 12:54:10",
"flag": "failure_login"
},
"msg": "认证失败",
"identifier": "-9999"
}

6. 总结

今天我们将 JWTSpring Security 联系了起来,实现了 登录成功后返回 JWT Token 。 这仅仅是一个开始,在下一篇我们将介绍 客户端如何使用 JWT Token 、服务端如何验证 JWT Token ,敬请关注。

文章相关的 DEMO 在分支day06

转载自@felord.cn

1. 前言

Json Web TokenJWT) 近几年是前后端分离常用的 Token 技术,是目前最流行的跨域身份验证解决方案。今天我们来手写一个通用的 JWT 服务。DEMO 获取方式在文末,实现在 jwt 相关包下

2. spring-security-jwt

spring-security_jwtSpring Security Crypto提供的JWT工具包

1
2
3
4
5
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>${spring-security-jwt.version}</version>
</dependency>

核心类只有一个:org.springframework.security.jwt.JwtHelper。它提供了两个非常有用的静态方法。

3. JWT编码

JwtHelper 提供的第一个静态方法就是 encode(CharSequence content, Signer signer) 这个是用来生成jwt的方法 需要指定 payloadsigner 签名算法。payload 存放了一些可用的不敏感信息:

  • iss jwt签发者
  • sub jwt所面向的用户
  • aud 接收jwt的一方
  • iat jwt的签发时间
  • exp jwt的过期时间,这个过期时间必须要大于签发时间iat
  • jti jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

除了以上提供的基本信息外,我们可以定义一些我们需要传递的信息,比如目标用户的权限集 等等。切记不要传递密码等敏感信息 ,因为 JWT 的前两段都是用了 BASE64 编码,几乎算是明文了。

3.1 构建JWT中的payload

我们先来构建payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
* 构建 jwt payload
*
* @author Felordcn
* @since 11:27 2019/10/25
**/
public class JwtPayloadBuilder {

private Map<String, String> payload = new HashMap<>();
/**
* 附加的属性
*/
private Map<String, String> additional;
/**
* jwt签发者
**/
private String iss;
/**
* jwt所面向的用户
**/
private String sub;
/**
* 接收jwt的一方
**/
private String aud;
/**
* jwt的过期时间,这个过期时间必须要大于签发时间
**/
private LocalDateTime exp;
/**
* jwt的签发时间
**/
private LocalDateTime iat = LocalDateTime.now();
/**
* 权限集
*/
private Set<String> roles = new HashSet<>();
/**
* jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击
**/
private String jti = IdUtil.simpleUUID();

public JwtPayloadBuilder iss(String iss) {
this.iss = iss;
return this;
}


public JwtPayloadBuilder sub(String sub) {
this.sub = sub;
return this;
}

public JwtPayloadBuilder aud(String aud) {
this.aud = aud;
return this;
}


public JwtPayloadBuilder roles(Set<String> roles) {
this.roles = roles;
return this;
}

public JwtPayloadBuilder expDays(int days) {
Assert.isTrue(days > 0, "jwt expireDate must after now");
this.exp = this.iat.plusDays(days);
return this;
}

public JwtPayloadBuilder additional(Map<String, String> additional) {
this.additional = additional;
return this;
}

public String builder() {
payload.put("iss", this.iss);
payload.put("sub", this.sub);
payload.put("aud", this.aud);
payload.put("exp", this.exp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
payload.put("iat", this.iat.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
payload.put("jti", this.jti);

if (!CollectionUtils.isEmpty(additional)) {
payload.putAll(additional);
}
payload.put("roles", JSONUtil.toJsonStr(this.roles));
return JSONUtil.toJsonStr(JSONUtil.parse(payload));

}

}

通过建造类 JwtPayloadBuilder 我们可以很方便来构建 JWT 所需要的 payload json 字符串传递给 encode(CharSequence content, Signer signer) 中的 content

3.2 生成 RSA 密钥并进行签名

为了生成 JWT Token 我们还需要使用 RSA 算法来进行签名。 这里我们使用 JDK 提供的证书管理工具 Keytool 来生成 RSA 证书 ,格式为 jks 格式。

生成证书命令参考:

1
keytool -genkey -alias felordcn -keypass felordcn -keyalg RSA -storetype PKCS12 -keysize 1024 -validity 365 -keystore d:/keystores/felordcn.jks -storepass 123456  -dname "CN=(Felord), OU=(felordcn), O=(felordcn), L=(zz), ST=(hn), C=(cn)"

其中 -alias felordcn -storepass 123456 我们要作为配置使用要记下来。我们要使用下面定义的这个类来读取证书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package cn.felord.spring.security.jwt;

import org.springframework.core.io.ClassPathResource;

import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.PublicKey;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.RSAPublicKeySpec;

/**
* KeyPairFactory
*
* @author Felordcn
* @since 13:41 2019/10/25
**/
class KeyPairFactory {

private KeyStore store;

private final Object lock = new Object();

/**
* 获取公私钥.
*
* @param keyPath jks 文件在 resources 下的classpath
* @param keyAlias keytool 生成的 -alias 值 felordcn
* @param keyPass keytool 生成的 -storepass 值 123456
* @return the key pair 公私钥对
*/
KeyPair create(String keyPath, String keyAlias, String keyPass) {
ClassPathResource resource = new ClassPathResource(keyPath);
char[] pem = keyPass.toCharArray();
try {
synchronized (lock) {
if (store == null) {
synchronized (lock) {
store = KeyStore.getInstance("jks");
store.load(resource.getInputStream(), pem);
}
}
}
RSAPrivateCrtKey key = (RSAPrivateCrtKey) store.getKey(keyAlias, pem);
RSAPublicKeySpec spec = new RSAPublicKeySpec(key.getModulus(), key.getPublicExponent());
PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(spec);
return new KeyPair(publicKey, key);
} catch (Exception e) {
throw new IllegalStateException("Cannot load keys from store: " + resource, e);
}

}
}

获取了 KeyPair 就能获取公私钥 生成 Jwt 的两个要素就完成了。我们可以和之前定义的 JwtPayloadBuilder 一起封装出生成 Jwt Token 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private String jwtToken(String aud, int exp, Set<String> roles, Map<String, String> additional) {
String payload = jwtPayloadBuilder
.iss(jwtProperties.getIss())
.sub(jwtProperties.getSub())
.aud(aud)
.additional(additional)
.roles(roles)
.expDays(exp)
.builder();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

RsaSigner signer = new RsaSigner(privateKey);
return JwtHelper.encode(payload, signer).getEncoded();
}

通常情况下 Jwt Token 都是成对出现的,一个为平常请求携带的 accessToken, 另一个只作为刷新 accessToken 之用的 refreshToken 。而且 refreshToken 的过期时间要相对长一些。当 accessToken 失效而refreshToken 有效时,我们可以通过 refreshToken 来获取新的 Jwt Token对 ;当两个都失效就用户就必须重新登录了。

生成 Jwt Token对 的方法如下:

1
2
3
4
5
6
7
8
9
10
11
public JwtTokenPair jwtTokenPair(String aud, Set<String> roles, Map<String, String> additional) {
String accessToken = jwtToken(aud, jwtProperties.getAccessExpDays(), roles, additional);
String refreshToken = jwtToken(aud, jwtProperties.getRefreshExpDays(), roles, additional);

JwtTokenPair jwtTokenPair = new JwtTokenPair();
jwtTokenPair.setAccessToken(accessToken);
jwtTokenPair.setRefreshToken(refreshToken);
// 放入缓存
jwtTokenStorage.put(jwtTokenPair, aud);
return jwtTokenPair;
}

通常 Jwt Token对 会在返回给前台的同时放入缓存中。过期策略你可以选择分开处理,也可以选择以refreshToken的过期时间为准。

4. JWT解码以及验证

JwtHelper 提供的第二个静态方法是Jwt decodeAndVerify(String token, SignatureVerifier verifier) 用来 验证和解码 Jwt Token 。我们获取到请求中的token后会解析出用户的一些信息。通过这些信息去缓存中对应的token ,然后比对并验证是否有效(包括是否过期)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 解码 并校验签名 过期不予解析
*
* @param jwtToken the jwt token
* @return the jwt claims
*/
public JSONObject decodeAndVerify(String jwtToken) {
Assert.hasText(jwtToken, "jwt token must not be bank");
RSAPublicKey rsaPublicKey = (RSAPublicKey) this.keyPair.getPublic();
SignatureVerifier rsaVerifier = new RsaVerifier(rsaPublicKey);
Jwt jwt = JwtHelper.decodeAndVerify(jwtToken, rsaVerifier);
String claims = jwt.getClaims();
JSONObject jsonObject = JSONUtil.parseObj(claims);
String exp = jsonObject.getStr(JWT_EXP_KEY);
// 是否过期
if (isExpired(exp)) {
throw new IllegalStateException("jwt token is expired");
}
return jsonObject;
}

上面我们将有效的 Jwt Token 中的 payload 解析为 JSON对象 ,方便后续的操作。

5. 配置

我们将 JWT 的可配置项抽出来放入 JwtProperties 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* Jwt 在 springboot application.yml 中的配置文件
*
* @author Felordcn
* @since 15 :06 2019/10/25
*/
@Data
@ConfigurationProperties(prefix=JWT_PREFIX)
public class JwtProperties {
static final String JWT_PREFIX= "jwt.config";
/**
* 是否可用
*/
private boolean enabled;
/**
* jks 路径
*/
private String keyLocation;
/**
* key alias
*/
private String keyAlias;
/**
* key store pass
*/
private String keyPass;
/**
* jwt签发者
**/
private String iss;
/**
* jwt所面向的用户
**/
private String sub;
/**
* access jwt token 有效天数
*/
private int accessExpDays;
/**
* refresh jwt token 有效天数
*/
private int refreshExpDays;
}

然后我们就可以配置JWTjavaConfig如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* JwtConfiguration
*
* @author Felordcn
* @since 16 :54 2019/10/25
*/
@EnableConfigurationProperties(JwtProperties.class)
@ConditionalOnProperty(prefix = "jwt.config",name = "enabled")
@Configuration
public class JwtConfiguration {


/**
* Jwt token storage .
*
* @return the jwt token storage
*/
@Bean
public JwtTokenStorage jwtTokenStorage() {
return new JwtTokenCacheStorage();
}


/**
* Jwt token generator.
*
* @param jwtTokenStorage the jwt token storage
* @param jwtProperties the jwt properties
* @return the jwt token generator
*/
@Bean
public JwtTokenGenerator jwtTokenGenerator(JwtTokenStorage jwtTokenStorage, JwtProperties jwtProperties) {
return new JwtTokenGenerator(jwtTokenStorage, jwtProperties);
}

}

然后你就可以通过 JwtTokenGenerator 编码/解码验证 Jwt Token 对 ,通过 JwtTokenStorage 来处理 Jwt Token 缓存。缓存这里我用了Spring Cache Ehcache 来实现,你也可以切换到 Redis 。相关单元测试参见 DEMO

6. 总结

今天我们利用 spring-security-jwt 手写了一套 JWT 逻辑。无论对你后续结合 Spring Security 还是 Shiro 都十分有借鉴意义。下一篇我们会讲解 JWT 结合Spring Security

本节代码分支在day05

转载自@felord.cn