在Spring Boot项目中使用Redis进行登录校验,一般的做法是将用户的登录状态(例如,JWT令牌或者用户信息)存储在Redis中,并在后续请求中进行校验。
我们需要建立两个拦截器:RefreshTokenInterceptor + LoginInterceptor,它们分别拦截全部路径和拦截需要登录的路径:
首先我们需要将这个token从前端进行获取,随后在Redis缓存中查询是否有该用户的token,如果没有就会返回401,如果查到该用户的token则进行获取数据,将用户信息保存在ThreadLocal线程空间内。
为什么要设置两个拦截器?
因为有的部分功能模块是不需要进行拦截处理,所以我们将所有模块都需要处理的步骤提取并加入新的拦截器,随后用登录校验的拦截器进行判断是否拦截,只有线程空间拥有值的情况,才能证明是有效用户,因为在全部拦截的拦截器,只有满足:
1.前端有token返回 2.redis内有用户信息
才能在线程空间存储数据。
首先创建拦截一切的 RefreshTokenInterceptor 拦截器:
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {
@Resource
private StringRedisTemplate stringRedisTemplate;
// // 手动注入:如果不加入@Component,那么需要在MvcConfig进行注入StringRedisTemplate,然后在new RefreshTokenInterceptor()的括号内加入stringRedisTemplate
// public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
// this.stringRedisTemplate = stringRedisTemplate;
// }
// @Override
// public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// // 获取session
// HttpSession session = request.getSession();
// // 获取sesstion用户
// Object user = session.getAttribute("user");
// if (user == null) {
// response.setStatus(401);
// return false;
// }
// UserHolder.saveUser((UserDTO)user);
// return true;
// }
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("authorization");
// 判断token是否为空
if(StrUtil.isBlank(token)){
return true;
}
// 通过token获取redis缓存中的用户信息(判断token是否有效)
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:" + token);
// 判断用户是否存在
if(userMap.isEmpty()){
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
UserHolder.saveUser(userDTO);
// 刷新有效时间(为了不让用户正在使用但是出现token过期的情况,即使刷新token有效期)
stringRedisTemplate.expire("login:token:" + token,30, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 销毁用户信息
UserHolder.removeUser();
}
}
从上面代码中我们可以看出,在该拦截器是不会直接拦截处理,而是全部放行。
那么线程空间如何创建的呢?
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(); } }
那我们怎么知道的token是合法的?
因为在UserController中,在登录成功后我们将token作为key,将用户信息作为value,使用hash进行存储数据,这样通过判断缓存中是否有token就可以判断该用户是否合法:
// 生成token,将user内的属性拷贝到userDTO以此存入redis String token = UUID.randomUUID().toString(); UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); // 将对象变成map进行存储,因为Redis的类型都需要是String,所以需要将内部的值转换成String类型 Map<String,Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true) .setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString())); stringRedisTemplate.opsForHash().putAll("login:token:" + token,map); // 设置token有效期 stringRedisTemplate.expire("login:token:" + token,30, TimeUnit.MINUTES);
随后创建登录拦截器 LoginInterceptor :
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 判断是否需要拦截(ThreadLocal中是否有用户)
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();
}
}
只有token有值且合法的情况才会通过此拦截器,所以这样就能够通过两个拦截器来有效处理用户正在使用但token过期后跳转问题。
既然我们已经创建成功两个拦截器,接下来就需要注册拦截器,在 /config/MvcConfig 文件中创建:
import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/login","/user/code",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/voucher/**",
"/upload/**"
).order(1); // 值越大的越后执行
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor()).addPathPatterns("/**").order(0);// 拦截所有请求
}
}
通过配置权值来决定拦截的先后顺序,在order()内进行写入,权值越大越后执行。
随后我们就要解决短信验证码这个问题了 ->
在UserController中创建两个方法分别对验证码以及登录校验进行处理
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private IUserService userService;
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm,session);
}
}
在以前,我们对于token存储问题都会使用session,而现在缓存是有利于存储且获取的一种手段,我们可以将token存储至缓存,随后在拦截器中通过查找是否有 key = token 的数据来判断token是否有效 ->
首先将验证码生成并保存在缓冲中,使用phone作为key来记录,以此保证唯一:
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误");
}
// 生成验证码
String code = RandomUtil.randomNumbers(6);
// // 保存验证码到sesson
// session.setAttribute("code", code);
// 保存验证码到Redis
stringRedisTemplate.opsForValue().set("login:code:" + phone,code,2, TimeUnit.MINUTES);
// 发送验证码(模拟步骤)
log.debug("发送短信验证码成功,验证码:{}",code);
return Result.ok();
}
}
这里设置有效期为两分钟,接下来我们的登录校验方法就可以通过在缓存中获取验证码来进行校验:
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误");
}
// Object cacheCode = session.getAttribute("code");
// 从Redis获取验证码来进行校验
String cacheCode = stringRedisTemplate.opsForValue().get("login:code:" + phone);
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.equals(code)){
return Result.fail("验证码错误");
}
// 一致,则根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
if(user == null){
user = createUserWithPhone(phone);
}
// // 将user内的属性拷贝到userDTO以此存入session
// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
// 生成token,将user内的属性拷贝到userDTO以此存入redis
String token = UUID.randomUUID().toString();
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 将对象变成map进行存储,因为Redis的类型都需要是String,所以需要将内部的值转换成String类型
Map<String,Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
stringRedisTemplate.opsForHash().putAll("login:token:" + token,map);
// 设置token有效期
stringRedisTemplate.expire("login:token:" + token,30, TimeUnit.MINUTES);
return Result.ok(token);
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName("user_" + RandomUtil.randomString(10)); // 随机昵称
// 保存用户
save(user);
return user;
}
}
这里校验手机号的格式使用的工具类:
public abstract class RegexPatterns { /** * 手机号正则 */ 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); } }