一、引言
在处理登录相关的业务时,常用到Cookie、Session、Token三种机制,但是对于一些完全专注后端业务开发的初学者伙伴,经常混淆这些概念,所以今天带大家从概念到应用来稍稍深入一下,下面是三者的简单对比,大家先简单浏览,后面我们结合当下比较常见的例子说明。
特性 | Cookie | Session | Token |
---|---|---|---|
定义 | 存储在客户端的文本信息,用于标识用户。 | 服务器端存储的用户会话信息。 | 基于令牌的认证机制,通常使用JWT。 |
工作原理 | 用户登录后,服务器生成一个SessionID,并将其存储在Cookie中。浏览器在后续请求中自动将包含SessionID的Cookie发送到服务器。 | 用户登录后,服务器生成一个SessionID,并将用户信息存储在服务器端的Session中。服务器通过Set-Cookie响应头将SessionID发送给客户端。客户端在后续请求中自动将包含SessionID的Cookie发送到服务器。服务器根据SessionID查找对应的Session数据。 | 用户登录后,服务器生成一个Token,并将其返回给客户端。客户端将Token存储在本地(如LocalStorage或Memory Storage)。客户端在每次请求中将Token包含在请求头中发送到服务器。服务器验证Token的有效性。 |
优点 | 简单易用,浏览器自动管理。 | 安全性较高,数据存储在服务器端。 | 无状态,适合分布式系统,安全性较高。 |
缺点 | 容易受到XSS攻击,数据量有限。 | 服务器端存储压力大,不适合分布式系统。 | 需要客户端手动管理Token,实现复杂。 |
适用场景 | 简单的Web应用,需要服务器端管理Session。 | 需要高安全性且用户量不大的应用。 | 分布式系统和需要高安全性的应用。 |
二、cookie+session的验证
1.分析
大家可以看到引言部分我们将cookie和session两者的工作原理结合在一起阐述了,那为什么是cookie+session呢?首先,cookie和session是可以单独完成校验的(很少不一起用,这里只是单纯为了说明能单独用而说明,了解即可不必纠结),但单独使用会存在如下的细些问题:
-
单独使用cookie即直接将校验信息存储在客户端cookie中,但其大小通常限制为4KB以内,存储的信息量就很局限,并且直接将信息存储在cookie中,前端遭受攻击攻击会导致信息泄露;所以其只适用于简单的应用场景,如记住用户偏好设置、简单的访问统计等。
-
单独使用session即将信息存储在服务器中,将sessionid返回给客户端,客户端可以通过 URL参数或者存储sessionid到LocalStorage、SessionStorage等方式在请求中携带id匹配服务端的session,但这样的方式不仅增加了开发复杂性,适应场景也被限制。
而cookie+session,在用户登录后,服务器会生成一个SessionID且自动存储到cookie,并将用户信息存储在服务器端的Session中。客户端在后续请求中自动将包含SessionID的Cookie发送到服务器,服务器根据SessionID查找对应的Session数据。
这样一来,cookie只用存储较小的sessionid,并且敏感信息不会直接在前端存储泄露,且session被cookie自动管理起来,开发的复杂性也大大降低。
2.应用
现在我们通过一个简单的cookie+session登陆业务观察一下,拦截器、配置文件等就不一一放在文章里了,大家可以自己写一下简单的demo测试
-
接口层
@PostMapping("/login") public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){ // 实现登录功能 userService.sessionLogIn(loginForm,session); return Result.ok(); }
-
Sercive层
@Override public void sendCode(String phone, HttpSession session) { //校验手机号 if (RegexUtils.isPhoneInvalid(phone)) { //不符合返回错误信息 throw new PhoneNumberEcpection("手机号格式错误!"); } //符合生成验证码 String code = RandomUtil.randomNumbers(6); //保存验证码到对应手机号的session session.setAttribute(phone, code); //发送验证码 log.debug("发送验证码成功:{}",code); } @Override public void sessionLogIn(LoginFormDTO loginForm, HttpSession session) { //校验手机号和验证码 String phone = loginForm.getPhone(); if(StrUtil.isBlank(phone)){ throw new PhoneNumberEcpection("手机号格式错误!"); } Object cacheCode = session.getAttribute(phone); if (cacheCode == null) { throw new PhoneNumberEcpection("手机号不符!"); } String postCode = loginForm.getCode(); if (postCode==null || !postCode.equals(cacheCode)){ throw new BaseException("验证码错误!"); } //查询用户是否存在 User user = query().eq("phone", phone).one(); if (user==null){ user=createUser(phone); } UserDTO userDTO = new UserDTO(); BeanUtils.copyProperties(user, userDTO); //保存用户信息到session中 session.setAttribute("user", userDTO); }
-
测试
我们打断点debug,在前端页面F12打开应用程序,可以看到cookie出现了JSESSIONID,并且有几个比较特殊的属性需要注意:
expires
属性为“会话”,说明我们的cookie和session是会话级别的,表示该Cookie将在浏览器会话结束时过期,这意味着当用户关闭浏览器时,该Cookie会被自动删除;
HttpOnly
属性是一个布尔值,当设置为true
时,表示该Cookie只能通过HTTP协议访问,无法通过客户端脚本(如JavaScript)访问,防止XSS(跨站脚本攻击)攻击者通过JavaScript窃取Cookie中的信息,从而提高安全性,但在面对一些钓鱼攻击,表单劫持时还是会出现泄露的情况;
SameSite
属性用于控制浏览器是否以及如何在跨站请求中发送Cookie,从而提供针对CSRF攻击的保护(跨站请求伪造),这里我们没有设置属性值则默认为所有请求都会发送cookie,这也就是CSRF风险残留问题。
关于XSS和CSRF我们在JWT令牌的应用部分再深入介绍。
在服务端可以看到JSESSIONID是服务器自动生成的sessionid
然后放行可以看到我们的信息已经全部添加到session并存储与服务器中,而随后的需要权限校验的路由将通过cookie中的JSESSIONID值去服务器进行匹配
最后我们关闭历览器再打开前端页面,发现JSESSIONID已经消失,证明确实是会话级别的
三、JWT令牌校验
1.分析
cookie+session虽然简单易用,安全性也不错,但是仍存在局限性,一方面session自身并不适合集群下的分布式系统(见下图),另一方面在如今大前端的环境下往往需要支持浏览器、小程序、app等等,而cookie+session主要针对浏览器这种能发送和接收HTTP请求的客户端。
如图所示当部署了tomcat集群时,nginx通过负载均衡策略将请求发送到不同服务器,假设处理登录请求的为tomcat1那么session信息会存储在该服务器,当请求到达另外两台tomcat则无法获取到session,虽然tomcat提供了session复制功能但所有服务器复制session,网络传输延迟高、内存占用大,并没有得到广泛认可。
为了解决分布式系统问题,Token就是一个可用的方法,其具有无状态的特性,,具体工作原理是用户登录后,服务器生成一个Token,并将其返回给客户端。客户端将Token存储在本地,在每次请求中携带Token,服务器根据一定规则解析校验,这样就不用在每台服务器进行存储,天然适合分布式系统。
2.应用
这里我们拿应用比较多的JWT(JSON Web Token)作为示例进行演示。
-
扩展
如上文所说,token会被存储在客户端本地,并在请求中携带,但具体怎么存储,由谁携带呢?一般的我们会根据需求直接存储于local storage 或者 session storage中,并由请求头中自带的Authorization属性携带,有时也会自定义请求头属性(如token)携带。
但这并不是唯一的方式,首先直接将令牌存在storage中可能遭到XSS攻击,简单介绍下攻击场景,类似于sql注入,攻击方将一段<script>获取token</script>的js脚本放入评论区或者其他地方,在用户刷新该界面后就会获取到用户的token来伪造用户行为。
那我们可以回想到上文提到cookie设置httponly属性为true后就不能通过script获取其内容了,那么可不可以将cookie存入cookie呢?答案是可以,这样能一定程度上防止XSS攻击,但是并不能杜绝XSS的一些其他行为,比如篡改页面信息,所以我们往往还会对页面内容进行过滤,不让攻击者的<script>标签生效。
那既然存到cookie里了能不能直接让cookie携带token每次请求自动发送就好了,多方便?这就又要提到CSRF攻击了,简单引入一个场景,当用户处于登陆状态,这时候一个攻击者构造了一个点击抽奖的诈骗链接https://bank.com/transfer?amount=1000&to=attacker_account被用户点击了,此时自动cookie会随请求自动发送,token被自动带去校验。虽然我们上文提到了samesite属性能限制cookie自动发送的情形,但它在面对某些特定的请求方式(如超链接访问、form表单提交到新页面等)仍然可能绕过SameSite的限制。而每次在请求头中前端逻辑手动添加token的方式有效减小了CSRF伪造用户请求的风险。
所以在一些安全性要求较高的场景,我们可能会使用cookie存储(合理配置HttpOnly、Secure、SameSite属性
),请求头携带的方式(一般使用标准化的Authorization属性)完成校验,这样攻击者既无法用XSS从cookie获取token,也无法用CSRF伪造自动携带token的请求。
-
JwtUtil
这里提供一个别人写好的工具类,可自己根据需求修改
import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.Map; public class JwtUtil { /** * 生成jwt * 使用Hs256算法, 私匙使用固定秘钥 * * @param secretKey jwt秘钥 * @param ttlMillis jwt过期时间(毫秒) * @param claims 设置的信息 * @return */ public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) { // 指定签名的时候使用的签名算法,也就是header那部分 SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 生成JWT的时间 long expMillis = System.currentTimeMillis() + ttlMillis; Date exp = new Date(expMillis); // 设置jwt的body JwtBuilder builder = Jwts.builder() // 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的 .setClaims(claims) // 设置签名使用的签名算法和签名使用的秘钥 .signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8)) // 设置过期时间 .setExpiration(exp); return builder.compact(); } /** * Token解密 * * @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个 * @param token 加密后的token * @return */ public static Claims parseJWT(String secretKey, String token) { // 得到DefaultJwtParser Claims claims = Jwts.parser() // 设置签名的秘钥 .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) // 设置需要解析的jwt .parseClaimsJws(token).getBody(); return claims; } }
接下来我们来尝试在cookie中存储token再通过请求头携带自定义token属性的方式完成校验,需要注意的是如果我们在生产环境中用这种方式必须配置上文介绍的HttpOnly、Secure、SameSite属性
来增加安全性,这里为了省事我就不配置了。
-
Controller
@PostMapping("/login") @ApiOperation(value = "员工登录") public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) { log.info("员工登录:{}", employeeLoginDTO); Employee employee = employeeService.login(employeeLoginDTO); //登录成功后,生成jwt令牌 Map<String, Object> claims = new HashMap<>(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token = JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims); EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder() .id(employee.getId()) .userName(employee.getUsername()) .name(employee.getName()) .token(token) .build(); //VO中返回token return Result.success(employeeLoginVO); }
-
Service
/** * 员工登录 * * @param employeeLoginDTO * @return */ public Employee login(EmployeeLoginDTO employeeLoginDTO) { String username = employeeLoginDTO.getUsername(); String password = employeeLoginDTO.getPassword(); //1、根据用户名查询数据库中的数据 Employee employee = employeeMapper.getByUsername(username); //2、处理各种异常情况(用户名不存在、密码不对、账号被锁定) if (employee == null) { //账号不存在 throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND); } //密码比对 password=DigestUtils.md5DigestAsHex(password.getBytes()); if (!password.equals(employee.getPassword())) { //密码错误 throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR); } if (employee.getStatus() == StatusConstant.DISABLE) { //账号被锁定 throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED); } //3、返回实体对象 return employee; }
-
测试
同上文测试方式一样,可以看到我们成功在cookie中储存了token,注意我标红的三个属性,应用时用来降低XSS、CSRF风险,并限制https
我们可以看到请求头中已经携带了我们自定义的Token,但是因为我们没做samesite属性限制,cookie同样自动发送了,这就是CSFR风险残留问题
四、 Redis+token
1.分析
最后我们介绍另一种在分布式系统中代替session的校验方式,码字码的有点累了后面我就简单说了,还记得上面那个tomcat集群示意图吧,在多台tomcat间复制session性能较差,但是我们可以在独立于tomcat集群的redis中进行token存储,工作原理就是服务器生成token存储于redis并返回客户端,客户端通过请求头携带到服务器,服务器从redis获取token进行校验。
这次存储呢我们就演示一下Storage,顺带再提一下存在localstorage和sessionstorage的区别,将Token存储在sessionStorage
中,关闭标签页或浏览器后,Token会失效,这是因为sessionStorage
的数据仅在会话期间有效,关闭标签页或浏览器后数据会被清除,而localstorage中的token关闭浏览器并不会被清楚,一般会在服务器设置的过期时间后失效,根据这些特性我们也能做一些其他发挥。
2.应用
-
Controller
/** * 登录功能 * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码 */ @PostMapping("/login") public Result login(@RequestBody LoginFormDTO loginForm){ // 实现登录功能 String token=userService.logIn(loginForm); return Result.ok(token); }
-
Service
拦截器校验和token刷新大家自己写一写,这里我就不一点点粘了@Override public void sendCode(String phone) { //校验手机号 if (RegexUtils.isPhoneInvalid(phone)) { //不符合返回错误信息 throw new PhoneNumberEcpection("手机号格式错误!"); } //符合生成验证码 String code = RandomUtil.randomNumbers(6); //保存验证码到redis stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY +phone,code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES); //发送验证码 log.debug("发送验证码成功:{}",code); } @Override public String logIn(LoginFormDTO loginForm) { //校验手机号和验证码 String phone = loginForm.getPhone(); if(StrUtil.isBlank(phone)){ throw new PhoneNumberEcpection("手机号格式错误!"); } String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone); String postCode = loginForm.getCode(); if (postCode==null || !postCode.equals(cacheCode)){ throw new BaseException("验证码错误!"); } //查询用户是否存在 User user = query().eq("phone", phone).one(); if (user==null){ user=createUser(phone); } UserDTO userDTO = new UserDTO(); BeanUtils.copyProperties(user, userDTO); HashMap<String, String> userMap = new HashMap<>(); userMap.put("nickName",userDTO.getNickName()); userMap.put("icon",userDTO.getIcon()); userMap.put("id",userDTO.getId().toString()); //保存用户信息到redis String token = UUID.randomUUID().toString(); String tokenKey = RedisConstants.LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey,userMap); stringRedisTemplate.expire(tokenKey,60,TimeUnit.MINUTES); return token; }
-
测试
同样的测试方式,我们登录,可以看到前端将返回的token存储在sessionstorage
服务器将token存到了redis
最后补充一下,文章里我并没有做分布式的负载均衡测试,并不复杂,大家可以自行尝试,本篇文章到这里就正式结束了,如果存在问题,欢迎大家留言指正,感谢阅读!