后台开发如何区分Http请求的用户,记录登录状态

写在前面:本文主要讲解两种后台开发如何区分Http请求用户的方式,附加部分Java后端代码的实现。如果同学们使用其他语言编写后端,可以阅读完文章后Google其他语言的代码实现,思路大体上是一样的。


(一)引言

1. 为什么需要区分Http请求的用户?

Http是一种无状态的协议,也就是说Http并没有记录连接状态的功能。没有状态的情况下意味着服务器不能确认这一次请求和下一次请求是否来源于同一个客户端。 然而根据我们的常识,用户登录成功,接下来发起的请求应该绑定该用户。比如,添加购物车,每次添加的商品应该都是添加到对应用户的购物车,那么服务器必须识别出这个用户,其实就是用户认证的功能。

最简单的方法就是每次请求都显示地带上用户ID,但这种方式很粗暴,也不安全,容易被伪造。下面介绍两种常见的解决方案。

2. 两种方案
  • session:为每一次会话建立一个session,HTTP头部携带session的ID,根据ID即可区别每一次会话。
  • token:也有解释为令牌的意思。为每一次会话创建一个token(标识符,类似ID),同样将token附在HTTP的头部,根据token验证会话。

两种方法都是在Http头部携带服务器生成的临时标识符,进而达到区分每一个用户,并且临时标识符的安全性远高于直接携带用户信息,难以仿造。

3. 术语
  • 会话:可以理解为一系列的请求,在web应用中,大多数情况下打开浏览器到关闭浏览器的过程就是一次会话。 我们希望在一次会话中,用户始终保存自己的登录状态,并且发送的请求都能被区分。

  • cookie、session:解决HTTP无状态的方案,教程:理解Cookie和Session机制


(二)揭秘 session 应用

在Java程序中,session保存在服务器端,服务器会有专门存放session的内存区域,我们可以将session看作对象,这些对象有ID字段,我们通过ID来获取不同会话的session对象。

以登录为例,我们为登录请求(或者说这一次会话)新建一个session对象。默认情况下,返回给前端的response报文将会自动带上JSESSIONID字段,这个就是session的ID。

在登录请求中,创建了session以后,我们需要确保一个session对应一个用户。做法是:我们将一些必要的用户信息存在session对象里,即调用setAttibute(),这样session对象就能直接和用户绑定在一起了,识别session就相当于识别用户。(这个过程在下面有图说明,还不清楚可以继续往下看~)

1. Java代码

我们主要用到的方法是 getSession(),方法声明为public HttpSession getSession(boolean create)。它用于获取本次会话的session对象,有一个boolean参数。参数可以省略,默认为true,表示如果session不存在,新建一个session对象。如果参数为false,则表示如果session不存在,不需要新建对象,直接返回null。

登录请求的代码:

@GetMapping("\login")
public MyResponse login(HttpServletRequest request, String account, String password) {
	HttpSession session = request.getSession();
	if (match(account, password)){	
	    // 将一些必要的用户信息,如ID存到session里
	    Info info = getUserInfo();
	    session.setAttibute("info",info);
	    
        return new MyResponse("登录成功");
    }    
    return new MyResponse("登录失败");
}

处理添加商品到购物车的请求:

@PostMapping("\shoppingCar")
public MyResponse addGoodToShopingCar(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session == null){
        /* 未登录,重定向到登录页面等操作 */
    }
    // 将保存的信息提取出来,就能知道对应的用户
    Info info = session.getAttribute("info");

    /* 这里可以写一些其他操作,数据库查询等  */
    
    return new MyResponse("添加成功");
}
2. 流程解释

对于web应用来说,只要服务器端调用一次getSession()就会为当前的会话创建session,并且之后的HTTP请求都会自动带上session字段,这与浏览器的cookie机制有关。

如果客户端是Android,就需要手动管理cookie,做法是:在请求报文头部添加session字段,如调用setCookie()这类方法,这个字段一般是JSESSIONID

这个过程可以用下面的图片表示:
在这里插入图片描述
从图中可以看出,第一次登录请求,报文头部还没有相关的session字段,但是服务器自动调用request.getSession()后,返回的报文response带上了Set-Cookie: JSESSIONID 字段。浏览器收到这个头部之后,在下一次添加购物车的请求报文中,使用了这个字段。

3. 有效时间

session存在有效时间,超过设定的时间,session就会失效,tomcat服务器默认是30分钟,时长可以自己重新设置。每一次请求都会重置有效时间,如果每隔几分钟就发起一次请求,就能保证一直不过期。

4. session 结合 Redis 的使用

正如我们上面所讲的,服务器内部开辟一块内存用于 sessoin 的保存,那么这种方式有什么弊端呢?

  1. session 是基于 cookie 传递的,如果用户不支持 cookie 就不起作用。
  2. session 保存在服务器的内存中,随着业务流量的增加,session 占用的内存越来越多,查询效率下降。
  3. session 只能保存在本地服务器,如果是分布式环境,特别是部署了多个系统实例,其他服务器实例也只能从该服务器上获取 sessoin 信息,没有利用到其他实例的处理能力。
  4. 单机保存 session ,容易出现单点故障和性能瓶颈。

所以,在分布式环境下,利用 Java 提供的 session 机制似乎并不适合大型流量网站,只能应付一些小型应用。本质上 session 机制利用的是 key-value 存储方案加上session ID的不可预测性,达到用户认证和保证一定安全性的目的。下面介绍笔者做过的项目中使用 Redis 实现的session 方案。

Redis 是一种高效的内存 key-value 数据库,它为我们实现自定义的 session 提供了一种可用的存储方案。如果你还不是很了解,那么不妨看下这篇文章——超强、超详细 Redis 入门教程

由于我们不再使用 Java 提供的session 生成机制,我们需要自己创建 sessoin ID。session ID 是独一无二的ID,随机串,我们可以使用Java 生成通用唯一识别码的UUID包生成一组随机串(更高级的生成方案会使用到加密,总之,怎么生成是自己决定的)。

UUID的标准型式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的32个字符。示例:
550e8400-e29b-41d4-a716-446655440000

同时,为了使用Redis数据库,我们将使用RedisTemplate完成操作。

Spring封装了RedisTemplate对象来进行对Redis的各种操作,它支持所有的Redis原生的api。RedisTemplate位于spring-data-redis包下。

创建 session ID 并保存相关 sessoin 信息的代码如下:

public String setSession(SessionInfo info){
	RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
	// 创建 session ID
	String sessionId = UUID.randomUUID().toString().replaceAll("-", "");;
	// 第二个参数是session,第三个参数是有效时间,第四个是时间的单位
	template.opsForValue().set(sessionId, info, 3*60*60, TimeUnit.SECONDS); 
	return sessionId;
}

我们需要手动管理 sessoin 的有效时间,Redis 提供的有效时间正好帮助我们解决这个问题。 更新有效时间的代码如下:

/**
 * 调用该方法更新 session 的有效时间
 */ 
public void updateSession(String sessionId){
	RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
	redisTemplate.expire(sessionId, 3*60*60, TimeUnit.SECONDS);
    }

最后,将生成的 sessionId 返回给前端,要求前端每一次请求都将 sessionId 携带在报文头部(保存在 cookie 或者其他 Header,最好是不使用 cookie,避免用户禁用)。

通过将 sessoin 与 web 服务器分离的方式,我们可以提供更多方案来提高 session 的使用效率,提高用户认证这一块的吞吐量,比如使用 Redis 服务器、构建高可用的 Redis 集群、读写分离等等。


(三)JSON Web Token (JWT)

JWT 是实现 token 的一种常用方式。这里推荐一篇比较好的文章:Server端的认证神器——JWT

JSON Web Token(以下简称 JWT)是一套开放的标准(RFC 7519),它定义了一套简洁(compact)且 URL 安全(URL-safe)的方案,以安全地在客户端和服务器之间传输 JSON 格式的信息。

上面的解释有点抽象,带着问题继续往下看。

在上一节的 session 方法中我们在客户端和服务器之间传输一个标识符,并通过匹配服务器本地保存的 session 信息来认证用户。而 token 不需要保存在服务器本地,它可以看作是由服务器创建并授予访问者的一个标识字符串,之后访问者需要在请求时附上 token,服务器解析并认证这个 token。

那么服务器怎么创建和解析这个 token 呢?下面是从推荐文章截取的内容,讲的很清晰。

1. 创建 JWT
// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  // reserved claims
  "iss": "a.com",
  "exp": "1d",
  // public claims
  "http://a.com": true,
  // private claims
  "company": "A",
  "awesome": true
}

// $Signature 签名
HS256 ((Base64(Header) + "." + Base64(Payload)) , secretKey)

// JWT最终形式
JWT = Base64(Header) + "." + Base64(Payload) + "." + $Signature

JWT 是"." 连接的三部分组成:

  1. 经过 Base64 编码的 Header。Header 是一个 JSON 对象,对象里有一个值为 “JWT” 的 typ 属性,以及 alg 属性,值为 HS256,表明最终使用的加密算法是 HS256。
  2. 经过 Base64 编码的 Payload。Payload 被定义为实体的状态,就像 token 自身附加元数据一样,claim 包含我们想要传输的信息,以及用于服务器验证的信息(用户认证信息),一般有 reserved/public/private 三类。(注意到,session的用户信息是保存在服务器本地的,而这里直接放在 token 内)
  3. Signature(签名)。它由 Header 指定的算法 HS256 加密产生。该算法有两个参数,第一个参数是 Header 及 Payload 组成的字符串,第二个参数是生成的密钥,由服务器保存。

什么是Base64可以参考 这里。简单来说是一种编码方式,将原来的二进制形式表示为字符串形式,所以在明文查看时,JWT 实际上就是一些没有逻辑的字符组合。当然 Base64 要解码很简单,毕竟原理都是公开的,所以一般不作为加密手段。

注意:从这里我们可以看出,JWT 仅仅是对 payload 做了简单的 sign 和编码处理,并未被加密,并不能保证数据的安全性,所以建议只在其中保存非敏感的用于身份验证的数据。

*特别是密码之类的信息,Base64 并不是一种加密手段,payload 被截取直接就能被解码。保存个 ID 之类的认证信息,能够识别出发起请求的用户足以

2. 解析JWT

服务器是怎么验证 JWT 是不是自己下发给用户的那一个呢?会不会是伪造的?

我们利用的是签名校验,token 的 Signature 是根据服务器指定的密钥加密的,所以只有相同密钥才能加密出完全一样的签名。我们对 token 的 header、payload 字段重新加密一次,将结果和 token 的签名比较一下,如果两者相同,可以认为该 token 就是服务器分发的,可以信赖,同时 token 的 payload 数据也可以直接使用。反之,这个 token 就是伪造的。

3. Java 实战

可以看出 token 的简单实现不难,只是一些随机生成与加密手段的运用。当然,作为一种常用的单点登录手段,肯定有一些比较好用的开源工具,下面 这篇文章 介绍了JJWT的使用,这里截取一部分内容。注释很清晰,不做转述了。

   public String createJWT(String id, String subject, long ttlMillis) throws Exception {
        SignatureAlgorithm signatureAlgorithm = 
        	SignatureAlgorithm.HS256; // 签名使用的HS256算法
       	long nowMillis = System.currentTimeMillis();//生成JWT的时间
        Date now = new Date(nowMillis);
        
        // 创建payload的私有声明:根据特定的业务需要添加,如果要拿这个做验证,一般是需要和
        // jwt的接收方提前沟通好验证方式
        Map<String,Object> claims = new HashMap<String,Object>();
        claims.put("uid", "DSSFAWDWADAS...");
        claims.put("user_name", "admin");
        claims.put("nick_name","DASDA121");
        
        // 下面是生成签名的时候使用的秘钥secret,这个方法本地封装了的,一般可以从本地配置文
        // 件中读取,切记这个秘钥不能外露哦。它就是你服务端的私钥,在任何场景都不应该流露出
        // 去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
        SecretKey key = generalKey();
        
        //下面就是在为payload添加各种标准声明和私有声明了
        JwtBuilder builder = Jwts.builder()
                .setClaims(claims)
                .setId(id)                  //设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
                .setIssuedAt(now)           //iat: jwt的签发时间
                .setSubject(subject)        //sub(Subject):代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串,可以存放什么userid,roldid之类的,作为什么用户的唯一标志。
                .signWith(signatureAlgorithm, key);// 签名算法和秘钥
        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date exp = new Date(expMillis);
            builder.setExpiration(exp);     //设置过期时间
        }
        return builder.compact(); // 压缩为jwt返回
    }
打印出一条JWT:
eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJEU1NGQVdEV0FEQVMuLi4iLCJzdWIiOiIiLCJ1c2VyX25hbWUiOiJhZG1pbiIsIm5pY2tfbmFtZSI6IkRBU0RBMTIxIiwiZXhwIjoxNTE3ODI4MDE4LCJpYXQiOjE1MTc4Mjc5NTgsImp0aSI6Imp3dCJ9.xjIvBbdPbEMBMurmwW6IzBkS3MPwicbqQa2Y5hjHSyo

下面是该文章作者生成密钥的代码:首先使用 Base64 编码密文,保存在配置文件。使用的时候从配置文件中读取,注意密文需要 Base64 解码。使用解码之后的密文构造密钥。

    public SecretKey generalKey(){
        String stringKey = Constant.JWT_SECRET;//本地配置文件中的密文7786df7fc3a34e26a61c034d5ec8245d
        byte[] encodedKey = Base64.decodeBase64(stringKey);//本地的密码解码[B@152f6e2
        // 根据给定的字节数组使用AES加密算法构造一个密钥,使用encodedKey中
        // 从0开始的前length个字节,其实就是所有元素。
        return key;
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
    }

*注意,每一个 token 的过期时间都是固定的,无法修改,甚至 token 的所有内容都不能修改。因为 token 的内容是加密的,一旦修改签名校验就无法通过了。

这极大地保证了 token的可信度,防止人为恶意修改,毕竟保存在payload的用户认证信息基本和明文没什么区别,人为伪造很方便,所以需要签名确保 token 一旦被修改马上无效。

4. token 和 session 比较
  1. token 不需要占用服务器额外的空间。
  2. 对于分布式系统来说,token 校验可以发生在任意节点上。
  3. token 将许多信息保存在 payload 上,本身存在一定安全隐患,且过分依赖加密手段,存在密钥泄漏的风险。为了防止token盗用,往往需要结合 https 使用。

正文结束,这里是笔者个人的一些主观想法,参考了很多博客,文中的所有转载都已表明出处,欢迎留言讨论。

  • 11
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值