本章目的
-
[ 掌握session实现登录的流程 ]
-
[ 认识使用session实现登录的缺点 ]
-
[ 掌握引入redis实现登录的流程 ]
前置知识(基于session实现短信登录)
在学习redis实现登录功能前,我先学习一下基于session实现的登录功能,然后再来思考一下基于session来实现的登录功能会有什么缺点,为什么要使用redis来实现我们这个短信登录呢?
在解决整个问题之前我先学习了session是如何实现的短信登录。 那么我们根据上面的流程去实现一下这个后端的登录功能
controller层
- 发送验证码
less
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userInfoService.sendCode(phone,session);
}
- 登录(进行验证码的校验)
less
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm,session);
}
- 校验
java
@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
Service层
- 发送验证码
arduino
Result sendCode(String phone, HttpSession session);
impl层
typescript
@Override
public Result sendCode(String phone, HttpSession session) {
// 判断手机号码是否正确
if (RegexUtils.isPhoneInvalid(phone)) {
// 如果手机号不合法
return Result.fail(SystemConstants.PHONE_INVALID);
}
// 如果手机号码正确,生成验证码
String code = RandomUtil.randomNumbers(6);
// 将验证码保存至session
session.setAttribute("code",code);
// 发送验证码(短信的发送需要调用第三方平台,这里就不写了)
log.debug("发送短信验证码成功,验证码:{}",code);
return Result.ok();
}
- 登录(进行验证码的校验)
scss
Result login(LoginFormDTO loginForm, HttpSession session);
impl层
scss
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
/**
* 短信校验登录注册
*/
// 校验手机号
if (loginForm.getPhone() == null || RegexUtils.isPhoneInvalid(loginForm.getPhone())){
return Result.fail(SystemConstants.PHONE_INVALID);
}
// 判断验证码是否正确
Object code = session.getAttribute(SystemConstants.LOGIN_CODE);
if (loginForm.getCode() == null || !code.toString().equals(loginForm.getCode())) {
return Result.fail(SystemConstants.LOGIN_CODE_WRONG);
}
// 通过手机号获取用户信息
User userInfo = lambdaQuery().eq(User::getPhone, loginForm.getPhone()).one();
// 判断是否存在该用户
if(userInfo == null){
// 创建新用户,并将用户信息存入数据库
userInfo = createUserWithPhone(loginForm.getPhone());
}
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(userInfo,userDTO);
// 保存用户到session
session.setAttribute(SystemConstants.USER_INFO,userDTO);
return Result.ok();
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
save(user);
return user;
}
注意:这里说一下为什么使用UserDTO,我们的session是存放在浏览器的(基于内存),那么我们从数据库里查询出来的是完整的用户信息,如果我们将其存放在session的缺点:
- 不安全,敏感信息直接显示在session(浏览器)
- 占用内存 所以我们会自定义一个DTO,去隐藏我们的敏感信息。
拦截器实现校验功能
我们的校验是针对整个项目而言的,那我们的项目中存在很多的controller,这样我们每次发送请求的时候就都需要去重复写一个校验请求,如何更加简洁呢?其实只需要引入一个拦截器,用户发送请求,先经过拦截器进行判断,再将请求发送给controller。实现如下:
- 拦截器的实现流程
java
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 userInfo = session.getAttribute(SystemConstants.USER_INFO);
// 3.判断用户信息是否存在
if (Objects.isNull(userInfo)) {
// 3.2 不存在放行
response.setStatus(401);
return false;
}
// 3.1 存在保存到threadLocal
BeanUtils.copyProperties(userInfo,userInfo);
UserHolder.saveUser((UserDTO) userInfo);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
- 配置拦截器
typescript
@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/**"
);
}
}
session的缺点(集群情况)
session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同的tomcat服务时导致数据丢失的问题 解决的思想:让session可以共享(数据拷贝) --》 问题:时间延迟,数据丢失、 session的替代方案:
- 数据共享
- 内存存储
- key,value结构 redis中满足上面的解决思想要求
基于Redis实现共享session登录
基于redis实现登录业务更改
scss
@Autowired
private RedisTemplate redisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
// 判断手机号码是否正确
if (RegexUtils.isPhoneInvalid(phone)) {
// 如果手机号不合法
return Result.fail(SystemConstants.PHONE_INVALID);
}
// 如果手机号码正确,生成验证码
String code = RandomUtil.randomNumbers(6);
// 将验证码保存至session
// session.setAttribute(SystemConstants.LOGIN_CODE,code);
// 将验证码保存至redis
redisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY+phone,code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 发送验证码
log.debug("发送短信验证码成功,验证码:{}",code);
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
/**
* 短信校验登录注册
*/
// 校验手机号
if (loginForm.getPhone() == null || RegexUtils.isPhoneInvalid(loginForm.getPhone())){
return Result.fail(SystemConstants.PHONE_INVALID);
}
// 判断验证码是否正确
// Object code = session.getAttribute(SystemConstants.LOGIN_CODE);
// 获取redis中的验证码进行校验
Object code = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY+loginForm.getPhone());
if (loginForm.getCode() == null || !code.toString().equals(loginForm.getCode())) {
return Result.fail(SystemConstants.LOGIN_CODE_WRONG);
}
// 通过手机号获取用户信息
User userInfo = lambdaQuery().eq(User::getPhone, loginForm.getPhone()).one();
// 判断是否存在该用户
if(userInfo == null){
// 创建新用户,并将用户信息存入数据库
userInfo = createUserWithPhone(loginForm.getPhone());
}
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(userInfo,userDTO);
// 保存用户到session
// session.setAttribute(SystemConstants.USER_INFO,userDTO);
// 7.保存用户信息到redis
// 7.1 生成一个随机token,作为登录令牌
String token = UUID.randomUUID().toString();
// 7.2 将userInfo对象转为hash存储
Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue)-> fieldValue.toString()));
// 7.3 保存数据到redis
String key = RedisConstants.LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(key,map);
stringRedisTemplate.expire(token,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
// 返回token
return Result.ok(token);
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
save(user);
return user;
}
拦截器:
java
public class LoginInterceptor implements HandlerInterceptor {
private RedisTemplate redisTemplate;
public LoginInterceptor(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
// 2.获取redis中的用户信息
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object,Object> userMap = redisTemplate.opsForHash().entries(key);
// 3.判断用户信息是否存在
if (userMap.isEmpty()) {
// 3.2 不存在放行
response.setStatus(401);
return false;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 3.1 存在保存到threadLocal
UserHolder.saveUser(userDTO);
// 刷新redis过期时间
redisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
优化拦截器
上面就成功实现了我们的一个基于redis实现登录功能,但是上面有一个bug,我们知道,我们的拦截器是针对我们的一个需要登录的路径做拦截,顾名思义就是针对需要登录的路径才做redis对token过期时间的刷新,那么这就会造成用户如果长期访问的是无需登录的页面,也会造成token过期 ,这是不友好的。怎么办呢?我们可以多加一个拦截器,进行拦截器的分工
RefreshTokenInterceptor拦截器
用于拦截一切请求
java
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate redisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@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.获取redis中的用户信息
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object,Object> userMap = redisTemplate.opsForHash().entries(key);
// 3.判断用户信息是否存在
if (userMap.isEmpty()) {
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 3.1 存在保存到threadLocal
UserHolder.saveUser(userDTO);
// 刷新redis过期时间
redisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
LoginInterceptor拦截器
用于拦截需要登录的路径
java
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 判断是否需要做拦截
UserDTO user = UserHolder.getUser();
if (user == null) {
response.setStatus(401);
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
typescript
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"voucher/**"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(redisTemplate))
.addPathPatterns("/**").order(0);
}
}
这里有多个拦截器,那么我们可以使用order来指定拦截器的一个执行顺序,order值越小,优先级越高。
好啦这样我们的一个基于redis实现的短信登录就实现啦!!!