Redis实战 — 黑马点评(一) 登录篇
来自黑马的redis课程的笔记
【黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目】
目录
- Redis实战 — 黑马点评(一) 登录篇
- 1. 项目介绍
- 2. 短信登录
- 2.1 流程:
- 2.2 一些小的收获
- 2.3 系统安全(重点)
- 2.4 实现流程和关键代码
- 2.4.1 发送验证码
- 2.4.2 短信验证码登录/注册
- 2.4.3 校验登录状态
 
 
 
1. 项目介绍


2. 短信登录
2.1 流程:

2.2 一些小的收获
-  使用hutool中的 RandomUtil.randomNumbers(6)和RandomUtil.randomString(10)随机生成验证码和随机字符串(用于默认用户名)
-  代码中不要出现“魔法值”,要统一定义常量 
-  mp中 lambdaQuery的使用(相信之后不用再new QueryWrapper了)User user = lambdaQuery().eq(User::getPhone, phone).one();
-  使用 UUID.randomUUID().toString(true)来生成不带‘-’的uuid
-  // 使用hutool工具中的对象转map,并自定义操作。 Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create() .ignoreNullValue() .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
-  封装好的常用的正则表达式和正则工具类 public abstract class RegexPatterns { /** * 手机号正则 * 使用过程中发现有些手机号不支持如191这些新手机号,自行修改即可,例如要支持191,只需将9[89]改为9[189] * 括号里面的就是第2,3位手机号 */ public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$"; /** * 邮箱正则 */ public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$"; /** * 密码正则。4~32位的字母、数字、下划线 */ public static final String PASSWORD_REGEX = "^\\w{4,32}$"; /** * 验证码正则, 6位数字或字母 */ public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$"; }public class RegexUtils { /** * 是否是无效手机格式 * @param phone 要校验的手机号 * @return true:不符合,false:符合 */ public static boolean isPhoneInvalid(String phone){ return mismatch(phone, RegexPatterns.PHONE_REGEX); } /** * 是否是无效邮箱格式 * @param email 要校验的邮箱 * @return true:不符合,false:符合 */ public static boolean isEmailInvalid(String email){ return mismatch(email, RegexPatterns.EMAIL_REGEX); } /** * 是否是无效验证码格式 * @param code 要校验的验证码 * @return true:不符合,false:符合 */ public static boolean isCodeInvalid(String code){ return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX); } // 校验是否不符合正则格式 private static boolean mismatch(String str, String regex){ if (StrUtil.isBlank(str)) { return true; } return !str.matches(regex); } }
2.3 系统安全(重点)
用户登录,管理的三种常见方案:
-  传统的cookie + session 流程: 1、用户向服务器发送用户名和密码。 2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。 3、服务器向用户返回一个 session_id,写入用户的 Cookie。 4、用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。 5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。 优点: - 数据存放在服务端,客户端只拿到令牌,服务端对用户状态掌控力强。
 缺点: - 浏览器支持cookie,移动端不行。
- session是存放在服务端程序内存中的,分布式架构下session共享是个问题,不易水平扩展。
 
-  JWT 流程: 1、用户向服务器发送用户名和密码。 2、服务器验证通过后,颁发一个jwt字符串,该字符串中可以包含一些不敏感的用户信息 3、客户端存储jwt。 4、用户随后的每一次请求,都会带上jwt,服务端验证jwt判断用户是否合法。 优点: - 去中心化,便于分布式系统使用。
- 基本信息可以直接放在token中。 username,nickname,role等。
 缺点: - 一旦颁发jwt,该jwt在有效期内都有效,服务端不能主动让其失效。
 
-  token + redis 该方案就是cookie + session的一个升级版,其思想是一样的。 也就是让redis代替session,让token代替sessionid。 流程: 1、用户向服务器发送用户名和密码。 2、服务器验证通过后,在redis里面保存相关数据,比如用户角色、登录时间等等。 3、服务器向用户返回一个 token。 4、用户随后的每一次请求,都会带上该token。 5、服务器收到 token,在redis中找到前期保存的数据,由此得知用户的身份。 优点: - 数据存放在服务端,客户端只拿到令牌,服务端对用户状态掌控力强。
- 用redis代替session后,不再担心分布式架构session共享的问题,性能好。
 缺点: - 依赖redis,redis操作增加系统复杂度。
 
redis课程当然采用的是第三种方案。
jwt的致命缺点就是无法主动踢出用户,个人感觉用户管理最佳方案就是token + redis的方式,遇到瓶颈只需要横向扩展redis即可。
2.4 实现流程和关键代码
2.4.1 发送验证码
要考虑的问题:
-  如何生成验证码? 
-  验证码有效期如何实现? 
-  如何将手机号和验证码绑定? 
-  如何发送短信? 
解决:
- 使用RandomUtil.randomNumbers(6);生成随机六位验证码。
- 使用redis的特性,设置该数据的ttl。
- 将手机号作为key,验证码作为value。
- 购买短信服务。
@Autowired
StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
    // 1. 校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2. 若不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3. 符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 4. 保存验证码和手机号到redis, 设置过期时间, 此处LOGIN_CODE_TTL为避免产生魔法值而设置的常量 2L
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    // 5. 发送验证码(此处没钱购买服务,一般可以去各大云购买短信服务调用相应的api)
    log.debug("发送验证码成功,验证码:{}", code);
    return Result.ok();
}
2.4.2 短信验证码登录/注册
跟着流程走即可,前端需要保存此处返回的token:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2. 若不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 2. 校验验证码
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (StrUtil.isBlank(code) || !code.equals(cacheCode)) {
        // 3. 不一致 报错
        return Result.fail("验证码错误");
    }
    // 4. 一致 根据手机号查询用户
    User user = lambdaQuery().eq(User::getPhone, phone).one();
    // 5. 判断用户是否存在
    if (user == null) {
        // 6. 不存在 创建用户
        user = createUserWithPhone(phone);
    }
    // 7. 存在 保存到session/redis
    // 7.1 随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 7.2 将user转为hashmap存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                                                     CopyOptions.create()
                                                     .ignoreNullValue()
                                                     .setFieldValueEditor((name, value) -> value.toString()));
    // 7.3 存储
    stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);
    stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 8. 返回token
    return Result.ok(token);
}
// 创建用户
private User createUserWithPhone(String phone) {
    // 1. 创建用户
    User user = new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    // 2. 保存用户
    save(user);
    return user;
}
2.4.3 校验登录状态
主要是使用拦截器实现,知识点:
-  拦截无权限请求 
-  token自动续签 
-  使用ThreadLocal 
思路:
一个拦截器用于token续签,一个拦截器用于权限验证。

token续签:
小tips:由于注册拦截器需要手动new,所以我们不使用自动装配的方式来注入StringRedisTemplate,而是使用构造方法在注册拦截器的时候注入。
public class RefreshTokenInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;
    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取请求头中token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2. 获取redis中的用户
        String key = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3. 判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
//        UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        UserDTO user = BeanUtil.toBean(userMap, UserDTO.class);
        // 5. 存在 保存用户信息到ThreadLocal
        UserHolder.saveUser(user);
        // 6. 刷新token有效期
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 7. 放行
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}
权限验证:
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (UserHolder.getUser() == null) {
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            return false;
        }
        return true;
    }
}
拦截器注册:
小tips:
-  在这里自动装配StringRedisTemplate,通过构造函数注入RefreshTokenInterceptor,使代码更优雅。 
-  不是所有请求都需要权限验证,所以根据需求设置白名单。 
-  使用order来设置拦截器的优先级,order越小,优先级越高,order相同,优先级按注册顺序降低。 
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Autowired
    StringRedisTemplate stringRedisTemplate;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        List<String> excludePath = new ArrayList<>();
        excludePath.add("/user/code");
        excludePath.add("/user/login");
        excludePath.add("/blog/hot");
        excludePath.add("/shop/**");
        excludePath.add("/shop-type/**");
        excludePath.add("/upload/**");
        excludePath.add("/voucher/**");
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(excludePath)
                .order(1);
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**")
                .order(0);
    }
}



















