僕たちのこと
雷神点评——短信登录
原理:基于Session实现
我们实现短信登录的原理为基于Session实现,具体实现的短信登录流程流程如下:

①用户提交手机号并校验,服务端校验通过则生成验证码并保留验证码到本地Session,然后服务端发送验证码给用户
②用户提交手机号和验证码,服务端校验验证码是否一致,校验通过则根据手机号查询用户,如果不存在则创建新用户并保存,然后保存用户手机号和验证码到本地Session
③用户每一次访问都需要校验登录状态,服务端从本地Session获取用户并判断是否存在,如果存在则保存用户到ThreadLocal并放行通过
配置文件信息如下:
server:port: 8080
spring:application:name: LeiShenCommentdatasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://192.168.100.128:3306/tcswuzbusername: rootpassword: 1234data:redis:host: 192.168.100.128port: 6379password: 1234lettuce:pool:max-wait: 10max-idle: 10min-idle: 1time-between-eviction-runs: 10sjackson:default-property-inclusion: non_null
mybatis-plus:configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case: truetype-aliases-package: org.example.pojo
mybatis:configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case: truetype-aliases-package: org.example.pojo
logging:level:org.example: debug
发送短信验证码实现
发送短信验证码实现代码如下:
// 控制层代码
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@Autowiredprivate UserService userService;@PostMapping("code")public ResultData SendCode(@RequestParam("phone") String PhoneNumber, HttpSession session) {return userService.UserSend(PhoneNumber,session);}
}// 服务层代码
@Slf4j
@Service
public class UserServiceImpl implements UserService {@Overridepublic ResultData UserSend(String PhoneNumber, HttpSession session) {// 校验手机号格式if(RegexUtils.isPhoneInvalid(PhoneNumber)) {return ResultData.error("The Format of the Phone Number is error!");}// 生成验证码String CheckCode = RandomUtil.randomNumbers(6);// Session存储验证码session.setAttribute("CheckCode",CheckCode);// 日志模拟验证码发送成功log.info("CheckCode Sends Successfully : {}",CheckCode);// 返回验证码return ResultData.success(CheckCode);}
}
这里校验电话号码以及邮箱地址等格式使用的工具类
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);}
}
测试效果如下:


实现短信验证的登录和注册功能
实现代码如下:
// 控制层代码
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@Autowiredprivate UserService userService;@PostMapping("/login")public ResultData Login(@RequestBody LoginFormData loginFormData,HttpSession session) {log.info("Now is Welcome:{} {}",loginFormData.getCode(),loginFormData.getPhone());return userService.UserLogin(loginFormData,session);}
}// 服务层代码
@Slf4j
@Service
public class UserServiceImpl implements UserService {@Autowiredprivate UserMapper userMapper;@Overridepublic ResultData UserLogin(LoginFormData loginFormData, HttpSession session) {// 校验用户电话号码格式String PhoneNumber = loginFormData.getPhone();if(RegexUtils.isPhoneInvalid(PhoneNumber)) {return ResultData.error("The Format of the Phone Number is error!");}// 查询用户验证码是否正确Object SessionCode = session.getAttribute("CheckCode");System.out.println(SessionCode);if(SessionCode==null || !SessionCode.toString().equals(loginFormData.getCode())) {return ResultData.error("CheckCode Error!");}// 根据电话号码查询用户是否存在LambdaQueryWrapper<UserData> lambdaWrapper = new LambdaQueryWrapper<>();lambdaWrapper.eq(UserData::getPhone,PhoneNumber);UserData LoginUser = userMapper.selectOne(lambdaWrapper);// 如果不存在则根据电话号码生成新用户if(LoginUser == null) {LoginUser = CreateUserWithPhoneNumber(PhoneNumber);}// 将登录用户信息存入Sessionsession.setAttribute("LoginUser",LoginUser);return ResultData.success("Login Success! Welcome "+PhoneNumber);}// 生成新用户public UserData CreateUserWithPhoneNumber(String PhoneNumber) {UserData NewUser = new UserData();NewUser.setPhone(PhoneNumber);NewUser.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));userMapper.insert(NewUser);return NewUser;}
}
这里存储生成用户名公共前缀头使用的工具类
public class SystemConstants {public static final String IMAGE_UPLOAD_DIR = "D:\\lesson\\nginx-1.18.0\\html\\hmdp\\imgs\\";public static final String USER_NICK_NAME_PREFIX = "user_";public static final int DEFAULT_PAGE_SIZE = 5;public static final int MAX_PAGE_SIZE = 10;
}
测试效果如下:
测试已存在的用户登录


测试不存在的用户注册


登录校验拦截器
使用拦截器对每次操作进行登录状态校验,校验完成后使用ThreadLocal存储当前登录用户信息
// 拦截器配置类
@Configuration
public class WebConfig implements WebMvcConfigurer {@Autowiredprivate LeiShenAccessInterceptor leishenInterceptor;// 添加拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry) {//拦截器拦截所有请求registry.addInterceptor(leishenInterceptor).addPathPatterns("/**").excludePathPatterns("/user/code","/user/login");}
}// 实现拦截器
@Slf4j
@Component
public class LeiShenAccessInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {log.info("开启拦截器请求");// 获取SessionHttpSession LoginSession = request.getSession();// 获取Session中的LoginUser信息Object LoginUser = LoginSession.getAttribute("LoginUser");// 如果不存在那么就是未登录,回复401状态码if(LoginUser==null) {response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);return false;}// 存入ThreadLocal线程CurrentHolder.setCurrentLocal((UserData) LoginUser);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {log.info("拦截方法彻底完成");// 在拦截方法彻底完成后释放相关资源CurrentHolder.removeCurrent();}}
ThreadLocal部分的代码实现
public class CurrentHolder {private static final ThreadLocal<UserData> CURRENT_LOCAL = new ThreadLocal<>();public static void setCurrentLocal(UserData NewUser) { //设置当前线程的userIDCURRENT_LOCAL.set(NewUser);}public static UserData getCurrent() { //获取return CURRENT_LOCAL.get();}public static void removeCurrent() { //删除CURRENT_LOCAL.remove();}
}
控制层测试方法
@GetMapping("/getme")
public ResultData GetNowUser() {UserData NowUser = CurrentHolder.getCurrent();log.info("Now User is {}",NowUser);return ResultData.success(NowUser);
}
测试效果如下:

隐藏用户敏感信息
每次登录时通过session.setAttribute("LoginUser",LoginUser);保存用户信息,但是如果存入信息过多会导致Session存储压力过大,同时也会暴露敏感信息,考虑只保留基本信息从而减缓Session存储压力并保证敏感信息安全
原本的User数据实体类
@TableName("tb_user")
public class UserData {@TableId(value = "id", type = IdType.AUTO)private Long id;private String phone;private String password;private String nickName;private String icon = "";private LocalDateTime createTime;private LocalDateTime updateTime;
}
只保留基本信息的数据实体类
public class UserBaseData {private Long id;private String nickName;private String icon = "";
}
修改后测试效果如下:

Redis替代Session
Session无法满足在多台Tomcat之间共享存储空间,使用Redis替代Session

使用Hash结构的Redis存储信息,更加灵活,可以针对单个字段进行CRUD操作,且占用内存较少
校验登录状态使用随机Token完成

修改发送短信验证码的方法,其中注意的点
①以电话号码作为
key时需要添加统一前缀头login:code:
②验证码存在有效期,需要在存入redis时设置
③有效期和统一前缀头最好封装为全局常量
public ResultData UserSend(String PhoneNumber, HttpSession session) {if(RegexUtils.isPhoneInvalid(PhoneNumber)) {return ResultData.error("The Format of the Phone Number is error!");}String CheckCode = RandomUtil.randomNumbers(6);// 存入Redis时需要给key添加统一前缀头并设置验证码有效期stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+PhoneNumber,CheckCode,LOGIN_CODE_TTL, TimeUnit.MINUTES);log.info("CheckCode Sends Successfully : {}",CheckCode);return ResultData.success(CheckCode);
}
修改登录方法
@Override
public ResultData UserLogin(LoginFormData loginFormData) {String PhoneNumber = loginFormData.getPhone();if(RegexUtils.isPhoneInvalid(PhoneNumber)) {return ResultData.error("The Format of the Phone Number is error!");}// 校验验证码是否正确Object SessionCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+PhoneNumber);System.out.println(SessionCode);if(SessionCode==null || !SessionCode.toString().equals(loginFormData.getCode())) {return ResultData.error("CheckCode Error!");}LambdaQueryWrapper<UserData> lambdaWrapper = new LambdaQueryWrapper<>();lambdaWrapper.eq(UserData::getPhone,PhoneNumber);UserData LoginUser = userMapper.selectOne(lambdaWrapper);if(LoginUser == null) {LoginUser = CreateUserWithPhoneNumber(PhoneNumber);}// 生成Token令牌并将LoginUser存入RedisString NewToken = UUID.randomUUID().toString();UserBaseData LoginUserBaseData = BeanUtil.toBean(LoginUser,UserBaseData.class);Map<String,Object> UserMap = BeanUtil.beanToMap(LoginUserBaseData);String TokenKey = LOGIN_USER_KEY+NewToken;// 存储stringRedisTemplate.opsForHash().putAll(TokenKey,UserMap);// 设置有效期stringRedisTemplate.expire(TokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);return ResultData.success("Login Success! Welcome "+PhoneNumber+" with Token: "+(NewToken));
}
在现实中,每一次访问都会刷新登录状态有效期(Token有效期),所以在拦截器中修改校验方式同时设置有效期刷新
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {log.info("开启拦截器请求");// 从请求头中获取Token令牌String UserToken = request.getHeader("Token");if(UserToken == null || UserToken.isEmpty()) {response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);return false;}// 根据Token令牌从Redis中获取登录用户信息Map<Object,Object> UserMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY+UserToken);// 如果登录用户不存在或者为空则返回401状态码if(UserMap==null||UserMap.isEmpty()) {response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);return false;}// 将当前用户存入ThreadLocalUserBaseData LoginUser = new UserBaseData();BeanUtil.fillBeanWithMapIgnoreCase(UserMap,LoginUser,false);System.out.println(LoginUser);CurrentHolder.setCurrentLocal(LoginUser);// 刷新有效期String TokenKey = LOGIN_USER_KEY+UserToken;stringRedisTemplate.expire(TokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);return true;
}
使用工具类RedisConstants封装Redis相关常量
public class RedisConstants {public static final String LOGIN_CODE_KEY = "login:code:";public static final Long LOGIN_CODE_TTL = 3L;public static final String LOGIN_USER_KEY = "login:token:";public static final Long LOGIN_USER_TTL = 10L;
}
进行测试:
首先发送验证码:

查看Redis数据库

使用验证码进行登录并获取Token

查看Redis数据库

使用Token访问

拦截器优化-状态登录刷新
优化拦截器设置,如果已登录那么全部的访问都会刷新登录状态

重新修改拦截器类:
相较于之前的拦截器,刷新拦截器
LeiShenRefreshInterceptor只负责刷新状态,无论结果如何访问全部过滤到下一个登录拦截器LeiShenLoginInterceptor,因此判断全部return true;
刷新拦截器LeiShenRefreshInterceptor中如果Token存在且合法那么会将用户信息存入ThreadLocal,因此登录拦截器LeiShenLoginInterceptor只需要校验ThreadLocal中是否保存用户信息即可判断访问会否合法
@Slf4j
@Component
public class LeiShenRefreshInterceptor implements HandlerInterceptor {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {log.info("开启刷新拦截器请求");String UserToken = request.getHeader("Token");if(UserToken == null || UserToken.isEmpty()) {return true;}Map<Object,Object> UserMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY+UserToken);if(UserMap==null||UserMap.isEmpty()) {return true;}UserBaseData LoginUser = new UserBaseData();BeanUtil.fillBeanWithMapIgnoreCase(UserMap,LoginUser,false);System.out.println(LoginUser);CurrentHolder.setCurrentLocal(LoginUser);String TokenKey = LOGIN_USER_KEY+UserToken;stringRedisTemplate.expire(TokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {log.info("拦截方法彻底完成");CurrentHolder.removeCurrent();}
}@Slf4j
@Component
public class LeiShenLoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if(CurrentHolder.getCurrent()==null) {response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);return false;}return true;}}
配置类修改如下,使用registry的order方法设置拦截器优先级,保证刷新拦截器和登录拦截器的执行顺序:
@Configuration
public class WebConfig implements WebMvcConfigurer {@Autowiredprivate LeiShenLoginInterceptor leishenLoginInterceptor;@Autowiredprivate LeiShenRefreshInterceptor leiShenRefreshInterceptor;// 添加拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry) {//拦截器拦截所有请求registry.addInterceptor(leishenLoginInterceptor).addPathPatterns("/**").excludePathPatterns("/shop/**","/voucher/**","/shop_type/**","/upload/**","/blog/hot","/user/code","/user/login").order(1);registry.addInterceptor(leiShenRefreshInterceptor).addPathPatterns("/**").order(0);}
}
