redis实现短信登录
最近在学习使用redis,实现一个简单的短信登录功能(没使用第三方api发送短信),使用的是黑马点评项目先用session实现,再用redis代替session
一、基于session实现短信登录的流程
发送短信验证码
根据上边的流程图写
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
//发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}
/**
* 发送验证码
*
* @param phone 手机号码
* @param session session
* @return result
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
Result.fail("手机号格式错误!");
}
//生成验证码,使用hutool包的内容
String code = RandomUtil.randomNumbers(6);
//保存验证码到session
session.setAttribute("code", code);
//发送验证码
//因为需要使用第三方服务,暂时先不写
log.debug("验证码:" + code);
return Result.ok();
}
短信验证码登录,注册
根据上边的流程图写
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// 实现登录功能
return userService.login(loginForm,session);
}
/**
* 登录
*
* @param loginForm 登录表单
* @param session session
* @return result
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
Result.fail("手机号格式错误!");
}
//2.校验验证码
//session中取出的验证码
Object cacheCode = session.getAttribute("code");
//用户提交的验证码
String code = loginForm.getCode();
if (code != cacheCode){
Result.fail("验证码错误");
}
//3.根据手机号查询用户
User user = query().eq("phone", phone).one();
//4.不存在,创建新用户
if (user == null) {
user = creatUserWithPhone(phone);
}
//5. 保存用户到session
session.setAttribute("user", user);
return Result.ok();
}
登录校验
因为不同的controller都需要校验登录状态,所以把登录校验直接写进拦截器,再把用户信息放进ThreadLocal中,这样都能拿到用户信息ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。
写拦截器
public class LoginInterceptor implements HandlerInterceptor {
//前置拦截,登录校验
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.从session获取用户
HttpSession session = request.getSession();
Object user = session.getAttribute("user");
//2. 判断用户是否存在
if (user == null){
//3. 没有就拦截
response.setStatus(401);
return false;
}
//4. 如果有,保存用户到ThreadLocal
UserHolder.saveUser((User) user);
return true;
}
//渲染之后,返回给用户之前,销毁对应的用户信息,避免内存泄漏
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//销毁
UserHolder.removeUser();
}
}
配置拦截器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
//添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
//这些路径不用拦截
.excludePathPatterns(
"/voucher/**",
"/upload/**",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/user/login",
"/user/code"
);
}
}
获取用户信息返回给前端
/**
* 登录校验
*
* @return
*/
@GetMapping("/me")
public Result me() {
// 获取当前登录的用户并返回
User user = UserHolder.getUser();
return Result.ok(user);
}
隐藏用户敏感信息
这是上边返回的用户信息,有用户信息泄露的风险
前边我们在登录的时候直接将完整的用户信息存到了session中,为了减少内存资源的占用以及降低用户信息泄露的风险,应对代码做出如下调整:
UserServiceImpl中
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
Result.fail("手机号格式错误!");
}
//2.校验验证码
//session中取出的验证码
Object cacheCode = session.getAttribute("code");
//用户提交的验证码
String code = loginForm.getCode();
if (code != cacheCode){
Result.fail("验证码错误");
}
//3.根据手机号查询用户
User user = query().eq("phone", phone).one();
//4.不存在,创建新用户
if (user == null) {
user = creatUserWithPhone(phone);
}
//5. 保存用户到session
//使用hutool包中的BeanUtil.copyProperties()方法将用户的属性复制到userdto中
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
UserHolder中
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();
}
}
修改后,用户信息相对安全:
集群的session共享问题
session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不tomcat服务时导致数据丢失的问题,所以接下来使用redis
二、Redis代替session实现短信登录的流程
发送短信验证码
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
Result.fail("手机号格式错误!");
}
//生成验证码,使用hutool包的内容
String code = RandomUtil.randomNumbers(6);
//保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//发送验证码
//因为需要使用第三方服务,暂时先不写
log.debug("验证码:" + code);
return Result.ok();
}
短信验证码登录,注册
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
Result.fail("手机号格式错误!");
}
//2.校验验证码
//redis中取出的验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//用户提交的验证码
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
Result.fail("验证码错误");
}
//3.根据手机号查询用户
User user = query().eq("phone", phone).one();
//4.不存在,创建新用户
if (user == null) {
user = creatUserWithPhone(phone);
}
//5. 保存用户到redis
//5.1使用hutool包中的BeanUtil.copyProperties()方法将用户的属性复制到userdto中
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//5.2将userDTO对象转为map
//把userDTO对象中的所有属性转为string再储存
Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).
setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
//5.3生成随机token
String token = UUID.randomUUID().toString(true);
String tokenKey=LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey, map);
//5.4设置token有效期
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);
}
登录校验
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//在MvcConfig中加入注释的内容
// @Autowired
// private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的token
String token = request.getHeader("authorization");
//2. 判断token是否存在
if (StrUtil.isBlank(token)) {
//3. 没有就拦截
response.setStatus(401);
return false;
}
//4.用token在redis里查询用户信息
String tokenKey= RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> entries =
stringRedisTemplate.opsForHash().entries(tokenKey);
//查询用户是否存在
if (entries.isEmpty()) {
//3. 没有就拦截
response.setStatus(401);
return false;
}
//5.map转为userDto
UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);
//4. 保存用户到ThreadLocal
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
登录拦截器的优化
需要加一个对一切路径都拦截的拦截器
新增一个拦截器:
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.从session获取用户
// HttpSession session = request.getSession();
// Object user = session.getAttribute("user");
// //2. 判断用户是否存在
// if (user == null){
// //3. 没有就拦截
// response.setStatus(401);
// return false;
// }
//
// //4. 如果有,保存用户到ThreadLocal
// UserHolder.saveUser((UserDTO) user);
// return true;
// }
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的token
String token = request.getHeader("authorization");
//2. 判断token是否存在
if (StrUtil.isBlank(token)) {
return false;
}
//4.用token在redis里查询用户信息
String tokenKey= RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> entries =
stringRedisTemplate.opsForHash().entries(tokenKey);
//查询用户是否存在
if (entries.isEmpty()) {
return false;
}
//5.map转为userDto
UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);
//4. 保存用户到ThreadLocal
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
//渲染之后,返回给用户之前,销毁对应的用户信息,避免内存泄漏
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//销毁
UserHolder.removeUser();
}
}
将原来的LoginInterceptor改为:
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 addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
//这些路径不用拦截
.excludePathPatterns(
"/voucher/**",
"/upload/**",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/user/login",
"/user/code"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/voucher/**",
"/upload/**",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/user/login",
"/user/code"
).order(0);
}
}
注:使用的一些常量
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;
public static final Long CACHE_NULL_TTL = 2L;
public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final String CACHE_SHOP_TYPE = "shop:type";
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
public static final String SECKILL_STOCK_KEY = "seckill:stock:";
public static final String BLOG_LIKED_KEY = "blog:liked:";
public static final String FEED_KEY = "feed:";
public static final String SHOP_GEO_KEY = "shop:geo:";
public static final String USER_SIGN_KEY = "sign:";
}