一、隐藏敏感信息
之前我们对 /user/me 路径,直接返回了登录的所有用户信息,其中的 passward 等敏感信息也会被返回到前端,这是很危险的,故我们需要选择性的返回用户信息,隐藏敏感用户信息
我们可以创建一个 UserDTO 类将 user 中可以返回的信息封装到其中后,将 UserDTO 返回
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
然后我们将登录成功时,存入 user 的操作,改为存入 UserDTO ,这里使用的是 hutool 工具中 BeanUtil 来封装,将之前的 user 换成 UserDTO 后再存入 session
//保存用户登录信息
// session.setAttribute("user",user);
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
在拦截器进行登录校验时,我们会拿出这个 user ,需要将拦截器的对应代码也修改
//前置拦截
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//拿到 session 中的 user
Object user = request.getSession().getAttribute("user");
System.out.println(user+"进入前置拦截器");
//若用户不存在,拦截
if(user == null){
response.setStatus(401);//响应 401 状态码,表示未授权
return false;
}
//将用户保存在 ThreadLocal 中,调用 UserHolder 中的静态方法
UserHolder.saveUser((UserDTO) user);
//放行
System.out.println("前置拦截放行");
return HandlerInterceptor.super.preHandle(request, response, handler);
}
public class UserHolder {
//user 对应的的线程池
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
//往线程池中存入用户
public static void saveUser(UserDTO user){
tl.set(user);
}
//拿到当前线程的 user,一个线程只有一个 user
public static UserDTO getUser(){
return tl.get();
}
//移除当前线程的 user
public static void removeUser(){
tl.remove();
}
}
修改完毕后,我们再次使用 postman 进行测试,完成发验证码,登录,等了校验三个请求
可以看到,这次返回的用户信息只含有 id,昵称,头像。保护了敏感信息。
二、解决集群的 session 共享问题
使用 session 进行登录信息的存储,当出现多台 tomcat 时,存储的信息会出现无法共享的情况,我们可以通过 Redis 来存储信息来解决
对于验证码,我们可以使用手机号作为 key 验证码存入 value中;
对于登录信息,我们可以使用哈希结构存储不同的信息,使用随机 token 生成随机且唯一的 key,在响应时,将 token 返回给浏览器,在之后需要用到 token 的请求,需要在请求中发送 token
在完成业务之前,我们先配置一下 redis 并测试是否可以正常访问
yml 配置文件:
server:
port: 8082
spring:
application:
name: mydp
datasource:
url: jdbc:mysql://localhost:3306/learnbase
username: root
password: 1234
redis:
host: 127.0.0.1
port: 6379
lettuce:
pool:
max-active: 8 # 最大连接
max-idle: 8 # 最大空闲连接
min-idle: 0
max-wait: 100 # 最大等待时间,单位毫秒
jackson:
default-property-inclusion: non_null # JSON处理时忽略非空字段
测试 redis 是否连接正常
@SpringBootTest
class MyDianpingApplicationTests {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
void redisText() {
stringRedisTemplate.opsForValue().set("dataRides","check");
Object dataRides = stringRedisTemplate.opsForValue().get("dataRides");
System.out.println(dataRides);
}
}
若 redis 中写入了键值对 dataRides , check 则可以认为连接时畅通的
接下来我们便可以通过 Redis 来优化登录业务了
发送验证码
首先在成员变量位置注入 StringRedisTemplate
@Resource
private StringRedisTemplate stringRedisTemplate;
Controller 层和 UserService 接口无需改动,只需修改 UserServiceImpl 的 sendCode 方法即可
@Override
public Result sendCode(String phone, HttpSession session) {
//先用 hutool 工具校验手机号是否合法
if (phone == null || !PhoneUtil.isPhone(phone)) {
return Result.fail("请输入合法的手机号");//若不合法直接响应错误
}
//用 hutool 工具生成六位验证码
String code = RandomUtil.randomNumbers(6);
//将生成的验证码放入 session
//TODO 优化:保存验证码到redis
//TODO 后面两个参数时验证码的有效期,2分钟,设置后 redis 会在底层加上 set key value ex 120
stringRedisTemplate.opsForValue().set("login:code:"+phone,code,2, TimeUnit.MINUTES);
//给手机号发送验证码,这里模拟发送验证码的操作,而不是真正的发送
System.out.println("手机收到了一条验证码短信:"+code);
return Result.ok();
}
登录逻辑
从 redis 中获取验证码并验证,验证通过后存入用户信息到 redis
生成 token 作为唯一的标识符,将用户信息封装为哈希表,将其挂在 token 下后存入 redis
最后返回 token ,在之后每次需要用到用户信息的请求中,我们都将 token 作为请求头发送请求
@Override
public Result login(LoginFormDTO loginFormDTO, HttpSession session) {
if(loginFormDTO == null){
return Result.fail("无效操作");
}
//校验手机号
String phone = loginFormDTO.getPhone();
if (phone == null || !PhoneUtil.isPhone(phone)) {
return Result.fail("请输入合法的手机号");//若不合法直接响应错误
}
//拿到 session 域中的验证码
//TODO 优化:从 redis 中获取验证码
String code1 = stringRedisTemplate.opsForValue().get("login:code:"+phone);
String code = loginFormDTO.getCode();
if(!code.equals(code1)){
return Result.fail("验证码输入错误,请重新输入");
}
//验证码正确,判断是否存在用户
User user = userMapper.selectByPhone(phone);
if(user == null){
//若不存在就创建一个用户并存入 mysql
user = createUserByPhone(phone);
userMapper.insert(user);
}
//保存用户登录信息
//TODO 优化:生成 token ,使用哈希的方式保存用户信息
//使用 hutool 的 UUID 来生成 token
String token = UUID.randomUUID().toString();
UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
//使用 stringRedisTemplate,使用 hash 的方式存储存信息时必须保证所有 key value 都是 String 类型
Map<String,String> userMap = new HashMap<>();
userMap.put("id",Long.toString(userDTO.getId()));
userMap.put("nickName",userDTO.getNickName());
userMap.put("icon",userDTO.getIcon());
stringRedisTemplate.opsForHash().putAll("login:token:"+token,userMap);
//设置 token 有效日期,此处设定初始有效日期,可以通过通用拦截器更新有效日期
stringRedisTemplate.expire("login:token:"+token,30,TimeUnit.MINUTES);
//TODO 优化:返回生成的 token
return Result.ok(token);
}
拦截器更新 token 有效期,登录验证
为了做到当用户完成任何操作后,token 的有效期更新,以保证用户的使用体验,我们可以设置一个全局拦截器,将 token 的更新操作写在全局拦截器中,并在全局拦截器中将用户信息存入线程池中
再使用第二级拦截器判断用户是否登录,登录的用户一定会存在于线程池,我们可以通过此来判断用户是否登录
一级拦截
public class RefreshTokenInterceptor implements HandlerInterceptor {
//这个类没有被 Spring 管理,我们可以使用其配置类 MvcConfig 拿到后通过有参构造传递进来
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)){
//token 不存在,直接放行
return true;
}
//基于 token 取用户信息
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:"+token);
if(userMap.isEmpty()){
//没有信息,直接放行
return true;
}
//有用户信息,将 userMap 转为 UserDTO 后保存到 ThreadLocal 中
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//最后一个参数表示是否忽略转换过程中的错误
UserHolder.saveUser(userDTO);
//更新 token 的失效时间
stringRedisTemplate.expire("login:token:"+token,30, TimeUnit.MINUTES);
//放行
return true;
}
//渲染后拦截
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//在渲染后将 user 从线程中移除
UserHolder.removeUser();
}
}
二级拦截
public class LoginInterceptor implements HandlerInterceptor {
//前置拦截
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断线程池中是否有登录用户,若没有则拦截,返回 401 状态码
if(UserHolder.getUser() == null){
response.setStatus(401);
return false;
}
return true;
}
}