说明
对于部分应用,尤其是手机应用,是不支持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
|
@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(); }
@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.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() .apply(new JsonLoginAuthenticationFilterConfigurer<>()) .loginProcessingUrl(authProperties.getLoginEndpoint()) .usernameParameter("username") .passwordParameter("password") .successHandler(authenticationSuccessFailHandler()) .failureHandler(authenticationSuccessFailHandler()) .and() .cors().configurationSource(configurationSource()) .and() .csrf().disable() ; log.info("登录信息存储方式: token"); http.apply(new TokenManagementConfigurer<>()) .removeSessionConfig() .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
| log.info("登录信息存储方式: token"); http.apply(new TokenManagementConfigurer<>()) .removeSessionConfig() .tokenStore(new MemoryTokenStore<>(authProperties.getStore().getExpireTime())) .keyGenerator(new DefaultTokenKeyGenerator()) .permitUrlContainer(permitUrlContainer) .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
| 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() { getBuilder().removeConfigurer(SessionManagementConfigurer.class); 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();
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
|
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; } 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; } @Override public String getRequestedSessionId() { if (!isToken){ return super.getRequestedSessionId(); } if (StringUtils.hasText(getToken())) { return getToken(); } else { return super.getRequestedSessionId(); } }
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); }
@Override public HttpSession getSession(boolean create) { String token = getToken(); 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 = 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().none() .maximumSessions(2) .maxSessionsPreventsLogin(true).and() .and() .csrf().disable() .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是否会在预料之外发生改变。