登录
1. 发送短信验证码
-
获取到前端发送过来的手机号
-
判断手机号是否正确
- 错误,返回错误信息
- 正确,进行第三步
-
生成验证码
-
将验证码保存到Redis中,并且设置验证码储存时长
-
发送验证码
-
返回信息给前端
flowchart TD
A([开始]) --> B(获取手机号) --> C{验证手机号} --> |不符合|B; C --> |符合|D(生成验证码) --> E(储存验证码) --> F(发送验证码) --> G(返回信息) --> H([结束])
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2. 不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3. 符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4. 保存验证码到Redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 5. 发送验证码
log.debug("验证码发送成功,验证码: {}", code);
// 返回
return Result.ok();
}
这里我是用存储验证码的键为login:code: + 手机号组成,设置的时间为2分钟
2. 登录,注册
-
从前端获取到手机号和验证码
-
校验手机号是否正确
- 错误,返回错误信息
- 正确,进行第三步
-
校验验证码
- 从Redis获取验证码信息
- 将获取的验证码和Redis中的验证码进行比对
- 错误,返回错误信息
- 正确,进行第四步
-
根据手机号查询用户,并判断用户是否存在
- 不存在,进行注册,并将查询到的用户信息进行更新
-
将用户信息储存到Redis中
- 生成随机的token,作为登录令牌,前端每次请求的时候都将携带这个token
- 将用户对象转化成Hash数据
- 将生成的token作为键,Hash数据作为值存储在Redis中
-
将token返回给前端
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 2.校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 3. 不一致,返回错误信息
return Result.fail("验证码错误");
}
// 4. 一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
// 5. 判断用户是否存在
if (user == null) {
// 6. 不存在,注册
user = createUserWithPhone(phone);
}
// 7. 保存用户到redis中
// 7.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 7.2 将User对象转为hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// setIgnoreNullValue:忽略一些空的值
// setFieldValueEditor:修改一些字段值
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3 存储
String tokenKey = LOGIN_USER_KEY + token; // "login:token:" + token
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 设置时效为30分钟
stringRedisTemplate.expire(tokenKey, 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;
}
注意:
-
这里用手机号查询出来的User对象是获取了User的所有字段,而有一些比较隐秘字段不需要展示,例如密码,所以我们需要将User对象进行处理,只保留一些需要返回的信息,所以这里我建了一个UserDTO类,只将id,昵称和头像返回
-
使用stringRedisTemplate.opsForHash().putAll()时,这里传的值为一个map,所以这里需要将UserDTO对象转化为一个map,但是这里的id是一个long类型,stringRedisTemplate保存的String类型,所以这里的map需要进一步处理,将所有字段都转成String类型
3. 校验登录状态
-
获取请求头中的token
- token不存在,直接返回
- 存在,进行下一步
-
根据token从Redis中获取用户信息
- 用户信息不存在,直接返回
- 存在,下一步
-
将查询到的Hash数据转化成UserDTO对象
-
将用户信息保存到ThreadLocal中
-
刷新token有效期
刷新拦截器
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.isBlankIfStr(token)) {
return true;
}
// 2. 基于token获取Redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3. 判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5. 将查询到的hash数据转成UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6. 存在,将用户信息保存到ThreadLocal中
UserHolder.saveUser(userDTO);
// 7. 刷新token的有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8. 放行
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(401);
return false;
}
// 有用户,放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
拦截器注册器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1); // order设置优先级
// 刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
注意:
-
这里为什么需要设置两个拦截器呢?是因为我们设置了token的时效刷新在拦截器中,而在登录拦截器中有些请求不需要拦截,例如主页,当我们一直停留在主页是,这时我们的请求就不会经过拦截器,而token当然也不会刷新,所以这时我们就需要设置一个不管我们访问什么页面,都会将token时效进行刷新的拦截器,这就是刷新拦截器,他会将携带了用户信息的token的请求进行刷新时效,而其他的直接放行到登录拦截器进行拦截
-
为什么需要用ThreadLocal来储存用户信息?ThreadLocal可以将用户信息保存到线程中,当请求结束时,我们再将保存的信息给清除。使用ThreadLocal可以再同一线程中很方便的获取用户信息,不需要频繁的从Redis中获取。
4. 退出登录
-
从请求头中获取token
-
判断token是否存在
- 不存在,返回提示
- 存在,下一步
-
从Redis中删除token信息
-
返回结果
public Result logout(HttpServletRequest request) {
// 1. 获取请求头中的token
String token = request.getHeader("authorization");
// 判断是否存在token
if (StrUtil.isBlankIfStr(token)) {
// 不存在,返回提示
return Result.ok("当前未登录");
}
// 存在,删除Redis存储的token
stringRedisTemplate.delete(LOGIN_USER_KEY + token);
return Result.ok("退出成功");
}