登录模块总结

引言:之前做项目时,做了登录模块;当时的理解不是很深刻,所以这里特地重新翻出来,重新消化理解;本篇内容来源于我和雄哥讨论交流总结得到的结果。

登录模块总结

技术选型

项目架构:SpringBoot+ Mybatis

数据库:mysql

缓存:Redis

连接Redis:RedisTemplate

登录验证原理:Redis+ token

token生成:UUID随机生成32位的串

用户密码加密:MD5 + salt

用户ID生成:雪花算法

登录相关逻辑

  • 验证码接口/verify

    1. 生成验证码(算法:随机获得字母、数字的4位混合串)
    2. 存入Redis中:(k-v分别为sessionID : verifyCode
    3. 将验证码通过response.getOutputStream写回验证码图片
  • 登录接口/login

    1
    2
    3
    4
    5
    6
    //前端传来:用户名、密码、验证码
    public class UserLoginForm {
    private String username;
    private String password;
    private String verifyCode;
    }
    1. 验证码校验:通过其sessionIdredis取对应的verifyCode值,校验用户输入的验证码是否正确
    2. 用户名是否存在:查库,检查用户名是否存在(如果存在,此时可以获得其ID、加密后的密码、盐值等等数据)
    3. 密码输入是否正确:将用户输入的密码用对应用户的盐值进行MD5加密,此结果再与库中存放的加密后的密码进行校验
    4. 是否已经登录:通过其 ID 去Redis取对应的token,如果能取到,说明其已经登录过,如果登录过,那么要删除其原有的Redis中的token,重新生成token
    5. 登录成功
    6. 生成token:使用UUID生成32位串,存入redisuserId: tokentoken: userId都要存储
    7. 返回登录信息:将需要的信息存入dto对象,返回给前端(dto对象包含的内容有:用户名、token及其他用户信息)
  • 其他接口:

    1. 拦截器拦截:Spring使用WebMvcConfigurer的实现类,将拦截器添加至项目中
    2. 预检请求:就立即返回true
    3. 查找token:在session的请求头或请求体中查找token
    4. token校验:使用前端传来的token去redis中取值,如果取不到,说明其传来的token不正确;如果成功取到,那么更新token的过期时间
    5. 登陆成功:在ThreadLocal中存一份用户的数据

代码实现

登录逻辑

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
@Service
public class UserServiceImpl implements UserService {
@Resource
UserDao userDao;
@Resource
private RedisRepository redisRepository;
@Override
public ResponseVo login(HttpSession session, UserLoginForm loginForm) {
// 处理session变化的问题
if(redisRepository.getVerifyCode(session.getId()) == null){
return ResponseVo.error(ResponseEnum.ERROR,"Session地址变化");
}

// 获取验证码,并将其验证
String verifyCode = loginForm.getVerifyCode();
if (!redisRepository.getVerifyCode(session.getId()).equalsIgnoreCase(verifyCode)) {
redisRepository.delVerifyCode(session);
return ResponseVo.error(ResponseEnum.VERIFY_CODE_ERROR);
}
// 验证码输入正确,也将redis内的该验证码删除
redisRepository.delVerifyCode(session);
// 使用用户名查库
User admin = userDao.selectUserByUsername(loginForm.getUsername());
// 不存在用户名
if (admin == null) {
return ResponseVo.error(ResponseEnum.USERNAME_ERROR);
}
// 密码判断,用盐值加密用户输入的密码与真实的密码进行比对校验
String loginPass = Md5Password.getMd5Passsword(admin.getSalt(), loginForm.getPassword());
// 密码不正确
if (!admin.getPassword().equals(loginPass)) {
return ResponseVo.error(ResponseEnum.PASSWORD_ERROR);
}
// 验证token是否已经登录
if (redisRepository.selectLoginAccessToken(admin.getUid()) != null) {
// 如果已经登录,就删除其原本的token,顶号
redisRepository.deleteAccessToken(redisRepository.selectLoginAccessToken(admin.getUid()));
redisRepository.deleteLoginAccessToken(admin.getUid());
}
// 验证成功
// 生成token,此处TokenInfo含有两个属性:UserId、Token
TokenInfo tokenInfo = new TokenInfo();
tokenInfo.setAccessToken(TokenUtil.genToken());
tokenInfo.setUserId(admin.getUid());

// 存入redis中,id、token双向存储,即id和token互为key、互为value
redisRepository.saveLoginAccessToken(tokenInfo);
redisRepository.saveAccessToken(tokenInfo);

// 返回给前端数据,有用户名、token
UserLoginDto userLoginDto = new UserLoginDto();
userLoginDto.setToken(tokenInfo.getAccessToken());
userLoginDto.setUsername(admin.getUsername());
return ResponseVo.success(userLoginDto);
}
}

拦截器

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
@Component
public class LoginInterceptor implements HandlerInterceptor {

@Autowired
private TokenService tokenService;

@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 如果是预检请求,让其通行
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
return true;
}
// 前端将token放在请求头内,这里从请求头获取token
String accessToken = request.getHeader("ACCESS_TOKEN");
// token为空,就去请求体里找
if (null == accessToken) {
accessToken = request.getParameter("ACCESS_TOKEN");
}
// 设置编码,防止乱码
response.setHeader("Content-Type", "application/json;charset=utf-8");
// 如果token仍然为空,提醒其认证失败
if (null == accessToken) {
ThreadLocalMap.remove("THREAD_LOCAL_KEY_LOGIN_USER");
try {
response.getWriter().println(ResponseVo.error(ResponseEnum.ERROR, "无Token"));
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
// 去redis里面找token
TokenInfo tokenInfo = tokenService.getTokenInfo(accessToken);
if (null == tokenInfo) {
// 通过前端传来的token去redis中取id,如果取不到,说明传来的token不正确
ThreadLocalMap.remove("THREAD_LOCAL_KEY_LOGIN_USER");
try {
response.getWriter().println(ResponseVo.error(ResponseEnum.ERROR, "TOKEN错误"));
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
// 在 threadlocal 里面存一份用户的数据
User userInfoDto = new User();
userInfoDto.setUid(tokenInfo.getUserId());
ThreadLocalMap.put("THREAD_LOCAL_KEY_LOGIN_USER", userInfoDto);
return true;
}

@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
// 用完就remove,防止内存泄露
ThreadLocalMap.remove("THREAD_LOCAL_KEY_LOGIN_USER");
}
}

实现登录的几种方式

目前了解到的有三种主流的登录验证方式:

  1. 使用Session进行验证登录
  2. 使用JWT进行验证登录
  3. 使用Redis + token进行验证登录

Session进行登录

指在客户端存储一个Session Id。认证时,请求携带Session Id,并由服务器从Session数据存储中找到对应的Session

这种方式在很多网站框架下都有,经典的实现方式

Session方式存在什么问题?

  1. 如果系统不止一个,无法实现单点登录(或者说是不好实现)

(PS:Tomcat集群可以共享Session,但是会降低Tomcat的运行速度)

  1. session不好解决CSRF攻击(下一节介绍)

JWT进行登录

JWT(Json web Token)

JWT方式中token由三部分组成:

1
base64(header).base64(json payload).signature
  • header存放一些基本信息:token的算法签名等
  • payLoad是一串json,可以存放信息
  • signature是后端随机生成的,和payLoad绑在一起,防止客户端伪造token

使用JWT有什么好处呢?

使用JWT的目的,就是后端服务器可以不去存储token信息,因为jwt的token已经存储了信息

后台只需要校验前端传来的JWT是否正确,如果正确,就可以信赖此token中存储的信息

JWT存在什么问题吗?

但是Jwt存在一些问题:

  1. Jwt通过payLoad传输信息,这存在一个问题,jwt能存储多少信息?作为一个前端每个操作都要传来的数据,如果存太多的数据,势必增大网络和服务器的带宽IO开销
  2. Jwt无法知悉用户的一些行为,对于用户退出、登录了几次服务器无法知悉,也不能强行踢掉一个不良用户
  3. Jwt不好控制token失效的时间

Jwt为什么不能统计用户行为,我不能在redis里面存一下吗?

可以,但是没有必要。

使用JWT的原因之一就是为了减小服务器开销,如果你使用了redis来存储信息的话,还费事搞那么复杂的一个Jwt干嘛呢?

redis+token进行登录

本节使用的方式,就是redis+token,这里的token是一个随机的串,并不保存任何信息

要注意的是,redis中存储的信息,idtoken要双向绑定:

  • id : token
    • 为什么要存这个值?为了保证此用户不会重复登录
    • 不存此值会存在一个问题:如果用户在A、B两台设备使用了相同的账号密码进行登录,我们会返回其不同的token,一个用户,两个token,这样显然不正常。
    • 如果存放了这个值,我们就能在其登录时判断其是否已经登陆过
  • token : id
    • 为什么要存这个值?为了验证token是否正确
    • 如果我们使用前端传来的token,取不到对应的id,那么说明前端传来的token是不正确的

redis + token 方式的优势

  1. 可以解决单点登录问题(指,在后台多系统下保证我只需登录一次,就可以享受这个网站的所有服务,这些服务很可能是不同的系统实现的)

    如果使用Session保存用户信息来实现的,多系统即可能有多个Tomcat,而Session是依赖当前系统的Tomcat,所以系统A的Session和系统B的Session是不共享

  2. 存放的数据更多,因为使用redis,不再拘束与jwt实现时那么局促的空间

  3. 可以很好的解决CSRF

  4. 可以保证Token实时过期,对比JWT实现来说,使用Redis可以保证token准时过期

CSRF跨站请求伪造

Cross Site Request Forgery 跨站请求伪造,劫持受信任用户向服务器发送非预期请求的攻击方式

举个栗子:有两个网站,A与B(A是正经网站,B是不正经网站)

  1. 你正常流程登录了A,A的服务器返回给你一个cookie,你存到了你的浏览器内,再以后的访问,你得带上cookie里存的值给服务器,服务器也是根据这个值来校验你这个人的
  2. 后来,你访问了B,B很坏,诱骗你点击一个链接,这个链接会用你之前的cookie去访问A,模仿你本人的操作

这就是跨站请求伪造

使用Token+Redis可以避免发生CSRF

为什么Token+Redis可以解决CSRF?

CSRF 攻击之所以能够成功,是因为攻击者可以完全伪造用户的请求,该请求中所有的用户验证信息都是存在于 Cookie 中,因此攻击者可以在不知道这些验证信息的情况下直接利用用户自己的 Cookie 来通过安全验证。

要抵御 CSRF,关键在于在请求中放入攻击者所不能伪造的信息,并且该信息不存在于 Cookie 之中

可以在 HTTP 请求中以参数的形式加入一个随机产生的 Token,并在服务器端建立一个拦截器来验证这个 Token,如果请求中没有 Token 或者 Token 内容不正确,则认为可能是 CSRF 攻击而拒绝该请求

其他问题

1、为什么使用RedisTemplate,而不用Jedis、Redission或者其他的?

Spring自己就封装了RedisTemplate,开箱即用,很简单方便。

(轮子不用白不用,况且是Spring家的轮子)

2、为什么用户密码加密要用MD5加盐值?

​ MD5是很好的加密算法,因为其不会被被逆向破解,但是如果你去百度搜MD5在线破解,会有很多很多。

因为就算不会被逆向破解,但MD5对于相同的串来说,生成的串也是相同的

所以加个盐,把盐值存起来,加密时插到要加密的串之间,安全性更高

3、为什么生成用户ID不用UUID,用了雪花算法?

雪花算法其核心思想就是:使用一个 64 bit 的 long型的数字作为全局唯一 id

雪花算法生成的ID大致由时间戳、数据中心、机器标识、序列号部分组成

SnowFlake算法的优点:

  1. 高性能高可用:生成时不依赖于数据库,完全在内存中生成。
  2. 容量大:每秒中能生成数百万的自增ID。
  3. 带有一定顺序:存入数据库中,索引效率高。

第三点,就是为什么使用雪花而不用UUID的最重要的一点:

​ 用户ID一般会作为索引,作为索引存储时,如果使用UUID,生成的全是随机值,那么在生成一个新用户插入数据库时,索引的B+树为了保持有序自我会进行调整,所以使用雪花算法生成的值,带有一定顺序,可以减小数据库的压力。

4、为什么Token不用雪花算法?

Token又不往数据库存,只放在了redis里面,保证随机即可,没有对顺序的要求。

总结

  1. 本文讲述了实现登录的三种方法,并分析了他们的优点缺点
  2. 着重讲述如何实现redis+token这种实现方式
  3. 介绍了雪花算法