http无状态协议

客户端和服务器之间是通过http协议进行通信,但http协议是无状态的,不同次请求会话是没有任何关联的。
浏览器与服务器是使用socke套接字进行通信,服务器将请求结果返回给浏览器之后,会关闭当前socket链接,而且服务器也会在处理页面完毕之后销毁页面对象。故http协议是无状态。

1. cookie和session

cookie

当用户首次使用浏览器访问一个支持Cookie网站时,用户提供包括用户名在内个人信息并且提交至服务器;服务器向客户端回传超文本的同时也会发回个人信息,这些信息存放于HTTP响应头(Response Header);当客户端浏览器接收到来自服务器响应后,浏览器将这些信息存放在一个统一的位置。存储在硬盘上的cookie 不可以在不同浏览器间共享。
cookie内容主要包括:名字,值,过期时间,路径和域。路径与域合在一起就构成了cookie的作用范围。
如果不设置过期时间,则表示这个cookie生命期为浏览器会话期间,只要关闭浏览器窗口,cookie就消失了,这种被称为会话cookie。会话cookie一般不存储在硬盘上而是保存在内存。如果设置了过期时间,浏览器会把cookie保存到硬盘上,关闭后再次打开浏览器,这些cookie仍然有效直到超过设定过期时间。
cookie是http状态保持在客户端的解决方案。本地客户端用来存储少量数据信息,很容易获取,安全性不高,存储数据量小。
cookie 是不可跨域的

session

客户端请求服务器,服务器端生成session对象,将session对象存储在jvm内存中,并且在响应头中放入sessionId响应给客户端,客户端收到响应后,将sessionid存储在本地cookie。当浏览器第二次请求时会将本地cookie中存储的seesionId通过请求头的方式传递给服务器,这样服务器和客户端就能保持会话信息。
分布式Session - 图1
分布式Session - 图2

session是一次浏览器和服务器交互的会话。浏览器的关闭并不会导致Session删除,只有当超时、程序调用HttpSession.invalidate()以及服务端程序关闭才会删除。
session是http状态保持在服务端的解决方案。服务器用来存储部分数据信息,不容易获取,安全性高,储存数据量相对大。
一般是通过 Cookie 来保存 SessionID,客户端禁用了Cookie,那么Seesion就无法正常工作。但是没有Cookie的话Session还能用,可以将SessionID放在请求的 url

2.分布式session

为了提高服务器端负载能力,后端一般将服务器节点做集群,通过ngnix轮询方式转发到目标服务器。打个比方,当浏览器首次访问A服务器生成session对象,然后再访问正好被ngnix转发到了A服务器,那么可以获取到session对象,如果不巧请求被转发到B服务器,由于之前生成session对象在A服务器,B服务器根本没有生成session对象,很自然访问不到session对象。

3.实现方式

Cookie 记录 Session

原理是将系统用户Session信息加密、序列化后,以Cookie方式, 统一种植在根域名下(如:.host.com),利用浏览器访问该根域名下所有二级域名站点时,会传递与之域名对应的所有Cookie内容特性,从而实现用户Cookie化Session在多服务间的共享访问。
数据存储在cookie中,无需额外服务器资源;占用一定的带宽资源,受http协议头信息长度限制,仅能够存储小部分用户信息,同时Cookie化的 Session内容需要进行安全加解密,如果一次请求cookie过大,会给网络增加更大的开销。

session绑定

基于nginx的ip-hash策略,可以对客户端和服务器进行绑定,同一个客户端就只能访问该服务器,无论客户端发送多少次请求都被同一个服务器处理。

  1. upstream mycluster{
  2. #这里添加的是上面启动好的两台Tomcat服务器
  3. ip_hash;#Session绑定
  4. server 192.168.22.229:8080 weight=1;
  5. server 192.168.22.230:8080 weight=1;
  6. }

优点:配置简单,不需要对session做任何处理。
缺点:缺乏容错性,如果当前访问的服务器发生故障,用户被转移到第二个服务器上时,session信息都将失效。

session复制

任何一个服务器上session发生改变(增删改),该节点会把这个 session所有内容序列化,然后广播给所有其它节点,不管其他服务器需不需要session,以此来保证Session同步。
1)设置tomcat ,server.xml 开启tomcat集群功能

<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"  
                channelSendOptions="8">  
         <Manager className="org.apache.catalina.ha.session.DeltaManager"  
                  expireSessionsOnShutdown="false"  
                  notifyListenersOnReplication="true"/>  
         <Channel className="org.apache.catalina.tribes.group.GroupChannel">  
           <Membership className="org.apache.catalina.tribes.membership.McastService"  
                       address="228.0.0.4"  
                       port="45564"  
                       frequency="500"  
                       dropTime="3000"/>  
           <Receiver className="org.apache.catalina.tribes.transport.nio.NioReceiver"  
                     address="auto"  
                     port="4000"  
                     autoBind="100"  
                     selectorTimeout="5000"  
                     maxThreads="6"/>  

           <Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">  
           <Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender"/>  
           </Sender>  
           <Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>  
           <Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatch15Interceptor"/>  
         </Channel>  

         <Valve className="org.apache.catalina.ha.tcp.ReplicationValve"  
                filter=""/>  
         <Valve className="org.apache.catalina.ha.session.JvmRouteBinderValve"/>  

         <Deployer className="org.apache.catalina.ha.deploy.FarmWarDeployer"  
                   tempDir="/tmp/war-temp/"  
                   deployDir="/tmp/war-deploy/"  
                   watchDir="/tmp/war-listen/"  
                   watchEnabled="false"/>  

         <ClusterListener className="org.apache.catalina.ha.session.JvmRouteSessionIDBinderListener"/>  
         <ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>  
       </Cluster>

2)在web.xml中开启session复制:<distributable/>
优点:可容错,各个服务器间session能够实时响应。
缺点:对网络负荷造成一定压力,如果session量大可能会造成网络堵塞,影响服务器性能。

容器扩展

基于开源组件Tomcat-redis-session-manager进行处理
配置tomcat配置文件context.xml

 <Valve className="com.orangefunction.tomcat.redissessions.RedisSessionHandlerValve" />        
    <Manager className="com.orangefunction.tomcat.redissessions.RedisSessionManager" 
        host="192.168.159.129"       
        port="6379"                 
        password="123456"            
        database="0"                 
        maxInactiveInterval="60" />

优点:性能高,可实现高并发,水平扩展容易。
缺点:复杂性高,过于依赖容器。

Token

用户输入用户名和密码,发送给服务器。
服务器验证用户名和密码,正确返回一个签名过的token,浏览器客户端拿到这个token。
后续每次请求中,浏览器会把token作为http header发送给服务器,服务器验证签名是否有效,如果有效那么认证就成功,可以返回客户端需要的数据。
一旦用户退出登录,只需要客户端销毁token即可,服务器端不需要任何操作。

Token+Redis

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
  redis:
    host: 192.168.0.113 #ip地址
    port: 6379 #端口
    password: #密码
server:
  port: 8081 #应用web端口
@RequestMapping("/user")
@RestController
public class UserController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @GetMapping("/loginWithToken")
    public String loginWithToken(@RequestParam String username, @RequestParam String password) {
        //账号密码正确
        String key = "token_" + UUID.randomUUID().toString();
        stringRedisTemplate.opsForValue().set(key, username, 3600, TimeUnit.SECONDS);
        return key;
    }

    @GetMapping("/infoWithToken")
    public String infoWithToken(@RequestHeader String token) {
        return "当前登录的是:" + stringRedisTemplate.opsForValue().get(token);
    }

JWT

浏览器第一次访问服务器,根据传过来唯一标识userId,服务端通过一些算法如HMAC-SHA256算法,然后加一个密钥,生成一个token,然后通过BASE64编码一下之后将这个token发送给客户端;客户端将token保存起来,下次请求时,带着token,服务器收到请求后,然后会用相同的算法和密钥去验证token,如果通过,执行业务操作,不通过,返回不通过信息。

分布式Session - 图3

 <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.10.3</version>
 </dependency>
@RequestMapping("/user")
@RestController
public class UserController {

    @GetMapping("/loginWithJwt")
    public String loginWithJwt(@RequestParam String userId, @RequestParam String username,@RequestParam String password) {
        Calendar calendar = Calendar.getInstance();
        Date issueDate = calendar.getTime();
        calendar.add(Calendar.MINUTE, 30);
        Date expireDate = calendar.getTime();
        Algorithm algorithm = Algorithm.HMAC256(Const.JWT_KEY);
        String token = JWT.create()
                .withAudience(userId)// 签发对象
                .withIssuedAt(issueDate)//  发行时间
                .withExpiresAt(expireDate)// 过期时间
                 // 载荷
                .withClaim(Const.USER_NAME, username)
                .withClaim(Const.USERID, userId)
                //签名
                .sign(algorithm);
        return token;
    }

    @GetMapping("/infoWithJwt")
    public String infoWithJwt(@RequestAttribute String USER_NAME) {
        return USER_NAME;
    }
}
public class Const {

    public static final String JWT_KEY = "lymn";//秘钥
    public static final String JWT_TOKEN = "token";
    public static final String USERID = "userid";
    public static final String USER_NAME = "username";
}
@Component
public class LoginIntercepter extends HandlerInterceptorAdapter {

    /**
     * 返回true, 表示不拦截,继续往下执行
     * 返回false/抛出异常,不再往下执行
     * 
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader(Const.JWT_TOKEN);
        if (StringUtils.isEmpty(token)) {
            throw new RuntimeException("token为空");
        }

        Algorithm algorithm = Algorithm.HMAC256(Const.JWT_KEY);
        JWTVerifier verifier = JWT.require(algorithm)
                .build(); //Reusable verifier instance
        try {
            DecodedJWT jwt = verifier.verify(token);
            //获取jwt中登录用户变得数据表,并将数据设置到内存中
            request.setAttribute(Const.USERID, jwt.getClaim(Const.USERID).asString());
            request.setAttribute(Const.USER_NAME, jwt.getClaim(Const.USER_NAME).asString());
        }catch (TokenExpiredException e) {
            //token过期
            throw new RuntimeException("token过期");
        }catch (JWTDecodeException e) {
            //解码失败,token错误
            throw new RuntimeException("解码失败,token错误");
        }
        return true;
    }
}
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private LoginIntercepter loginIntercepter;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(loginIntercepter)
               .addPathPatterns("/user/**")//未登录的都会被拦截
               .excludePathPatterns("/user/loginWithJwt");
    }
}

优点:轻量级,json风格;跨语言;无需服务端存储用户数据,减轻服务端压力
缺点:jwt一旦签发,无法修改;不包含权限控制

session共享机制

使用分布式缓存方案比如memcached、Redis,但是要求Memcached或Redis必须是集群。

分布式Session - 图4

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- Lettuce是一个基于Netty的NIO方式处理Redis的技术-->
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-data-starter-redis</artifactId>
</dependency>
#redis数据库索引(默认是0)
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.port=6379
#默认密码为空
spring.redis.password=
#连接超时时间(毫秒)
spring.redis.timeout=500ms
#连接池最大连接数(负数表示没有限制)
spring.redis.jedis.pool.max-active=1000
#连接池最大阻塞等待时间(负数表示没有限制)
spring.redis.jedis.pool.max-wait=-1ms
#连接池中的最大空闲连接
spring.redis.jedis.pool.max-idle=10
#连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=2
# 会话存储类型,等同于手动添加@EnableRedisHttpSession注释的配置
spring.session.store-type = redis
# 用于存储会话的键的命名空间
spring.session.redis.namespace = spring:session
@RequestMapping("/user")
@RestController
public class UserController {

    @GetMapping("/login")
    public String login(@RequestParam String username,
                        @RequestParam String password,
                        HttpSession session){
        //账号密码正确
        session.setAttribute("login_user", username);
        return "登录成功";
    }

    @GetMapping("/info")
    public String info(HttpSession session) {
        return "当前登录的是:" + session.getAttribute("login_user");
    }

SpringSession 的 Redis 存储结构

每一个session都会创建3组数据。

分布式Session - 图5

set结构,过期时间记录
spring:session:expirations:1515135000000
hash结构,spring-session存储的主要内容
spring:session:sessions:fc454e71-c540-4097-8df2-92f88447063f
String结构,用于ttl过期时间记录
spring:session:sessions:expires:fc454e71-c540-4097-8df2-92f88447063f
更换 SpringSession 的序列化器
SpringSession 中默认的序列化器为 jdk 序列化器,该序列化器效率低下,内存使用大。 根据自己需要更换其他序列化器,如 GenericJackson2JsonRedisSerializer 序列化器。 使用配置类跟换序列器

@Configuration
public class SpringSessionConfig {
    /**
     * 更换序列化器
     * @return
     */
    @Bean("springSessionDefaultRedisSerializer")
    public RedisSerializer setSerializer(){
        return new GenericJackson2JsonRedisSerializer();
    }
}

优点:redis自身可做集群,搭建主从,使用方便;适合并发场景
缺点:多了一次网络调用,web容器需要向redis访问;不适合移动端

4. Token

普通Token

普通令牌是在用户提交账号和密码登陆成功之后,服务器会返回一串令牌的字符串给客户端并将令牌保存在数据库或者缓存中。它唯一标识存贮在数据库或内存中的用户信息。

Acesss Token

  • 访问资源接口(API)时所需要的资源凭证
  • 简单 token 的组成: uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)
  • 特点:
    • 服务端无状态化、可扩展性好
    • 支持移动端设备
    • 安全
    • 支持跨程序调用
  • token 的身份验证流程:

分布式Session - 图6

  1. 客户端使用用户名跟密码请求登录
  2. 服务端收到请求,去验证用户名与密码
  3. 验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端
  4. 客户端收到 token 以后,会把它存储起来,比如放在 cookie 或者 localStorage 里
  5. 客户端每次向服务端请求资源的时候需要带着服务端签发的 token
  6. 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据

登录时 token 不宜保存在 localStorage,被 XSS 攻击时容易泄露,放在Cookie 中,有 CSRF 风险。
每一次请求都需要携带 token,需要把 token 放到 HTTP 的 Header 里
基于 token 的用户认证是一种服务端无状态的认证方式,服务端不用存放 token 数据。用解析 token 的计算时间换取 session 的存储空间,从而减轻服务器的压力,减少频繁的查询数据库
token 完全由应用管理,所以它可以避开同源策略

Refresh Token

refresh token 是专用于刷新 access token 的 token。如果没有 refresh token,也可以刷新 access token,但每次刷新都要用户输入登录用户名与密码。有了 refresh token,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作。

分布式Session - 图7

Access Token 有效期比较短,当 Acesss Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Token,如果 Refresh Token 也失效了,用户就只能重新登录了。
Refresh Token 及过期时间是存储在服务器的数据库中,只有在申请新的 Acesss Token 时才会验证,不会对业务接口响应时间造成影响,也不需要向 Session 一样一直保持在内存中以应对大量的请求。

JWT

Json web token (JWT)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。该token被设计为紧凑且安全,特别适用于分布式站点单点登录场景。
JWT由头部(header)、载荷(payload)、签证(signature) 三部分组成。
header
jwt头部承载两部分信息:

  • 声明类型,这里是jwt
  • 声明加密的算法 通常直接使用 HMAC SHA256
{
  'typ': 'JWT',
  'alg': 'HS256'
}
//将头部进行base64加密(对称加密),构成了第一部分

playload

  • 标准中注册的声明
  • 公共的声明
  • 私有的声明

标准中注册的声明 (建议但不强制使用) :

  • iss: jwt签发者
  • sub: jwt所面向的用户
  • aud: 接收jwt的一方
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间
  • nbf: 定义在什么时间之前,该jwt都是不可用的.
  • iat: jwt的签发时间
  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}
//将头部进行base64加密(对称加密),得到Jwt的第二部分

signature

  • header (base64后的)
  • payload (base64后的)
  • secret

base64加密后的header和base64加密后的payload连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。
当用户希望访问一个受保护的路由或者资源的时候,可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好做法是放在 HTTP 请求头信息的 Authorization 字段里,使用 Bearer 模式添加 JWT。

Authorization: Bearer <token>

跨域的时候,可以把 JWT 放在 POST 请求的数据体里;通过 URL 传输。

注意事项

  • Base64编码方式是可逆的,JWT token中不应该在载荷里面加入任何敏感的数据,比如用户的密码
  • jwt token的过期时间一般不要过长
  • 保护好服务端的secret私钥
  • 为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输

    5. Token存储

token在客户端保存的时候可以考虑sessionStorage、localStorage、Cookie或请求头中
localStorage的生命周期是永久的,除非主动删除数据。
sessionStorage生命周期是在仅在当前会话下有效
在服务端存储时考虑数据库和内存中。

6. Cookie 、 Session、Token

  • 安全性: Session 比 Cookie 安全,Session 是存储在服务器端,Cookie 是存储在客户端。
  • 存取值的类型不同:Cookie 只支持存字符串数据,Session 可以存任意数据类型。
  • 有效期不同: Cookie 可设置为长时间保持,比如我们经常使用默认登录功能,Session 一般失效时间较短。
  • 存储大小不同: 单个 Cookie 保存数据不能超过 4K,Session 可存储数据远高于 Cookie,但是当访问量过多,会占用过多的服务器资源。

Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。而 Token 是令牌访问资源接口(API)时所需要的资源凭证。Token 使服务端无状态化,不会存储会话信息。