使用Session完成登录
1. 手机号发送验证码
逻辑步骤:
- 校验手机号格式是否正确。
- 生成验证码(例如使用Hutool工具类)。
- 将手机号和验证码存入Session。
- 返回验证码发送成功的响应。
2. 用户登录逻辑
逻辑步骤:
- 从Session中获取存储的手机号和验证码。
- 校验前端传来的手机号和验证码是否与Session中一致。
- 如果一致,根据手机号查询用户是否存在。
- 如果不存在,创建新用户并随机生成用户名。
- 将用户信息存入Session。
package com.hmdp.service.impl;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.LoginFormDTO;
import com.hmdp.dto.Result;
import com.hmdp.entity.User;
import com.hmdp.mapper.UserMapper;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RegexUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpSession;
import static com.hmdp.utils.SystemConstants.USER_NICK_NAME_PREFIX;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
// 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 不符合 返回错误信息
return Result.fail("手机号格式错误");
}
// 生成验证码
String code = RandomUtil.randomNumbers(6);
// 保存验证码到Session
session.setAttribute("code",code);
session.setAttribute("phone",phone);
//TODO 发送验证码 需要调用第三方
log.debug("发送验证码成功,验证码{}",code);
// 返回成功
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 校验手机号和验证码
if (!loginForm.getPhone().equals(session.getAttribute("phone"))) {
return Result.fail("手机号和之前的不同");
}
// 校验码不一致,报错
if (!session.getAttribute("code").equals(loginForm.getCode())) {
return Result.fail("验证码不正确");
}
// 一致,根据手机号查询用户
User user = query().eq("phone", loginForm.getPhone()).one();
if (user==null) {
// 用户不存在 创建新的用户 保存用户到数据库,保存用户到Session
user = createUserWithPhone(loginForm.getPhone());
}
// 用户存在,直接保存用户到Session
session.setAttribute("user",user);
return Result.ok();
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10)); // 设置默认的用户名
save(user);
return user;
}
}
3. 登录校验拦截器
逻辑步骤:
- 拦截所有需要登录验证的请求。
- 检查Session中是否存在
user
对象。 - 如果不存在,返回未登录的错误信息;否则将user信息转换为只含有id,昵称和头像地址的类之后(保护用户隐私信息)存入TreadLocal当中,并且放行。
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Object user = request.getSession().getAttribute("user");
if (user == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("未登录,请先登录");
return false;
}
// 将用户信息存入 ThreadLocal
// 为了保护用户的隐私需要专门设置一个类只存储用户的昵id,称和头像地址
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
UserContext.setUser(userDTO );
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理 ThreadLocal 防止内存泄漏
UserContext.clear();
}
}
UserContext
工具类:
public class UserContext {
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();
}
}
还需要在MvcConfig当中配置拦截器
package com.hmdp.config;
import com.hmdp.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/voucher/**"
)
.order(0);
}
}
session 在服务器端有默认的时长(过期时间),这是由服务器配置决定的。默认情况下,Session 的有效期会受到服务器设置的影响,而无需手动设置时长。如果需要自定义时长,可以进行配置。 (默认值:
在大多数 Servlet 容器(如 Tomcat、Jetty)中,Session 的默认超时时间是 30分钟。
这个时间表示,如果用户在 30 分钟内没有访问服务器,Session 会被销毁。)
基于Redis代替Session登录
问题:Session
是存储在服务器内存中的,默认情况下每个服务器实例维护自己的 Session 数据。在分布式系统中,不同的请求可能被分配到不同的服务器实例,从而导致无法访问原始 Session
。
虽然 Session
使用方便,但在分布式、高并发、跨平台场景下,其缺点可能带来较大的限制。因此,很多现代应用倾向于采用 无状态认证(如 JWT) 或集中式存储方案(如 Redis)来代替传统的 Session
。选择方案时需要根据业务需求、系统架构和可接受的复杂性权衡决定。
1. 手机号发送验证码
逻辑:
- 校验手机号是否规范。
- 使用
Hutool
工具生成验证码。 - 将验证码存入 Redis,设置过期时间为 2 分钟。
2. 用户登录逻辑
逻辑:
- 从 Redis 获取验证码,并与前端提交的验证码比对。
- 根据手机号查询用户,不存在则创建新用户。
- 生成登录令牌(
token
),将用户信息转换为Hash
并存入 Redis,设置有效期为 30 分钟。 - 返回
token
给前端。
package com.hmdp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.LoginFormDTO;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.mapper.UserMapper;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RegexUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
import static com.hmdp.utils.SystemConstants.USER_NICK_NAME_PREFIX;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
final private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
// 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 不符合 返回错误信息
return Result.fail("手机号格式错误");
}
// 生成验证码
String code = RandomUtil.randomNumbers(6);
/*
// 保存验证码到Session
session.setAttribute("code",code);
session.setAttribute("phone",phone);
*/
// 存储到Redis中
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
//TODO 发送验证码 需要调用第三方
log.debug("发送验证码成功,验证码{}",code);
// 返回成功
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
/*
// 校验手机号和验证码
if (!loginForm.getPhone().equals(session.getAttribute("phone"))) {
return Result.fail("手机号和之前的不同");
}
// 校验码不一致,报错
if (!session.getAttribute("code").equals(loginForm.getCode())) {
return Result.fail("验证码不正确");
}
*/
//
String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());
if(code == null || ! code.equals(loginForm.getCode())){
return Result.fail("验证码不正确或者手机号错误");
}
// 一致,根据手机号查询用户
User user = query().eq("phone", loginForm.getPhone()).one();
if (user==null) {
// 用户不存在 创建新的用户 保存用户到数据库,保存用户到Session
user = createUserWithPhone(loginForm.getPhone());
}
// 用户存在,直接保存用户到Session
// session.setAttribute("user",user);
// 保存到Redis中 以hash模式存储
// 随机生成一个token作为登录令牌
String token = UUID.randomUUID().toString(true);
// 将User对象转为UserDTO 再以token为key,Hash形式存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 将userDTO转换为map
Map<String, Object> Usermap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).
setFieldValueEditor((fieldName,fieldValue)-> fieldValue.toString())
);
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY +token,Usermap);
// 设置有效期
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);
// 返回token
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)); // 设置默认的用户名
save(user);
return user;
}
}
3. 登录拦截器
逻辑:
- 从请求头中获取
token
。 - 使用
token
从 Redis 获取用户信息。 - 如果用户信息为空,拦截请求。
- 将用户信息保存到
ThreadLocal
。 - 刷新
token
的有效期。
但是如果我们还是只在登录的拦截器当中刷新token的有效值,那么就只会在局部范围内保证token有效。而不是全局范围内,保证用户的token不会过期。
所以我们需要加一层 加一层拦截器(RefreshTokenInterceptor),虽然说是拦截器,但是他不进行拦截操作,拦截操作还是有LoginInterceptor进行拦截。
什么只在 LoginInterceptor 中刷新 token 不够?
局限性
LoginInterceptor 只对需要登录的接口进行拦截。如果用户只访问公开页面或非登录接口(如 /home
、/shop
等),这些请求不会经过 LoginInterceptor
,导致 token
无法刷新。
如果用户长时间浏览公开页面后访问需要登录的页面,可能因 token
过期被迫重新登录,影响用户体验。
全局活跃性保证
用户访问任何页面都应该被视为活跃状态,无论页面是否需要登录,都需要刷新 token
的有效期。单独依赖 LoginInterceptor
只能保证在局部范围(需要登录的接口)内刷新 token
。
为什么需要 RefreshTokenInterceptor?
- RefreshTokenInterceptor 的目的是在全局范围内检测
token
并刷新其有效期。 - 它负责在所有请求(无论是否需要登录)中检查
token
,并且将用户保存到TreadLocal当中,但不进行登录状态的校验。 - 真正的拦截操作(判断用户是否登录)仍由
LoginInterceptor
执行。(LoginInterceptor
可以查看ThreadLocal中是否存在user就可以判断是否登录了)
RefreshTokenInterceptor的代码:
package com.hmdp.Interceptor;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;
@RequiredArgsConstructor
public class RefreshTokenInterceptor implements HandlerInterceptor {
final StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从请求头当中获取token
String token = request.getHeader("authorization");
//如果 string token = "" ; 这个 token != null,而是 长度为0。所以不可以直接用 == null
if (StrUtil.isBlankIfStr(token)) {
response.setStatus(401);
return true;
}
String key = LOGIN_USER_KEY + token;
// 从Redis获取用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 判断userMap
if (userMap.isEmpty()) {
response.setStatus(401);
return true;
}
// 将userMap转换为Bean
UserDTO userDTO= BeanUtil.fillBeanWithMap(userMap, new UserDTO(),false);
// UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 保存到ThreadLocal里面
UserHolder.saveUser(userDTO);
// 刷新token的有效期
stringRedisTemplate.expire(key,LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
LoginInterceptor拦截器代码:
package com.hmdp.Interceptor;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
// 判断是否需要拦截也就是TreadLocal当中是否存在user
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserDTO userDTO = UserHolder.getUser();
if (userDTO == null) {
response.setStatus(401);
return false;
}
// 有用户放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
MvcConfig配置
package com.hmdp.config;
import com.hmdp.Interceptor.LoginInterceptor;
import com.hmdp.Interceptor.RefreshTokenInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class MvcConfig implements WebMvcConfigurer {
final StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 拦截所有 执行顺序默认都是0,按照添加顺序执行,指定Order,越小越先执行
// token刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
// 拦截部分请求
registry.addInterceptor(new LoginInterceptor()).
excludePathPatterns(
"/user/code",
"/user/login",
"blog/hot",
"/shop/**",
"/shop-type/**",
"/voucher/**").order(1);
}
}
MvcConfig
作为Spring管理的Bean,可以通过构造注入或字段注入获取StringRedisTemplate
。由于拦截器实例是手动创建的,MvcConfig
需要将StringRedisTemplate
显式传递给LoginInterceptor
的构造方法。