🌼 文章基于 B 站黑马程序员视频教程编写
🌼 做笔记便于日后复习
一、基于 Session 实现登录
(1) 发送短信验证码
① 手机号格式后端校验
手机号校验的正则表达式
/** * 正则表达式 */ 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); } }
② 生成短信验证码
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.7.17</version> </dependency>
🌼 hutool 工具的详细使用: https://doc.hutool.cn/pages/index/
/** * 发送短信验证码 * * @param phone 手机号 * @param session 用户缓存验证码 */ @Override public Result sendCode(String phone, HttpSession session) { // 校验手机号格式是否符合手机号的规范 if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误"); } // 生成 6 位数字短信验证码 String code = RandomUtil.randomNumbers(6); // 把短信验证码保存到服务端 session 中 session.setAttribute("code", code); // 发送短信验证码给手机号 phone log.info("向 {} 手机号发送了验证码:{}", phone, code); return Result.ok("发送验证码成功"); }
(2) 短信验证码登录、注册
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { Result result = requestParamsValidate(loginForm, session); if (!result.getSuccess()) { // 手机号或验证码校验失败 return result; } // 根据手机号查询用户 String phone = loginForm.getPhone(); User user = query().eq("phone", phone).one(); // 根据手机号在数据中没有查询到用户信息 if (user == null) { // 注册用户 Result saveResult = saveUserByPhone(phone); if (saveResult.getSuccess()) { // 注册用户成功 user = (User) saveResult.getData(); } else { return saveResult; } } // 保存用户信息到 session session.setAttribute("user", user); return Result.ok("登录成功"); } private Result saveUserByPhone(String phone) { User newUser = new User(); newUser.setNickName("USER_" + RandomUtil.randomString(9)); newUser.setPhone(phone); if (save(newUser)) { return Result.ok(newUser); } return Result.fail("服务器忙, 用户保存到数据库失败"); } private Result requestParamsValidate(LoginFormDTO loginForm, HttpSession session) { // 校验手机号 String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误"); } // 校验验证码是否存在 Object cacheCode = session.getAttribute("code"); String paramCode = loginForm.getCode(); if (RegexUtils.isCodeInvalid(paramCode) || !paramCode.equals(cacheCode)) { return Result.fail("验证码错误"); } return Result.ok(); }
(3) 登录验证
🌿 根据 Cookie 中的 JSESSIONID 获取到 Session
🌿 然后从 Session 中获取到信息
🌿 登录校验需要在拦截器(Interceptor)中完成
🌿 SpringMVC 的 Interceptor 可以拦截 Controller,在请求到达 Controller 之前做一些事情
① 通过 SpringMVC 定义拦截器
- 实现(implements)HandlerInterceptor 接口
- 可覆盖该接口中的三个默认方法
🌼 preHandle
:在 Controller 的处理方法之前调用(当该方法的返回值为 true 的时候 才执行 Controller 里面的内容)
🌱通常在 preHandle
中进行初始化、请求预处理等操作(可进行登录验证)
🌱preHandle
返回 true 才会执行后面的调用。若返回 false,不会调用 Controller 处理方法、postHandle 和 afterCompletion
🌱当有多个拦截器时,preHandle 按照正序执行
🌼 postHandle
:在 Controller 的处理方法之后,DispatcherServlet
进行视图渲染之前调用
🌱可在 postHandle 中进行请求后续加工处理操作
🌱当有多个拦截器时,postHandle 按照逆序执行
🌼 afterCompletion
:在 DispatcherServlet 进行视图渲染之后调用
🌱一般在这里进行资源回收操作
🌱当有多个拦截器时,afterCompletion 按照逆序执行
配置拦截器:
@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" ); } }
② ThreadLocal
🌼 ThreadLocal 可以解释成线程的局部变量
🌼一个 ThreadLocal 的变量只有当前自身线程可以访问,别的线程都访问不了,那么自然就避免了线程竞争
🌼ThreadLocal 提供了一种与众不同的线程安全方式,它不是在发生线程冲突时想办法解决冲突,而是彻底避免了冲突的发生
/** * 用于保存 UserDTO 的 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(); } }
登录拦截器:
/** * 登录校验拦截器 */ public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("user"); if (null == user) { response.setStatus(401); return false; // 拦截 } UserDTO userDTO = user2UserDto((User) user); // 保存用户信息到 ThreadLocal 中 UserHolder.saveUser(userDTO); return true; } private UserDTO user2UserDto(User user) { UserDTO userDTO = new UserDTO(); userDTO.setIcon(user.getIcon()); userDTO.setId(user.getId()); userDTO.setNickName(user.getNickName()); return userDTO; } /** * 资源释放 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } }
@GetMapping("/me") public Result me() { // 获取当前登录的用户并返回 UserDTO userDto = UserHolder.getUser(); return Result.ok(userDto); }
🌼
/me
这个 Controller 执行完毕后,LoginInterceptor 的afterCompletion
会被调用
(4) 集群 Session 不共享问题
二、基于 Redis 实现共享 session 登录
🎉 登录之后,每次发起请求都要携带 token(用户登录凭证)
(1) 登录之后,缓存 token 到客户端
login() { const {radio, phone, code} = this.form if (!radio) { this.$message.error("请先确认阅读用户协议!"); return } if (!phone || !code) { this.$message.error("手机号和验证码不能为空!"); return } if (phone.length !== 11) { this.$message.error("手机号格式错误!"); return } axios.post("/user/login", this.form) .then(({data}) => { if (data) { // 保存用户信息到 session sessionStorage.setItem("token", data); } // 跳转到首页 location.href = "/info.html" }) .catch(err => { console.log(err) this.$message.error(err) }) },
(2) 每次请求都携带 token
let commonURL = "/api"; // 设置后台服务地址 axios.defaults.baseURL = commonURL axios.defaults.timeout = 2000 // request 拦截器,将用户 token 放入头中 let token = sessionStorage.getItem("token") // 请求拦截器 axios.interceptors.request.use( config => { // 如果 token 存在, 将用户 token 放入头中(key 是 authorization) if (token) config.headers['authorization'] = token return config }, error => { console.log(error) return Promise.reject(error) } ) // 响应拦截器 axios.interceptors.response.use(function (response) { // 判断执行结果 if (!response.data.success) { return Promise.reject(response.data.errorMsg) } return response.data; }, function (error) { // 一般是服务端异常或者网络异常 console.log(error) if (error.response.status == 401) { // 未登录,跳转 setTimeout(() => { location.href = "/login.html" }, 200); return Promise.reject("请先登录"); } return Promise.reject("服务器异常"); }); // 请求参数序列化 axios.defaults.paramsSerializer = function (params) { let p = ""; Object.keys(params).forEach(k => { if (params[k]) { p = p + "&" + k + "=" + params[k] } }) return p; }
🎶在 axios 的请求拦截器中配置 token,每次发起请求该请求会首先被拦截
🎶被拦截之后,往该请求的请求头中设置 token【key: authorization;value: token 值】
(3) 短信验证码
Redis 相关常量:
public class RedisConstants { public static final String LOGIN_CODE_KEY = "login:code:phone"; public static final Long LOGIN_CODE_TTL = 2L; // 验证码有效期 public static final String LOGIN_USER_KEY = "login:token:"; public static final Long LOGIN_USER_TTL = 36000L; 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 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:"; }
public Result sendCode(String phone, HttpSession session) { // 校验手机号格式是否符合手机号的规范 if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误"); } // 生成 6 位数字短信验证码 String code = RandomUtil.randomNumbers(6); // 把短信验证码保存到 Redis 中 stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES); // 发送短信验证码给手机号 phone log.info("向 {} 手机号发送了验证码:{}", phone, code); return Result.ok("发送验证码成功"); }
(4) 短信验证码登录、注册
@Slf4j @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result login(LoginFormDTO loginForm, HttpSession session) { Result result = requestParamsValidate(loginForm, session); if (!result.getSuccess()) { // 手机号或验证码校验失败 return result; } // 根据手机号查询用户 String phone = loginForm.getPhone(); User user = query().eq("phone", phone).one(); // 根据手机号在数据中没有查询到用户信息 if (user == null) { // 注册用户 Result saveResult = saveUserByPhone(phone); if (saveResult.getSuccess()) { // 注册用户成功 user = (User) saveResult.getData(); } else { return saveResult; } } // 保存用户信息到 Redis // 生成随机 token 串 String token = RedisConstants.LOGIN_USER_KEY + UUID.randomUUID().toString(true); // 把 User 转换为 UserDTO(过滤敏感数据) UserDTO userDTO = user2UserDto(user); // 把 UserDTO 转换为 HashMap(便于往 Redis 中存储) Map<String, Object> userDtoMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())); // 把用户信息保存到 Redis // stringRedisTemplate 要求存储的 key 和 value 都必须是 String 类型 // 否则会报类型转换错误 stringRedisTemplate.opsForHash().putAll(token, userDtoMap); // 设置登录有效期 stringRedisTemplate.expire(token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); session.setAttribute("user", user); return Result.ok(token); } private UserDTO user2UserDto(User user) { UserDTO userDTO = new UserDTO(); userDTO.setIcon(user.getIcon()); userDTO.setId(user.getId()); userDTO.setNickName(user.getNickName()); return userDTO; } private Result saveUserByPhone(String phone) { User newUser = new User(); newUser.setNickName("USER_" + RandomUtil.randomString(9)); newUser.setPhone(phone); if (save(newUser)) { return Result.ok(newUser); } return Result.fail("服务器忙, 用户保存到数据库失败"); } private Result requestParamsValidate(LoginFormDTO loginForm, HttpSession session) { // 校验手机号 String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误"); } // 通过手机号获取验证码 String redisCode = stringRedisTemplate.opsForValue() .get(RedisConstants.LOGIN_CODE_KEY + phone); // 校验验证码是否存在 String paramCode = loginForm.getCode(); if (RegexUtils.isCodeInvalid(paramCode) || !paramCode.equals(redisCode)) { return Result.fail("验证码错误"); } return Result.ok(); } }
(5) 免登录
🎄 需要实现一个效果:用户只有在使用该应用,哪么该用户的登录有效期就延长七天
🎄 而不是到达七天就退出登录
🎄 在 LoginInterceptor 中,若用户有发请求,就把该用户的登录有些时间的缓存更新为七天
/** * 登录校验拦截器 */ 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 { // 获取请求头中的 token 串 String token = request.getHeader("authorization"); if (StrUtil.isBlank(token)) { response.setStatus(401); return false; // 拦截 } // 通过前端传过来的 token 串从 Redis 中获取用户信息 Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token); if (userMap.isEmpty()) { response.setStatus(401); return false; // 拦截 } // 保存用户信息到 ThreadLocal 中 // 把 HashMap 类型转换为 UserDto 类型 UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); UserHolder.saveUser(userDTO); // 刷新登录有效期(只要用户是活跃的, 登录有效期就延长七天) stringRedisTemplate.expire(token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); return true; } /** * 资源释放 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } }
🎄 LoginInterceptor 拦截器并没有被 IoC 管理,所以不能在 LoginInterceptor 中使用
@Resource
和@Autowired
@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" ); } }
(6) 刷新登录有效期
🎄 RefreshInterceptor 会拦截每一个请求,然后进行登录有效期刷新
🎄 上一节中的 LoginInterceptor 只有当访问需要登录校验的请求的时候才会刷新
/** * 刷新登录拦截器(每个请求都要来到这里) */ public class RefreshLoginInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public RefreshLoginInterceptor(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; // 拦截(进入 LoginInterceptor) } // 通过前端传过来的 token 串从 Redis 中获取用户信息 Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token); if (userMap.isEmpty()) { return true; // 拦截(进入 LoginInterceptor) } // 保存用户信息到 ThreadLocal 中 // 把 HashMap 类型转换为 UserDto 类型 UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); UserHolder.saveUser(userDTO); // 刷新登录有效期(只要用户是活跃的, 登录有效期就延长七天) stringRedisTemplate.expire(token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); return true; } /** * 资源释放 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } }
/** * 登录校验拦截器 */ 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; } }
@Configuration public class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; /** * 配置拦截器 */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( // 这些请求不可拦截 "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" ); // order 值越小优先级越高 // order 值默认是 0 registry.addInterceptor(new RefreshLoginInterceptor(stringRedisTemplate)) .addPathPatterns("/**").order(-1); } }