Redis实战——验证码和Token的存储与管理

本文最后更新于:2 个月前

引言

随着互联网应用的快速发展,用户认证方式也在不断演进。传统的用户名密码登录方式虽然广泛使用,但存在密码泄露、暴力破解等安全隐患。基于验证码的登录方式,尤其是通过邮箱验证码进行登录,因其操作简便、安全性高而受到越来越多应用的青睐。

在微服务架构下,多个服务协同工作,用户认证与授权需要具备高可用性和可扩展性。Redis作为高性能的内存数据存储系统,凭借其快速的数据读写能力和丰富的数据结构,成为实现分布式认证系统的理想选择。

本文将详细介绍如何在微服务架构中使用Redis实现邮箱验证码登录系统,包括各个流程的具体步骤、代码实现以及相关的优化建议。

发送邮箱验证码流程

步骤

  1. 客户端填写邮箱并提交至服务端。
  2. 服务端校验邮箱是否合法,不合法则提示客户端重新输入,合法则继续以下步骤。
  3. 判断邮箱是否有未过期的登录验证码,有则提示客户端勿频繁请求,无则继续以下步骤。
  4. 服务端生成多位数的随机验证码。
  5. 将邮箱与验证码的对应关系存入Redis中,并设置一定的过期时间。
  6. 异步地将验证码发送至对应的邮箱中,并进行日志记录与监控。

时序图

sequenceDiagram
    participant 客户端
    participant 服务端
    participant Redis
    participant 邮件服务
    participant 日志系统

    客户端->>服务端: 提交邮箱
    服务端->>服务端: 校验邮箱合法性
    alt 邮箱不合法
        服务端->>客户端: 提示重新输入
    else 邮箱合法
        服务端->>Redis: 检查是否有未过期的验证码
        alt 存在未过期的验证码
            服务端->>客户端: 提示勿频繁请求
        else 无未过期验证码
            服务端->>服务端: 生成验证码
            服务端->>Redis: 存储邮箱与验证码关系并设置过期时间
            服务端->>邮件服务: 异步发送验证码
            服务端->>日志系统: 记录日志与监控
            服务端->>客户端: 验证码发送成功提示
        end
    end

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public RestResult sendEmailCode(String email) {
// 校验邮箱合法性
if (RegexUtils.isEmailInvalid(email)) {
throw new RuntimeException("邮箱格式错误!");
}

// 生成验证码
String code = RandomUtil.randomNumbers(6);

// 使用Redis的SETNX确保验证码判断存在与存储是原子操作
Boolean isSet = stringRedisTemplate.opsForValue().setIfAbsent(LOGIN_CODE_KEY + email, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(isSet)) {
// 已存在且未过期
throw new RuntimeException("请求频繁,请稍后再试!");
}

// 发送邮件(异步)
springMailUtil.sendSimpleEmail(email, "验证码", "您的验证码是:" + code);

return RestResult.success();
}

邮箱验证码登录流程

步骤

  1. 客户端填写邮箱及收到的验证码并提交至服务端。
  2. 服务端校验邮箱是否合法,不合法提示客户端重新输入,合法则继续以下步骤。
  3. 同一邮箱验证多次不通过以后应当限制该邮箱的登录操作。
  4. 校验邮箱与验证码的对应关系是否存在于Redis中,不存在则提示客户端重新输入、增加失败次数,存在则继续以下步骤。
  5. 根据邮箱查找用户是否存在,不存在则先创建用户。
  6. 生成Token并将Token与用户对象的对应关系存入Redis中,注意设置合理的过期时间。
  7. 将校验通过地邮箱验证码对应关系进行删除,防止重复使用。
  8. 返回Token至客户端。

时序图

sequenceDiagram
    participant 客户端
    participant 服务端
    participant Redis
    participant 用户数据库

    客户端->>服务端: 提交邮箱和验证码
    服务端->>服务端: 校验邮箱合法性
    alt 邮箱不合法
        服务端->>客户端: 提示重新输入
    else 邮箱合法
        服务端->>Redis: 检查该邮箱的登录尝试次数
        alt 验证次数过多
            服务端->>客户端: 限制登录操作
        else 验证次数正常
            服务端->>Redis: 校验邮箱与验证码的对应关系
            alt 校验失败
                服务端->>客户端: 提示重新输入
            else 校验成功
                服务端->>用户数据库: 根据邮箱查找用户
                alt 用户不存在
                    服务端->>用户数据库: 创建新用户
                end
                服务端->>服务端: 生成Token
                服务端->>Redis: 存储Token与用户的对应关系并设置过期时间
                服务端->>Redis: 删除邮箱与验证码的对应关系
                服务端->>客户端: 返回Token
                客户端->>客户端: 本地存储Token值
            end
        end
    end

代码示例

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
public RestResult<String> emailLogin(String email, String code) {
// 校验邮箱合法性
if (RegexUtils.isEmailInvalid(email)) {
throw new RuntimeException("邮箱格式错误");
}
// 校验验证码合法性
if (code == null || code.length() != 6) {
throw new RuntimeException("验证码错误");
}
// 失败次数过多拦截
String loginFailValue = stringRedisTemplate.opsForValue().get(LOGIN_FAIL_COUNT + email);
if (loginFailValue != null && Integer.parseInt(loginFailValue) >= 5) {
throw new RuntimeException("请稍后再试!");
}
// 校验验证码是否正确
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + email);
if (cacheCode == null) {
throw new RuntimeException("验证码已过期");
}
if (!code.equals(cacheCode)) {
stringRedisTemplate.opsForValue().increment(LOGIN_FAIL_COUNT + email);
stringRedisTemplate.expire(LOGIN_FAIL_COUNT + email, 5, TimeUnit.MINUTES);
throw new RuntimeException("验证码错误");
}
// 查找用户,不存在则新建
User user = query().eq("email", email).one();
if (user == null) {
user = new User();
user.setEmail(email);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
save(user);
}
// 生成Token并将Token与用户对象的对应关系存入Redis中
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, BeanUtil.beanToMap(userDTO));
// 删除验证码,防止重复使用
stringRedisTemplate.delete(LOGIN_CODE_KEY + email);
// 返回Token
return RestResult.successWithData(token);
}

用户登录后的请求流程

步骤

  1. 设置拦截器获取请求头中authorization字段的Token值(注意拦截器应对注册、登录、请求验证码等接口放行)。
  2. 若Token值为空则响应401,否则对请求放行。
  3. 根据Token值从Redis中取出对应的用户对象,若用户对象为空,则响应401,否则继续放行。
  4. 将Token值对应的用户对象存入ThreadLocal中,方便后续流程使用。
  5. 刷新此Redis中此Token值的过期时间。
  6. 放行请求。
  7. 注意请求处理结束以后需及时清理ThreadLocal中的用户数据,避免内存泄漏。

时序图

sequenceDiagram
    participant 客户端
    participant 服务端
    participant Redis

    客户端->>服务端: 发送请求(带Token)
    服务端->>服务端: 拦截器获取请求头中的Token值
    alt Token为空
        服务端->>客户端: 返回401响应
    else Token不为空
        服务端->>Redis: 根据Token从Redis获取用户对象
        alt 用户对象为空
            服务端->>客户端: 返回401响应
        else 用户对象存在
            服务端->>服务端: 往ThreadLocal存入用户对象
            服务端->>Redis: 刷新Token的过期时间
            服务端->>服务端: 放行请求
            服务端->>服务端: 处理请求
            服务端->>服务端: 请求结束,清理ThreadLocal中的用户数据
        end
    end

代码示例

用户信息存取工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

public static void saveUser(UserDTO user){
tl.set(user);
}

public static UserDTO getUser(){
return tl.get();
}

public static void removeUser(){
tl.remove();
}
}

登录拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LoginInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(@Nullable HttpServletRequest request,
@Nullable HttpServletResponse response,
@Nullable Object handler) throws IOException {
// 拦截
if (UserHolder.getUser() == null) {
assert response != null;
response.setStatus(401);
response.getWriter().write("未登录或登录已过期,请重新登录");
return false;
}
// 放行
return true;
}
}

Token有效期刷新拦截器

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
public class RefreshTokenInterceptor implements HandlerInterceptor {

private final StringRedisTemplate stringRedisTemplate;

public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求头中的token
String token = request.getHeader("Authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 用户存在则将用户信息存入ThreadLocal并刷新token有效期
if (CollUtil.isNotEmpty(userMap)) {
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
} else {
// 如果Token不存在或已过期,清除Token,并返回错误信息
response.setStatus(401);
response.getWriter().write("登录信息已过期,请重新登录");
return false;
}
// 放行
return true;
}

@Override
public void afterCompletion(@Nullable HttpServletRequest request,
@Nullable HttpServletResponse response,
@Nullable Object handler, Exception ex) {
// 移除用户
UserHolder.removeUser();
}
}

Spring Boot拦截器配置

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public void addInterceptors(InterceptorRegistry registry) {
// 分别添加登录拦截器和token刷新拦截器,登录拦截器优先级高
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/user/code", "/user/login", "/user/logout").order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}

用户登出流程

步骤

  1. 获取请求头Token值,校验Token合法性,若不合法则返回错误信息,合法则继续。
  2. 删除Redis中Token值对应的键值对数据。

注意:

  1. Token的多设备支持,应根据业务要求决定是否允许单一Token多设备登录,或者为每个设备生成独立的Token。登出时,可以选择删除单个Token或所有相关Token。
  2. 确保客户端在登出后,清理本地存储的Token,防止意外的会话恢复。

时序图

sequenceDiagram
    participant 客户端
    participant 服务端
    participant Redis

    客户端->>服务端: 发送登出请求(带Token)
    服务端->>服务端: 获取请求头中的Token值
    alt Token不合法
        服务端->>客户端: 返回错误信息
    else Token合法
        服务端->>Redis: 删除Token对应的键值对数据
        服务端->>服务端: 删除ThreadLocal中的用户信息
        服务端->>客户端: 返回登出成功消息
        客户端->>客户端: 删除本地存取的Token值
    end

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public RestResult<String> logout(HttpServletRequest request) {
// 获取请求头中的Token值
String token = request.getHeader("Authorization");

if (token != null) {
// 删除Redis中Token对应的用户数据
stringRedisTemplate.delete(LOGIN_USER_KEY + token);
// 清理ThreadLocal中的用户信息
UserHolder.removeUser();
} else {
// 如果Token为空,返回401 Unauthorized
return RestResult.fail("Token已过期或无效,请重新登录");
}

return RestResult.success("登出成功");
}

后续优化

  1. 单点故障:Redis作为核心组件,若出现故障可能影响整个认证系统的可用性。需通过Redis集群、高可用配置等手段提升系统的可靠性。
  2. 内存消耗:验证码和Token的存储依赖于Redis的内存,若用户量过大,可能导致Redis内存消耗迅速增加。需合理设置数据的过期时间,并进行内存监控和管理。
  3. 复杂的异常处理:在高并发和分布式环境下,验证码和Token的管理涉及多种异常情况(如Redis连接异常、网络延迟等),需要细致的异常处理和恢复机制。
  4. Token管理的复杂性:若采用传统的Opaque Token,需要在每次请求时访问Redis进行Token验证,可能成为性能瓶颈。可考虑使用JWT等无状态Token机制,但需权衡安全性和管理复杂性。

总结

本文详细介绍了如何在微服务架构下使用Redis实现基于邮箱验证码的登录系统,包括发送验证码、登录验证、Token管理以及登出流程。通过具体的步骤、时序图和代码示例,展示了系统的实现过程,并提出了多项优化建议,提升系统的性能、安全性和可维护性。

Redis凭借其高性能和丰富的数据结构,成为分布式认证系统中不可或缺的组件。然而,系统设计需充分考虑Redis的高可用性、内存管理以及异常处理,确保系统在高并发和分布式环境下的稳定性和可靠性。

在实际应用中,还可结合其他技术和策略,如使用消息队列进行异步处理、引入分布式锁防止并发冲突、采用无状态的JWT Token机制以减少对Redis的依赖等,进一步优化系统性能和用户体验。

通过合理的设计和优化,基于Redis的邮箱验证码登录系统能够在微服务架构中高效、安全地完成用户认证与授权,满足现代互联网应用的需求。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!