第5节 实战篇-登陆实现
🧊1.基于Session实现登录
3步流程如下:
- 发送短信验证码
- 短信验证登录、注册
- 校验登录状态
✅1.实现发送短信验证码
第一步,用户填写手机号后,发送并获取短信验证码,接口:/user/code,后端逻辑处理如下:
①controller层处理请求
/**
* 发送手机验证码
* post请求,参数为手机号、session
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
②service层处理逻辑
public Result sendCode2(String phone, HttpSession session) {
// 1.校验手机号,是否是无效手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.不符合抛异常
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(4);
// 4.保存验证码到 session
session.setAttribute("code", code);
// 5.发送验证码,这里模拟发送
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
到此,使用短信发送并保存到sessiojn的后端逻辑处理完成。需要注意的是,这里的手机号校验是通过引入hutool工具依赖并封装了一个公用的正则校验工具:
使用需先早pom文件导入hutool依赖:
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
import cn.hutool.core.util.StrUtil;
/**
* 正则校验工具类
*/
public class RegexUtils {
//手机号正则
private static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
//邮箱正则
private static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
//密码正则。4~32位的字母、数字、下划线
private static final String PASSWORD_REGEX = "^\\w{4,32}$";
//验证码正则, 6位数字或字母
private static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";
/**
* 是否是无效手机格式
* @param phone 要校验的手机号
* @return true:符合,false:不符合
*/
public static boolean isPhoneInvalid(String phone){
return mismatch(phone, PHONE_REGEX);
}
/**
* 是否是无效邮箱格式
* @param email 要校验的邮箱
* @return true:符合,false:不符合
*/
public static boolean isEmailInvalid(String email){
return mismatch(email, EMAIL_REGEX);
}
/**
* 是否是无效验证码格式
* @param code 要校验的验证码
* @return true:符合,false:不符合
*/
public static boolean isCodeInvalid(String code){
return mismatch(code, VERIFY_CODE_REGEX);
}
// 校验是否不符合正则格式
private static boolean mismatch(String str, String regex){
if (StrUtil.isBlank(str)) {
return true;
}
return !str.matches(regex);
}
}
✅2.实现登录
第二步,前端填写验证码后,并提交手机号和验证码进行登录,接口:/user/login,后端逻辑处理如下:
①controller层处理请求
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}
②service层逻辑处理
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 2.校验验证码
Object cacheCode = session.getAttribute("code");//获取session中的验证码
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 3.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 4.判断用户是否存在
if (user == null) {
// 不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户部分信息到session中,session的默认有效时间是30分钟(min)
session.setAttribute("user",BeanUtil.copyProperties(user,UserDTO.class));
//session不需要返回登录凭证,在访问tomcat时sessionID会自动保存在浏览器的cookie中
return Result.ok();
}
// 创建用户
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName("user_" + RandomUtil.randomString(10));
// 2.保存用户
save(user);
return user;
}
到此,session实现登录功能的后端逻辑处理完成。这里用到两个DTO类,@Data注解需要导入lombok依赖
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
✅3.实现登录拦截功能
① 创建LoginInterceptor登录拦截器类
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取session
HttpSession session = request.getSession();
// 2.获取session中的用户
Object user = session.getAttribute("user");
// 3.判断用户是否存在
if (user == null){
response.setStatus(401);
return false;
}
// 4.存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);
// 4.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户
UserHolder.removeUser();
}
}
拦截时如果session中的用户为null则不放行,反之放行,同时用ThreadLocal保存用户信息
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();
}
}
②创建Mvc配置类
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
③controller层实现校验接口,并返回用户信息
@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
✅4.Session共享问题
到此,基于Session实现登录功能完成。实现起来并不复杂,但是对于这种方案有一个致命的问题:
集群Session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同的tomcat服务器时导致数据丢失的问题。
session的替代方案应该满足:
- 数据共享
- 内存存储:因为session本身是基于内存的,读写速度快,替代方案也需满足
- key、value结构:方便数据的处理
redis恰好满足这些条件,是替代的不二之选:
解决方案:使用redis代替session
🧊2.基于redis实现登录
Redis代替session的业务流程如下:
- 发送短信验证码:这里的验证码是保证在redis中
- 短信验证登录、注册:登录成功后的用户信息是以随机token为key存储在redis中
- 校验登录状态:用前端传过来的token去redis中查验
✅1.实现发送短信验证码
①controller层处理请求
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone);
}
②service层处理逻辑
保存code到redis中,手机号为key,验证码code为value
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone) {
// 1.校验手机号,是否是无效手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.不符合抛异常
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(4);
// 4.保存验证码到redis中,设置过期时间为5分钟 //set key value ex 300
stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 5L, TimeUnit.MINUTES);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
✅2.实现登录
①controller层处理请求
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm){
return userService.login(loginForm);
}
②service层逻辑处理
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result login(LoginFormDTO loginForm) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get("login:code:" + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 3.若一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 4.判断用户是否存在
if (user == null) {
// 5.若不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 6.若存在,保存用户信息到 redis中,随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.存储到redis中,并设置token有效期,这里设置为30分钟
String tokenKey = "login:token:" + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, 30L, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
✅3.实现登录拦截功能
① 创建LoginInterceptor登录拦截器类
这里因为没有通过spring注解进行管理,使用构造器注入StringRedisTemplate
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(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)){
//若不存在,返回401
response.setStatus(401);
return false;
}
// 2.获取redis中的用户
String key = "login:token:" + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()){
response.setStatus(401);
return false;
}
//4.保存用户信息到ThreadLocal
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
// 5.刷新token有效期
stringRedisTemplate.expire(key, 30L, TimeUnit.MINUTES);
// 6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户
UserHolder.removeUser();
}
}
拦截时,若token存在且从redis中获取用户存在则放行,同时保存用户信息到ThreadLocal,反之不放行
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();
}
}
②创建Mvc配置类
这里的mvc是通过spring注解配置的,所以StringRedisTemplate直接使用注解实现注入。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
注意:虽然第3小节实现token的有效期刷新,但是只有特定的接口才能刷新token有效期,比如那些需要登录验证的接口,如果我一直访问的是不需要验证登录的接口,那么到时间后token就会过期,从redis中删除,显然并不符合业务场景。
✅4.解决状态登录刷新问题
针对上节最后的问题,可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
创建RefreshTokenInterceptor刷新token拦截器,同时将之前LoginInterceptor登录拦截器中的处理搬到这里处理
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 {
//请求头获取token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
return true;
}
//获取用户信息
String key = "login:token:" + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()){
return true;
}
//保存用户信息到ThreadLoacl
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
//刷新token
stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES);
//放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
修改LoginInterceptor登录拦截器,只需判断UserHolder.getUser() 中是否有用户即可。
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null){
// 没有,需要拦截,设置状态码
response.setStatus(401);
return false;
}
// 有用户,则放行
return true;
}
}
最后调整mvc配置中拦截器的执行顺序,顺序是从上往下执行。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 刷新token拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**"); // 全部拦截
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
当前也可以使用order(0)方法调整,数字越小越先执行
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);