黑马点评项目的学习记录
1、基于Session的登录
1.1 需求分析
1、根据电话号码获取验证码
2、根据电话号码、验证码实现登录/注册
3、登录态校验
1.2 发送短信验证码逻辑
1、用户输入电话号码、点击获取验证码
2、校验电话号码格式(校验考虑用正则表达式)
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}$";//电话号码正则
正则表达式使用
//满足要求的表达式
String content = "^\\w+$";
//pattern模式对象.compile(表达式).matcher(需要满足表达式内容的对象)
Matcher matcher = Pattern.compile(content).matcher(userAccount);
if (!matcher.find()){ //matcher.find()是否又匹配表达式的部分
return -1;
}
3、不符合要求、返回一个信息描述错误
4、符合要求:生成验证码(可以使用Hutool工具类随机生成)
//2、号码格式没错、生成校验码
String code = RandomUtil.randomNumbers(6); //hutool工具类:自动生成随机数
5、保存验证码(方便后续验证用户输入的验证码是否正确)
//3、保存校验码到session:用于与用户提交的校验码进行校验
session.setAttribute("code",code);
6、返回验证码(本应该有发送短信的步骤:考虑项目侧重点:选择log打印即可)
使用到的技术:正则表达式(校验)、Hutool(生成随机数)、Session(保存用户信息、标识)
1.3 短信验证码登录、注册逻辑
1、校验手机号码格式(与上面一致)
2、校验验证码是否正确
String originCode =(String) session.getAttribute("code"); //获取注册时的验证码
String code = loginForm.getCode(); //登录输入的验证码
3、根据手机号码查询用户:判断用户是否注册过
//mybatis-plus:根据电话号码查用户
//SQL:select * from User where phone = ?
User user = query().eq("phone", phone).one();
4、判断用户是否为空、空说明没有注册过:创建新用户、插入数据库(也需要存入session)
if (user == null){
//这里不创建新变量、直接沿用上面查询的对象:可以减少一步重复存入session的操作
user = createUserWithPhone(phone);
save(user);
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
//Hutool工具随机生成字符串:用于随机生成用户昵称、加上一个前缀
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
return user;
}
5、如果查到、存入session
//这种方式不安全:所有信息都存进去了、因此要先进行数据脱敏
//session.setAttribute("user",user);
//这里是hutool的工具类、BeanUtil.copyProperties():用于对象属性拷贝
session.setAttribute("user",BeanUtil.copyProperties(user,UserDTO.class) );
使用到的技术:mybatis-plus、Hutool、Session
1.4 登录校验功能
因为有的页面需要身份验证、有的不需要、而若需要的页面过多、在每个页面都实现一边拦截会造成繁琐、冗余、因此使用拦截器实现统一拦截、执行逻辑
拦截器
1、创建类实现 HandlerInterceptor (说明这个类是拦截器类)
2、注册拦截器 创建配置类实现 WebMvcConfigurer(说明这个类用于注册拦截器的类)
小感悟:面向对象的语言、功能实现:创建类说明是什么、创建对象调用方法、额外说明配置类
注意:这里的拦截类中因为没有注册为bean、因此无法使用自动装配注入对象
因为注册类中使用了这个类:可以在注册类中自动注入、然后作为参数传入
需要拦截器类创建一个新构造方法:参数为StringredisTemplate
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
@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){
//4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((User)user);
//6.放行
return true;
}
}
@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"
);
}
}
这里使用到threadLocal:线程域、可以存储某个线程的相关信息
使用到的技术:threadLocal、拦截器概念
2、集群的session共享问题
session共享问题:在需要实现负载均衡时、用户若切换到不同服务器会导致数据丢失(session的数据内容并不会在不同tomcat服务器共享)
解决:数据共享:使用redis存储登录信息(基于内存)可共享的
2.1 注册代码更新
public Result sendCode(String phone, HttpSession session) {
//1、校验手机号码
if(RegexUtils.isPhoneInvalid(phone)){
//号码格式错误
return Result.fail("手机号格式错误!");
}
//2、号码格式没错、生成校验码
String code = RandomUtil.randomNumbers(6); //hutool工具类:自动生成随机数
//保存验证码到redis、key用手机号码、value用验证码,key加一个前缀表示涉及的业务
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
//4、发送校验码
log.info("发送短信校验码成功:{}",code);
//5、返回结果
return Result.ok();
}
2.2 登录代码更新
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1、校验电话号码格式
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//号码格式错误
return Result.fail("手机号格式错误!");
}
//2、校验验证码是否正确
//redis解决session共享
String originCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (originCode == null || !code.equals(originCode)){ //验证码超时或验证码错误
return Result.fail("验证码出错");
}
//3、根据手机号码获取用户信息
User user = query().eq("phone", phone).one();
//4、如果没查到、注册新用户、添加数据库、存入session
if (user == null){
user = createUserWithPhone(phone); //这里不创建新变量:可以减少一步重复存入session的操作
save(user);
}
//修改
/*
将随机生成的token作为key、将用户信息存入hash结构
为避免过多访问服务器使用put方法、选择使用putAll方法一次性将用户所有信息存入
因此又涉及到将脱敏后的对象DTO封装为Map对象:使用hutool工具类实现
*/
//5、redis解决session共享
//5.1、生成token
String token = UUID.randomUUID().toString(true);
//5.2、将DTO转为Map集合
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//直接转换会出错:DTO的id是Long而stringRedisTemplate要求key、value都是string
//Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create() //自定义内容
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString())
);
//5.3、存储用户值
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
//5.4、设置token有效期
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
//6、返回结果
return Result.ok(token);
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
return user;
}
2.3 校验功能更新
需要实现一个实时更新token有效期的功能、而不是从登录开始就倒计时有效期
token 的刷新写在拦截器:即只要触发拦截器就可以实现token有效期的刷新
一共需要两个拦截器:一个专门刷新token有效期、一个校验登录态
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/*
1、获取session
HttpSession session = request.getSession();
*/
//1、从请求头中获取token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
return true; //因为这个拦截器只用于刷新token有效期,所以这里无论数据是否有问题都不处理、交给专门处理拦截的处理
}
/*
2、从session中获取用户
Object user = session.getAttribute("user");
*/
//2、根据token从redis获取用户信息
String key = LOGIN_USER_KEY+token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); //entries:获取redis的多个字段值
//3、对象为空:拦截
if (userMap.isEmpty()){
response.setStatus(401); //设置为空状态码
return true;
}
//4、将查到的Hash数据转为DTO对象、方便后续存储对象信息到threadLoacl
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//5、对象不为空:保存对象信息到threadLocal
UserHolder.saveUser(userDTO);
//6、刷新token有效期:设置在拦截器只要访问了需要拦截器的地方就刷新:需优化
/*
要实现只要用户访问页面就重新刷新token有效期
而不是从登录那一刻就开始倒数有效期
*/
stringRedisTemplate.expire(key,LOGIN_USER_TTL, TimeUnit.MINUTES);
//7、放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser(); //释放threadLocal内容、防止内存泄露
}
}
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor() {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 登陆校验功能的拦截器
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/*
这个拦截器就是专门用于判断是否需要拦截的
*/
if (UserHolder.getUser() == null) {
//如果threadLocal没有存入对象
response.setStatus(401);
return false;
}
//如果存入了对象
return true;
}
}
public void addInterceptors(InterceptorRegistry registry) {
//登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1); //这里还需要调节拦截器的执行顺序:先拦截所有路径调整token有效期、再判断是否拦截
//拦截所有请求:方便刷新token有效期
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}