1.基于Session的登录
1.1 登录流程
- 用户在提交手机号后,会验证手机号是否合法。如果合法,则会调用发送验证码的相关程序,给用户发送验证码,并将验证码保存到session中。
- 用户将验证码和手机号输入系统,系统根据用户输入的验证码和session中的比较,如果相同,则允许登录,否则登陆失败。此外,如果用户不存在,则创建用户。并将用户信息保存到session中。
- 用户发送请求是,会通过cookie将sessionId携带到后台,后台从session中拿到用户信息,如果存在用户信息,则放行,并将用户信息保存到ThreadLocal,否则不放行。
1.2 具体实现
1.2.2 代码实现
验证码发送
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(
// 4.保存验证码到 session
session.setAttribute("code",code);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户
User user = query().eq("phone", phone).one
//5.判断用户是否存在
if(user == null){
//不存在,则创建
user = createUserWithPhone(phone);
}
//7.保存用户信息到session中
session.setAttribute("user",use);
return Result.ok();
}
登录拦截器
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){
//4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);
//6.放行
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(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
为了保证用户信息的安全,我们在session中保存的用户信息应该隐藏掉敏感信息,比如密码。所以我们在存入session的时候要敏感信息,这里我们使用userDTO来实现。
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
修改登录方法中的代码
//7.保存用户信息到session中
session.setAttribute("user", BeanUtils.copyProperties(user,UserDTO.class));
2.基于Session登录存在的问题分析
2.1 Nginx服务器和集群环境
手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。
在引入了Nginx服务器和集群环境下,每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题
2.2 思路分析
为解决session共享问题,早期的做法是进行session拷贝,保证每一台tomcat中都有全部的session信息。这样就可以实现session共享了,但是这种方式两大问题
- 每台服务器都有全部session,导致服务器压力过大
- session拷贝过程中可能存在延迟
3 使用Redis实现登录流程
3.1 Key-value的设计
我们要使用redis实现登录首先就是要设计key。key应该满足一下条件
- 唯一性
- 脱敏性
为了满足这两条,我们最终采用了随机生成一个token作为key。“login:” + token
接下来就是选择合适的数据结构,因为我们保存的用户信息非常少,值包含基本信息,因此这里我们就选择最简单的String类型保存用户信息。将用户信息序列化成Json保存到value中。
当然,我们也将验证码保存到redis中 key “code:” + phone value为验证码的值。
3.2 整体访问流程
- 用户点击发送验证码,系统将验证码保存到redis中,key “code:” + phone value为验证码的值。
- 用户登陆,根据手机号到redis中查询验证码。如果登录成功,则随机生成一个token返回到前端,并且登录用户的信息写入到redis中。key为“login:” + token,value为用户的JSON字符串。
- 验证登录状态。用户发送请求,携带token,后端根据token到redis中查找用户信息,如果查找到,则将用户信息保存到ThreadLocal并放行。
关于token,如果用户登录成功,我们会返回给前端一个token。前端保存这个token,然后以后每次发送请求,都会带上这个token,下面是前端的相关代码。
// request拦截器,将用户token放入头中
let token = sessionStorage.getItem("token");
axios.interceptors.request.use(
config => {
if(token) config.headers['authorization'] = token
return config
},
error => {
console.log(error)
return Promise.reject(error)
}
)
3.3 代码实现
@Override
public Result sendCode(String phone, HttpSession session) {
//1. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//3. 符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4. 将验证码保存到Redis
// session.setAttribute("code", code);
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5. 发送验证码
System.out.println(code);
log.debug("发送短信验证码成功,验证码:{}", code);
//返回ok
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm) {
//1. 校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
//2. 校验验证码
// TODO 2.1 从Redis中获取验证码 key为手机号
// Object cacheCode = session.getAttribute("code");
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
//3. 不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//5. 判断用户是否存在
if (user == null) {
//6. 不存在,创建新用户
user = createUserWithPhone(phone);
}
// 使用UUID生成Token
String token = UUID.fastUUID().toString(true);
// 将用户转化为Map
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 因为我们保存的到Redis的时候使用的是String类型,然而在userDto中ID是Long类型,因此会报错
// 使用setFieldValueEditor将userDto所有字段都转化成String类型,然后保存
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 将Map以Hash形式保存到Redis
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 设置Key有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.SECONDS);
// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
// 这里将token返回给前台,前台会放到sessionStorage中存放,下次再发送请求的时候将在header中添加auth字段中把token带回给服务器
return Result.ok(token);
}
3.4 登陆状态刷新的问题
3.4.1 问题分析
我们知道session的有效期是30分钟,如果30分钟之内用户没有操作,则session自动过期。然而我们目前实现的基于redis的登录方法还无法做到这一点。
// 设置Key有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.SECONDS);
目前的情况是,我们只在用户登录的时候设置了有效期30分钟。也就是说用户即使和系统还有交互,但是30分钟以后用户的登录状态也会失效。这肯定是不能接受的,因此我们还要进行优化。
3.4.2 思路
存在的问题:但是这种方案存在一些问题,因为我们的拦截器并不是什么请求都拦截的,一些商家信息和主页信息用户不登录也是可以访问的,所以如果只是在登录拦截器上设置token的过期时间,那么用户登录后一直在访问不需要登录验证的页面,那么这个重新设置token有效期的代码就不会执行,因此用户还是会在30分钟之后失去登录状态。
方案二:我们建立一个新的拦截器,让他拦截所有的请求,对所有请求都放行。他的作用是判断用户如果登录,那么就重置token有效期。这样一来,只要用户发送请求,token有效期必然重置。
3.4.3 代码实现
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 {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
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;
}
}
注意:这里牵扯到两个拦截器,就必须要指定一个拦截器的拦截顺序,谁在前谁在后。可以通过设置order的方式设置拦截器的拦截顺序。数字越小,拦截器的顺序越靠前。
@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(1);
// 默认拦截器拦截所有请求 使用order控制拦截器的先后顺序
registry.addInterceptor(new RedisRefreshInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/user/code",
"/user/login"
).order(0);
}
}