论坛项目进展02

第2章 Spring Boot实践,开发社区登录模块
2.1发送邮件

image-20220611155539171

#对springboot-email的配置
spring.mail.host=smtp.sina.com
spring.mail.port=465
spring.mail.username=nowcoder@sina.com
spring.mail.password=nowcoder123
spring.mail.protocol=smtps
spring.mail.properties.mail.smtp.ssl.enable=true
@Component
public class MailClient {//发送邮件的工具类
   private static final Logger logger = LoggerFactory.getLogger(MailClient.class);
   @Autowired
   private JavaMailSender mailSender;
   @Value("${spring.mail.username}")
   private String from;
   
   public void sendMail(String to, String subject, String content) {
       try {
           MimeMessage message = mailSender.createMimeMessage();
           MimeMessageHelper helper = new MimeMessageHelper(message);
           helper.setFrom(from);
           helper.setTo(to);
           helper.setSubject(subject);
           helper.setText(content, true);
           mailSender.send(helper.getMimeMessage());
      } catch (MessagingException e) {
           logger.error("发送邮件失败:" + e.getMessage());
      }
  }
}
2.2开发注册功能

image-20220611162535078

在userService里写了register函数用来注册用户,同时写了activation函数用来激活用户。

  public int activation(int userId, String code) {
       User user = userMapper.selectById(userId);
       if (user.getStatus() == 1) {//status为1说明已激活过了
           return ACTIVATION_REPEAT;
      } else if (user.getActivationCode().equals(code)) {//code等于激活码,激活成功,status设置为1
           userMapper.updateStatus(userId, 1);
           clearCache(userId);
           return ACTIVATION_SUCCESS;
      } else {//激活失败
           return ACTIVATION_FAILURE;
      }
  }
2.3会话管理

image-20220613155210520

image-20220613161145490

能用cookie尽量用cookie,session用的越来越少,因为现在大部分是分布式部署,如果在服务器1上存了一个session后。下次浏览器被分配到了服务器2去处理,还要再创建一个session,造成空间浪费。有以下几种解决方法: 1.设置负载均衡的分配策略,一种方式是叫粘性session,当一个浏览器被分配给服务器1去处理,下次访问还分配到服务器1去处理,即一个固定的ip永远分配给同一个服务器去处理。该方法的缺点就是很难保证服务器的负载是均衡的。2.同步session:当某个服务器创建并保存一个session后,会把这个session同步给其他服务器保存,缺点是空间浪费,且服务器之间会产生耦合,且性能下降。2.共享session:单独设置一台服务器专门用来存储session,缺点是这台服务器只有一个,如果宕机则会有很大影响。image-20220613162230732

所以现在主流的做法是能存到cookie就存cookie里,有敏感数据的不能存的就存到数据库里。数据库可以做一个集群。image-20220613162439790

缺点就是mysql中的数据在硬盘中,访问硬盘比较慢。所以可以把数据存到redis里。image-20220613162604651

 

 

2.4生成验证码

image-20220612125822338

 

在config包下创建KaptchaConfig用于配置Kaptcha

         @Bean
   public Producer kaptchaProducer() {
       Properties properties = new Properties();
       properties.setProperty("kaptcha.image.width", "100");
       properties.setProperty("kaptcha.image.height", "40");
       properties.setProperty("kaptcha.textproducer.font.size", "32");
       properties.setProperty("kaptcha.textproducer.font.color", "0,0,0");
       properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
       properties.setProperty("kaptcha.textproducer.char.length", "4");
       properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");
       DefaultKaptcha kaptcha = new DefaultKaptcha();
       Config config = new Config(properties);
       kaptcha.setConfig(config);
       return kaptcha;
  }

在loginController里写getKaptcha函数

    @Autowired
   private Producer kaptchaProducer;
    ...
   @RequestMapping(path = "/kaptcha", method = RequestMethod.GET)
   public void getKaptcha(HttpServletResponse response/*, HttpSession session*/) {
       // 生成验证码
       String text = kaptchaProducer.createText();
       BufferedImage image = kaptchaProducer.createImage(text);
       // 将验证码存入session
       // session.setAttribute("kaptcha", text);
       // 验证码的归属
       String kaptchaOwner = CommunityUtil.generateUUID();
       Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner);
       cookie.setMaxAge(60);
       cookie.setPath(contextPath);
       response.addCookie(cookie);
       // 将验证码存入Redis
       String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
       redisTemplate.opsForValue().set(redisKey, text, 60, TimeUnit.SECONDS);
       // 将突图片输出给浏览器
       response.setContentType("image/png");
       try {
           OutputStream os = response.getOutputStream();
           ImageIO.write(image, "png", os);
      } catch (IOException e) {
           logger.error("响应验证码失败:" + e.getMessage());
      }
  }

然后在前端修改,就可以得到验证码,并且点击刷新验证码也可以刷新

2.5开发登录,退出功能

image-20220612132412873

生成的登录凭证最终要发送一个key给客户端,让它记住,下次提交给服务端时能够识别。因为用户数据包含一些敏感数据,包括用户id,用户名或密码,这些数据不能存到客户端,所以只能存不敏感的ticket。ticket可以存到服务器的session中,也可以存到数据库。这里我们就存到数据库里。将来还会对其进行重构,将其存到redis里。

mysql中的login_ticket表:ticket:凭证,一个随机字符串;status:0-有效1-无效 expired:凭证的过期时间

image-20220612133126501

客户端在cookie中存了ticket凭证后,再次访问服务器后,服务端用cookie中的ticket查询mysql的login_ticket中的数据,可以判断出来这是哪个用户在登录和访问,以及它的status以及过期时间。

在entity报下建一个LoginTicket类,在dao包下建一个LoginTicketMapper

@Mapper
@Deprecated
public interface LoginTicketMapper {
   @Insert({
           "insert into login_ticket(user_id,ticket,status,expired) ",
           "values(#{userId},#{ticket},#{status},#{expired})"
  })
   @Options(useGeneratedKeys = true, keyProperty = "id")//主键自动生成
   int insertLoginTicket(LoginTicket loginTicket);
   @Select({
           "select id,user_id,ticket,status,expired ",
           "from login_ticket where ticket=#{ticket}"
  })
   LoginTicket selectByTicket(String ticket);//根据ticket查询
   @Update({
           "<script>",
           "update login_ticket set status=#{status} where ticket=#{ticket} ",
           "<if test=\"ticket!=null\"> ",
           "and 1=1 ",
           "</if>",
           "</script>"
  })
   int updateStatus(String ticket, int status);
}

在UserService里写login函数

public Map<String, Object> login(String username, String password, long expiredSeconds) {
       Map<String, Object> map = new HashMap<>();
       // 空值处理
       if (StringUtils.isBlank(username)) {
           map.put("usernameMsg", "账号不能为空!");
           return map;
      }
       if (StringUtils.isBlank(password)) {
           map.put("passwordMsg", "密码不能为空!");
           return map;
      }
       // 验证账号
       User user = userMapper.selectByName(username);
       if (user == null) {
           map.put("usernameMsg", "该账号不存在!");
           return map;
      }
       // 验证状态
       if (user.getStatus() == 0) {
           map.put("usernameMsg", "该账号未激活!");
           return map;
      }
       // 验证密码
       password = CommunityUtil.md5(password + user.getSalt());
       if (!user.getPassword().equals(password)) {
           map.put("passwordMsg", "密码不正确!");
           return map;
      }
       // 生成登录凭证
       LoginTicket loginTicket = new LoginTicket();
       loginTicket.setUserId(user.getId());
       loginTicket.setTicket(CommunityUtil.generateUUID());
       loginTicket.setStatus(0);
       loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
//       loginTicketMapper.insertLoginTicket(loginTicket);//将登录凭证插入mysql表中
       String redisKey = RedisKeyUtil.getTicketKey(loginTicket.getTicket());
       redisTemplate.opsForValue().set(redisKey, loginTicket);
       map.put("ticket", loginTicket.getTicket());
       return map;//将map返回给客户端,这样客户端就能存ticket
  }

在UserService里写logout函数,

    public void logout(String ticket) {
//       loginTicketMapper.updateStatus(ticket, 1);//把status改为1表示无效
       String redisKey = RedisKeyUtil.getTicketKey(ticket);
       LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(redisKey);
       loginTicket.setStatus(1);
       redisTemplate.opsForValue().set(redisKey, loginTicket);
  }
2.6显示登录信息

image-20220612164447184image-20220612164459947

image-20220612170618010

每次请求都要走一次这个过程,所以这套逻辑应该用拦截器实现,而不是写多次重复的代码。所以在controller包下建一个interceptor包,再在其中新建一个拦截器,降低耦合

@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
   @Autowired
   private UserService userService;
   @Autowired
   private HostHolder hostHolder;
   @Override//在请求一开始就要获得要登录的用户信息,即通过cookie得到ticket
   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
       // 从cookie中获取凭证
       String ticket = CookieUtil.getValue(request, "ticket");
       if (ticket != null) {
           // 查询凭证
           LoginTicket loginTicket = userService.findLoginTicket(ticket);
           // 检查凭证是否有效
           if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
               // 根据凭证查询用户
               User user = userService.findUserById(loginTicket.getUserId());
               // 在本次请求中持有用户
               hostHolder.setUser(user);
               // 构建用户认证的结果,并存入SecurityContext,以便于Security进行授权.
               Authentication authentication = new UsernamePasswordAuthenticationToken(
                       user, user.getPassword(), userService.getAuthorities(user.getId()));
               SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
          }
      }
       return true;
  }
   @Override// 在Controller之后,在TemplateEngine之前执行。因为执行该方法后紧接着就执行模板了,这时模板就可以用到modelAndView中已经有的user对象了
   public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
       User user = hostHolder.getUser();
       if (user != null && modelAndView != null) {
           modelAndView.addObject("loginUser", user);//把user存入modelAndView
      }
  }
   @Override  // 在TemplateEngine(模板)之后执行。把持有的用户信息清理掉
   public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
       hostHolder.clear();
       SecurityContextHolder.clearContext();
  }
}

在utils包下建了一个CookieUtil,用来获取某个cookie中的值

public class CookieUtil {
   public static String getValue(HttpServletRequest request, String name) {
       if (request == null || name == null) {
           throw new IllegalArgumentException("参数为空!");
      }
       Cookie[] cookies = request.getCookies();
       if (cookies != null) {
           for (Cookie cookie : cookies) {
               if (cookie.getName().equals(name)) {
                   return cookie.getValue();
              }
          }
      }
       return null;
  }
}

在在utils包下建了一个HostHolder,起到一个容器的作用,持有用户信息,用于代替session对象.而且是线程隔离的,因为服务器是多线程环境的,所以每个客户端访问服务端,服务端都会用单独一个线程去处理请求并持有客户端用户的信息,所以每个线程的用户信息不一样,所以必须使用线程隔离的ThreadLocal对象来存用户信息,而不能用普通的容器来存。

@Component
public class HostHolder {
   private ThreadLocal<User> users = new ThreadLocal<>();
   public void setUser(User user) {
       users.set(user);
  }
   public User getUser() {
       return users.get();
  }
   public void clear() {//请求结束时把这个ThreadLocal中的user清理掉,不然每次都往里存,不清理内存会越来越大
       users.remove();
  }
}

写好拦截器后要配置拦截器,在config包下建WebMvcConfig:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
   @Autowired
   private LoginTicketInterceptor loginTicketInterceptor;
   @Override
   public void addInterceptors(InterceptorRegistry registry) {
       registry.addInterceptor(loginTicketInterceptor)
              .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");//访问静态资源时不拦截,且其他所有请求都要被拦截
  }
}

最后在index.html进行修改

2.7账号设置
image-20220613163317581

新建一个UserController,

@Controller
@RequestMapping("/user")
public class UserController implements CommunityConstant {
   private static final Logger logger = LoggerFactory.getLogger(UserController.class);
   @Value("${community.path.upload}")
   private String uploadPath;
   @Value("${community.path.domain}")
   private String domain;
   @Value("${server.servlet.context-path}")
   private String contextPath;
   @Autowired
   private UserService userService;
   @Autowired
   private HostHolder hostHolder;
   // 更新头像路径
   @RequestMapping(path = "/header/url", method = RequestMethod.POST)
   @ResponseBody
   public String updateHeaderUrl(String fileName) {
       if (StringUtils.isBlank(fileName)) {
           return CommunityUtil.getJSONString(1, "文件名不能为空!");
      }

       String url = headerBucketUrl + "/" + fileName;
       userService.updateHeader(hostHolder.getUser().getId(), url);

       return CommunityUtil.getJSONString(0);
  }
   // 废弃
   @LoginRequired
   @RequestMapping(path = "/upload", method = RequestMethod.POST)
   public String uploadHeader(MultipartFile headerImage, Model model) {
       if (headerImage == null) {
           model.addAttribute("error", "您还没有选择图片!");
           return "/site/setting";
      }
       String fileName = headerImage.getOriginalFilename();
       String suffix = fileName.substring(fileName.lastIndexOf("."));
       if (StringUtils.isBlank(suffix)) {
           model.addAttribute("error", "文件的格式不正确!");
           return "/site/setting";
      }
       // 生成随机文件名
       fileName = CommunityUtil.generateUUID() + suffix;
       // 确定文件存放的路径
       File dest = new File(uploadPath + "/" + fileName);
       try {
           // 存储文件
           headerImage.transferTo(dest);
      } catch (IOException e) {
           logger.error("上传文件失败: " + e.getMessage());
           throw new RuntimeException("上传文件失败,服务器发生异常!", e);
      }
       // 更新当前用户的头像的路径(web访问路径)
       // http://localhost:8080/community/user/header/xxx.png
       User user = hostHolder.getUser();
       String headerUrl = domain + contextPath + "/user/header/" + fileName;
       userService.updateHeader(user.getId(), headerUrl);
       return "redirect:/index";
  }
   // 废弃
   @RequestMapping(path = "/header/{fileName}", method = RequestMethod.GET)
   public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
       // 服务器存放路径
       fileName = uploadPath + "/" + fileName;
       // 文件后缀
       String suffix = fileName.substring(fileName.lastIndexOf("."));
       // 响应图片
       response.setContentType("image/" + suffix);
       try (
               FileInputStream fis = new FileInputStream(fileName);
               OutputStream os = response.getOutputStream();
      ) {
           byte[] buffer = new byte[1024];
           int b = 0;
           while ((b = fis.read(buffer)) != -1) {
               os.write(buffer, 0, b);
          }
      } catch (IOException e) {
           logger.error("读取头像失败: " + e.getMessage());
      }
  }
}
2.8检查登陆状态

image-20220613171302831

为什么要检查登录状态:如果某个用户没有登陆,但是知道某个功能的路径,他在浏览器上直接输入该路径也可以访问,比如localhost:8080/community/user/setting,这就是系统漏洞。所以可以使用拦截器。先自定义一个注解。 新建一个包annotation,在下面建一个注解LoginRequired,哪个方法需要用户登录才能访问,就在方法上加上注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {

}

然后在UserController里的uploadHeader和getSettingPage方法上加上该注解。然后在interceptor包下建一个拦截器LoginRequiredInterceptor

@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {
   @Autowired
   private HostHolder hostHolder;
   @Override
   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
       if (handler instanceof HandlerMethod) {//判断拦截的目标是不是一个方法
           HandlerMethod handlerMethod = (HandlerMethod) handler;//转型一下
           Method method = handlerMethod.getMethod();//获取拦截到的对象
           LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);//从这个方法去取LoginRequired注解
           if (loginRequired != null && hostHolder.getUser() == null) {//没登陆则强制其登录
               response.sendRedirect(request.getContextPath() + "/login");
               return false;
          }
      }
       return true;
  }
}

然后在WebMvcConfig将LoginRequiredInterceptor注册进来



posted @   zhangshuai2496689659  阅读(133)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
点击右上角即可分享
微信分享提示