说明

对于部分应用,尤其是手机应用,是不支持cookie的,因此原本基于session的用户凭证保存就不适用了,需要设计token的方案
但是大部分框架都支持的session,例如spring-security,spring-mvc 等、
本文讨论token方案的几种设定

实现方案

方案一:抛弃session,直接新建一套token流程

思路

  • 用户登录的时候生成一个token,服务端保存下token及用户信息,并将token返给前端,完成登录
  • 用户后续所有的请求带上token,将其设置在header请求头中的Authorization字段中
  • 服务端收到解析token,获取用户信息用以确定用户登录,存入用户上下文,应用中也就可以拿到了

实现

本实现采用spring-security来进行改造,毕竟spring-security是专业的安全框架, 用它来更具有实用价值。、
如果不用spring-security的话用sping-mvc的拦截器来实现也是可以的,但是要保证拦截器的顺序。

引入spring-security依赖

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

配置

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

/**
* @author kewen
* @descrpition 安全配置
* @since 2023-02-28
*/
@Slf4j
@Configuration
@ConditionalOnClass(EnableWebSecurity.class)
public class SecurityAuthConfig extends WebSecurityConfigurerAdapter {

@Autowired
AuthProperties authProperties;

@Autowired
PermitUrlContainer permitUrlContainer;


public SecurityAuthConfig() {
log.info("使用SpringSecurity作为安全框架");
}

@Bean
public SecurityUserDetailService authUserDetailService(){
return new SecurityUserDetailService();
}

/**
* 加入监听器,session销毁时才会触发 spring容器的感知,否则 security监听不到销毁
* @return
*/
@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}


@Bean
SecurityUserContextContainer securityUserContextContainer(){
return new SecurityUserContextContainer();
}

@Bean
AuthenticationSuccessFailureHandler authenticationSuccessFailHandler(){
return new AuthenticationSuccessFailureHandler();
}


@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//auth.inMemoryAuthentication().withUser("user").password("123456").authorities("R_0","R_1");
auth.userDetailsService(authUserDetailService());
}

@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}

@Override
protected void configure(HttpSecurity http) throws Exception {

http
.authorizeRequests()
.antMatchers(permitUrlContainer.getPermitUrls()).permitAll()
.antMatchers().permitAll()
.anyRequest().authenticated()
.and()
//.addFilterAt(loginFilter(),UsernamePasswordAuthenticationFilter.class)
//.formLogin().and()
.apply(new JsonLoginAuthenticationFilterConfigurer<>()) //采用新建配置类的方式可以使得原来config中配置的对象依然有效
.loginProcessingUrl(authProperties.getLoginEndpoint())
.usernameParameter("username")
.passwordParameter("password")
.successHandler(authenticationSuccessFailHandler())
.failureHandler(authenticationSuccessFailHandler())
.and()
.cors().configurationSource(configurationSource()) //允许跨域
//.disable()
.and()
.csrf().disable()
;
//基于token的用户信息存储
log.info("登录信息存储方式: token");
http.apply(new TokenManagementConfigurer<>())
//.tokenAuthenticationStrategy()
.removeSessionConfig() //移除session的配置
.tokenStore(new MemoryTokenStore<>(authProperties.getStore().getExpireTime()))
.keyGenerator(new DefaultTokenKeyGenerator())
.permitUrlContainer(permitUrlContainer)
.authenticationFailureHandler(authenticationSuccessFailHandler());
}
CorsConfigurationSource configurationSource(){
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");

UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();
corsConfigurationSource.registerCorsConfiguration("/**",corsConfiguration);
return corsConfigurationSource;
}
private void writeResponseBody(HttpServletResponse response,Result result) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(result));
out.flush();
}
}

主要看token的配置这里

1
2
3
4
5
6
7
8
9
//基于token的用户信息存储
log.info("登录信息存储方式: token");
http.apply(new TokenManagementConfigurer<>()) //配置manager
//.tokenAuthenticationStrategy()
.removeSessionConfig() //移除session的配置
.tokenStore(new MemoryTokenStore<>(authProperties.getStore().getExpireTime())) //token存储的地方,是内存或者是redis,内存还可以通过静态的ConcurrentHashMap或guava的Cache来实现
.keyGenerator(new DefaultTokenKeyGenerator()) //token生成器,一般就是采用uuid了,也可以自定义,和存储的抽象分离是比较好的
.permitUrlContainer(permitUrlContainer) //允许的url,未经登录也允许使用的
.authenticationFailureHandler(authenticationSuccessFailHandler());

对于TokenManagementConfigurer,主要配置token的一些实现方式,如token的存储,token的生成等,
采用Configurer的方式主要是因为 security的过滤器里面会共享一些配置的bean,而使用Configurer可以拿到这些共享的bean,如果直接单独定义一个bean的话,内部引用的属性对象就变了,势必需要重新定义其他地方所有用到这些对应的地方,会修改很多,如 UsernamePasswordAuthenticationFilter中的sessionRegister就在SessionManagementFitler中用到等。

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
// extends AbstractHttpConfigurer<SessionManagementConfigurer<H>, H> 这一串有点复杂,需要好好理解一下
public class TokenManagementConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractHttpConfigurer<SessionManagementConfigurer<H>, H> {

TokenAuthenticationStrategy tokenAuthenticationStrategy = new TokenAuthenticationStrategy();
TokenStore<Authentication> tokenStore ;
TokenKeyGenerator tokenKeyGenerator = new DefaultTokenKeyGenerator();
AuthenticationFailureHandler authenticationFailureHandler;
PermitUrlContainer permitUrlContainer;
TokenFilter tokenFilter;
private boolean isRemoveSession = false;

public TokenManagementConfigurer() {
this.tokenFilter = new TokenFilter();
}

public TokenManagementConfigurer<H> tokenAuthenticationStrategy(TokenAuthenticationStrategy tokenAuthenticationStrategy) {
this.tokenAuthenticationStrategy=tokenAuthenticationStrategy;
return this;
}

public TokenManagementConfigurer<H> removeSessionConfig() {
//移除掉session的,因为不需要session了
getBuilder().removeConfigurer(SessionManagementConfigurer.class);
//这里就是取得公共的bean了
//需要修改SecurityContextRepository,否则SessionManagementConfigurer 默认将 HttpSessionSecurityContextRepository 加入到SharedObject中
getBuilder().setSharedObject(SecurityContextRepository.class, new TokenSecurityContextRepository());
isRemoveSession = true;
return this;
}

public TokenManagementConfigurer<H> tokenStore(TokenStore<Authentication> tokenStore) {
this.tokenStore = tokenStore;
return this;
}

public TokenManagementConfigurer<H> keyGenerator(TokenKeyGenerator tokenKeyGenerator) {
this.tokenKeyGenerator = tokenKeyGenerator;
return this;
}

public TokenManagementConfigurer<H> authenticationFailureHandler(AuthenticationFailureHandler failureHandler) {
this.authenticationFailureHandler = failureHandler;
return this;
}
public TokenManagementConfigurer<H> permitUrlContainer(PermitUrlContainer permitUrlContainer) {
this.permitUrlContainer = permitUrlContainer;
return this;
}

@Override
public void init(H http) throws Exception {

if (!isRemoveSession) {
removeSessionConfig();
}
http.setSharedObject(SessionAuthenticationStrategy.class, this.tokenAuthenticationStrategy);

}

@Override
public void configure(H http) throws Exception {
tokenAuthenticationStrategy.setStore(tokenStore);
tokenAuthenticationStrategy.setKeyGenerator(tokenKeyGenerator);
tokenFilter.setTokenAuthenticationStrategy(tokenAuthenticationStrategy);
tokenFilter.setPermitUrlContainer(permitUrlContainer);
tokenFilter.setFailureHandler(authenticationFailureHandler);
tokenFilter = postProcess(tokenFilter);
tokenFilter.setFailureHandler(authenticationFailureHandler);
//这里添加到过滤器链中
http.addFilterAfter(tokenFilter, SessionManagementFilter.class);
}
}

首先一个比较重要的类了TokenFilter,这个类会替换掉原来sessionManagementFilter,用token代替session实现,而且这里需要注意TokenFilter注册的位置,

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
public class TokenFilter extends GenericFilterBean {

private TokenAuthenticationStrategy tokenAuthenticationStrategy;
private AuthenticationFailureHandler failureHandler;
private PermitUrlContainer permitUrlContainer;
private final AntPathMatcher pathMatcher = new AntPathMatcher();

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

doFilter((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, filterChain);
}

public void doFilter(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws IOException, ServletException {
String uri = httpServletRequest.getRequestURI();

//这里就是从Header中拿到totoken并解析出用户信息
Authentication token = tokenAuthenticationStrategy.getToken(httpServletRequest);

if (token == null) {
if (!isMatches(uri)) {
failureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, new SessionAuthenticationException("尚未登录或登录已过期,请重新登录"));
return;
}
} else {
SecurityContextHolder.getContext().setAuthentication(token);
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}

private boolean isMatches(String uri) {
String[] permitUrls = permitUrlContainer.getPermitUrls();

for (String permitUrl : permitUrls) {
if (pathMatcher.match(permitUrl, uri)) {
return true;
}
}
return false;
}


public void setTokenAuthenticationStrategy(TokenAuthenticationStrategy tokenAuthenticationStrategy) {
this.tokenAuthenticationStrategy = tokenAuthenticationStrategy;
}

public void setFailureHandler(AuthenticationFailureHandler failureHandler) {
this.failureHandler = failureHandler;
}

public void setPermitUrlContainer(PermitUrlContainer permitUrlContainer) {
this.permitUrlContainer = permitUrlContainer;
}
}

然后是其内部的替换session的相关

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
/* 这个类主要是认证session的,实现了SessionAuthenticationStrategy接口 
void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) throws SessionAuthenticationException;

*/
public class TokenAuthenticationStrategy implements SessionAuthenticationStrategy {
private TokenKeyGenerator keyGenerator;
private TokenStore<Authentication> store;

public Authentication getToken(HttpServletRequest httpServletRequest){

String token = httpServletRequest.getHeader("Authorization");
if (token ==null){
return null;
}
token = token.substring("Bearer ".length());
return store.get(token);
}

@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
String token = keyGenerator.generateKey();
SecurityUser user = (SecurityUser) authentication.getPrincipal();
user.setToken(token);

store.set(token,authentication);

}

public void setKeyGenerator(TokenKeyGenerator keyGenerator) {
this.keyGenerator = keyGenerator;
}

public void setStore(TokenStore<Authentication> store) {
this.store = store;
}
}

对比RegisterSessionAuthenticationStrategy(如下)来看,RegisterSessionAuthenticationStrategy主要就是注册一个新的session,所以SessionAuthenticationStrategy就是创建并保存session的逻辑,对于token而言就是保存token了

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RegisterSessionAuthenticationStrategy implements
SessionAuthenticationStrategy {
private final SessionRegistry sessionRegistry;
public RegisterSessionAuthenticationStrategy(SessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
public void onAuthentication(Authentication authentication,
HttpServletRequest request, HttpServletResponse response) {
sessionRegistry.registerNewSession(request.getSession().getId(),
authentication.getPrincipal());
}
}

然后是AuthenticationFailureHandler,这个就是security处理失败后的钩子函数,一般就是返回登录报错等,不然默认会跳转/error页面,这些跟token本身没啥关系

这里还有一个问题,前面都是token的校验和获取,那登录时是怎么将token保存到TokenStore中的呢
其实我们也有调整登录的相关内容,主要就是将AbstractAuthenticationProcessingFilter这个登录类中的SessionAuthenticationStrategy替换掉,用成我们前面准备好的TokenAuthenticationStrategy,当登录成功之后AbstractAuthenticationProcessingFilter会自动调用sessionStrategy.onAuthentication()方法,如下

1
2
3
4
5
6
7
authResult = attemptAuthentication(request, response);
if (authResult == null) {
// return immediately as subclass has indicated that it hasn't completed
// authentication
return;
}
sessionStrategy.onAuthentication(authResult, request, response);

这样,就把登录信息存下来了。并且,这里生成token之后会设置到原来的UserDetails中,返回给前端。

大概来说就是这样的方式。

方案二: 保留session,在通过token获取session的时候做处理

根据经验来看,使用token的方案整体改动还是比较多的,但好在与session完全分离,并且自己实现token过期策略和存储等。
而session改造则保留服务器中session,只是sessionId保存的地方不再是cookie中了,可以返回给前端,前端请求的时候带上请求头中,后台根据id来匹配即可,好处是同时兼容原cookie方案和不支持cookie的手机方案,改造量小,但是改造位置需要前置,比较深入,容易引发未知问题。

其实,将session前置的这想法还是来源于session与cookie的关系。既然session的sessionId是存在cookie中的,然后用户内容是存在服务器上的,那么其本质也是Key(String)-Value(Object)的形式,只是这个转换在某个地方自动完成了而已,那么可以猜想,只要我们能找到将sessionid从cookie中取出来并获取session中的数据的地方,也就有可能实现修改session的逻辑,使之与cookie分离。当然,还需要找到session生成sessionId并设置到cookie的地方。

经过不懈的努力,在tomcat中发现了将sessionId转换为session的身影,这还真是靠前啊,不过也合理,毕竟tomcat与session是深度绑定的。但是我们不能去改Tomcat啊,因此就只能在Tomcat之后考虑,也就是在应用通过HttpRequestServlet#getRequestedSessionId()考虑,通过包装一层请求来完成替换,因此有了下面这个类

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
139
140
141
142
143
144
public class TokenSessionHttpServletRequest extends HttpServletRequestWrapper {

private static final ThreadLocal<String> tokenThreadLocal = new ThreadLocal<>();

private static final ConcurrentHashMap<String, HttpSession> tokenMap = new ConcurrentHashMap<>();

private final boolean isToken;

public TokenSessionHttpServletRequest(HttpServletRequest request) {
super(request);
String token = request.getHeader("token");
tokenThreadLocal.set(token);
isToken = token != null;
}
//获取请求中的sessionId,这里修改成有token就获取token中的id,token是在getSession创建session的时候就保存在了ThreadLocal中
@Override
public String getRequestedSessionId() {
if (!isToken){
return super.getRequestedSessionId();
}
if (StringUtils.hasText(getToken())) {
return getToken();
} else {
return super.getRequestedSessionId();
}
}

/**
* 没有token,说明没有从前端传入,可能是登录请求
* @return
*/
private String getToken() {
return tokenThreadLocal.get();
}

@Override
public HttpSession getSession() {
return getSession(true);
}

@Override
public Object getAttribute(String name) {
return super.getAttribute(name);
}

@Override
public void setAttribute(String name, Object o) {
super.setAttribute(name, o);
}

/*
创建session,这里覆盖了原来创建的流程,创建了之后又保存到ThreadLocal中,
*/
@Override
public HttpSession getSession(boolean create) {
String token = getToken();
//没有token则生成一个
if (token == null) {
HttpSession session = super.getSession(create);
if (session != null){
token = session.getId();
tokenThreadLocal.set(token);
tokenMap.put(token,session);
}
return session;
}

HttpSession session = tokenMap.get(getToken());
if (session != null){

return session;
}

//不创建则就直接返回了
if (!create) {
return session;
}
//创建则创建session,并加入到threadLocal中
session = super.getSession(true);
token = session.getId();

tokenMap.put(token,session);
tokenThreadLocal.set(token);

return session;
}
}
``

创建session这里,其实也是有妥协的概念,毕竟这里不能够将token带出去,只能通过临时的对象来了,使用Threadlocal也刚好适用
如果token有,则直接到tokenMap中去拿,这里可以看出来实际上有两处保存了session了已经,原来的Tomcat中保存了一份,但因为不方便操作,因此这里又保存了一份,但这里和Tomcat中保存的实际上的引用地址都是一致的,都是同一个session对象,因此不会有问题。
有一个可能出现的问题,就是有没可能Tomcat中的session换了引用(即重新生成一个),而这里没有换掉这种情况。虽然我自己分析了应该创建session都会从这里进来,但毕竟对tomcat这块不是很熟,还得要经过验证

security的配置如下

```java
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().anyRequest().authenticated().and()
.formLogin()
.successHandler((request, response, authentication) -> {
Object principal = authentication.getPrincipal();
if (principal instanceof AuthUser){
AuthUser user = (AuthUser) principal;
user.setToken(request.getSession().getId());
}
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(principal));
writer.flush();
writer.close();
}).failureHandler((request, response, e) -> {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(e.getMessage()));
writer.flush();
writer.close();
})
.and()
.exceptionHandling().accessDeniedHandler((request, response, e) -> {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(e.getMessage()));
writer.flush();
writer.close();
}).and()
.sessionManagement()
//.sessionFixation().changeSessionId() //这里改为none则不会出现postman连续登录会报session过多
.sessionFixation().none() //这里改为none则不会出现postman连续登录会报session过多
.maximumSessions(2)
.maxSessionsPreventsLogin(true).and()
.and()
.csrf().disable()
//主要看这里,这里将TokenSessionFilter加入过滤器链中,提供修改的入口。TokenSessionFilter,主要也是来包装HttpRequestServlet,配合下面代码TokenSessionFilter看
//注意要放在前面一点,我这里
.addFilterBefore(new TokenSessionFilter(), WebAsyncManagerIntegrationFilter.class)

;
}

}
1
2
3
4
5
6
7
8
9
10
public class TokenSessionFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
TokenSessionHttpServletRequest tokenSessionHttpServletRequest = new TokenSessionHttpServletRequest(request);

filterChain.doFilter(tokenSessionHttpServletRequest, response);
}
}

比较

方案二的优势在于改动量较小,可以直接兼容原本的session方案和header头中传token的方案,但是方案二也许有其他的问题,比如session在什么时候会刷新呢,什么时候会清空重新生成,我自己也没有过多的验证,需要进一步的实践看session是否会在预料之外发生改变。