2.5 开发登录、退出功能

199 阅读7分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情

开发登录、退出功能

image-20220710214648224

1. 访问登录页面

点击顶部区域的“登录”按钮,打开登录页面。这个功能之前我们已经实现过了,所以我们无须实现

2. 登录

生成的登录凭证最终是要发送一个key给客户端,然后让它记住,下次提交给服务端,以便能够识别。但是这个登录凭证里面包括了一些敏感的数据,包括用户的id、用户名、密码,这些数据不能发送给客户端要存到服务端,我们可以存到session里或者数据库里,这里我们把它存到数据库里,将来我们重构的时候再把它存到redis里。

我们先看一些登录表

CREATE TABLE `login_ticket` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `ticket` varchar(45) NOT NULL,
  `status` int(11) DEFAULT '0' COMMENT '0-有效; 1-无效;',
  `expired` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `index_ticket` (`ticket`(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

image-20220711100711300

开发顺序 数据访问层(dao)---> 业务层(service)---> 视图层(controller、themeleaf模板)

与登录表login_ticket对应的实体类

public class LoginTicket {

    private int id;
    private int userId;         // 用户id
    private String ticket;      // 登录凭证
    private int status;      // 登录状态(当前登录凭证是否有效)
    private Date expired;     // 过期时间(凭证失效时间)

		// 为了影响阅读体验,这里没有粘get、set、toString方法,实际上是有的    
}

数据访问层(dao)

dao接口

之前我们实现dao接口是新建一个mapper配置文件写sql,除了这种方式我们还可以在mapper接口中写注解去实现方法。这里我们使用注解的方式实现一下dao接口中的方法。

实现dao方法的注解之中允许书写多个字符串,用逗号隔开即可,到时候会自动拼接


@Mapper
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);      // 根据凭证查询LoginTicket

    @Update({
            "update login_ticket set status = #{status} where ticket = #{ticket}"
    })
    int updateStatus(String ticket, int status);    // 更新凭证状态为status
}

image-20220711115458882

关于 if 标签如何写

image-20220711142204638

数据访问层写完之后最好做一个测试,因为这里比较容易出错。

测试类:

@SpringBootTest
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = CommunityApplication.class)
public class MailTests {

    @Autowired
    private LoginTicketMapper loginTicketMapper;

    @Test
    public void testInsertLoginTicket(){
        LoginTicket loginTicket = new LoginTicket();
        loginTicket.setUserId(101);
        loginTicket.setTicket("abc");
        loginTicket.setStatus(0);
        loginTicket.setExpired(new Date(System.currentTimeMillis() + 1000 * 60 * 10));
        loginTicketMapper.insertLoginTicket(loginTicket);
    }

    @Test
    public void testSelectAndUpdateTicket(){
        LoginTicket loginTicket = loginTicketMapper.selectByTicket("abc");
        System.out.println(loginTicket);
        loginTicketMapper.updateStatus("abc", 1);       // 失效改为1
        loginTicket = loginTicketMapper.selectByTicket("abc");
        System.out.println(loginTicket);
    }
}

业务层(service)

登录是属于用户的行为,所以我们在UserService写登录的业务。

注入 LoginTicketMapper

登陆的时候可能成功、失败,失败原因有可能是账号没输入,账号不存在, 账号没有激活,等等,所以我们返回一个map,可以封装多种情况的返回结果,

// 为了保证UserService的完整性,所以全部粘过来了,其实我们这里写的只是1.注入LoginTicket 2.登录方法
@Service
public class UserService implements CommunityConstant {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private MailClient mailClient;

    @Autowired
    private TemplateEngine templateEngine;

    @Autowired
    private LoginTicketMapper loginTicketMapper;

    @Value("${community.path.domain}")      // @Value 注解会将 application.properties 配置文件中的指定值注入给它
    private String domain;                  // 域名

    @Value("${server.servlet.context-path}")
    private String contextPath;             // 项目名

    public User findUserById(int id){
        return userMapper.selectById(id);
    }

    // 注册方法:注册需要 username、password、email,所以传入一个 user
    // 返回的应该是相关信息,比如“账号已存在、邮箱不能为空”等等,所以为了封装多个内容,返回Map
    public Map<String, Object> register(User user){
        Map<String, Object> map = new HashMap<>();

        // 先进行空值处理 user 为 null
        // (username为null、password为null、email为null 或者 全部为 null)

        // 空值处理
        if(user == null){
            throw new IllegalArgumentException("参数不能为空");
        }

        // 账号/密码/邮箱 为空是业务上的漏洞,但是不是程序的错误,因此我们把信息封装到map里面返回给客户端
        if(StringUtils.isBlank(user.getUsername())){
            map.put("usernameMsg", "账号不能为空!");
            return map;
        }
        if(StringUtils.isBlank(user.getPassword())){
            map.put("passwordMsg", "密码不能为空!");
            return map;
        }
        if(StringUtils.isBlank(user.getEmail())){
            map.put("emailMsg", "邮箱不能为空!");
            return map;
        }

        // 验证账号是否已存在
        User u = userMapper.selectByName(user.getUsername());
        if(u != null){
            map.put("usernameMsg", "该账号已存在!");
            return map;
        }

        // 验证邮箱是否已被注册
        u = userMapper.selectByEmail(user.getEmail());
        if(u != null){
            map.put("emailMsg", "该邮箱已被注册!");
            return map;
        }

        // 如果可以执行到这里说明账号/密码/邮箱都不为空,且账号/邮箱都未注册

        // 这里我们要对用户输入的密码后面加一个salt(随机字符串)(password+salt 之后md5加密之后才真正存到数据库的)
        // salt 一般 5 位就够了
        user.setSalt(CommunityUtil.generateUUID().substring(0, 5));
        user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt()));
        user.setType(0);            // 0 表示普通用户
        user.setStatus(0);          // 0 表示没有激活
        user.setActivationCode(CommunityUtil.generateUUID());   //  设置激活码
        // 图片url:https://images.nowcoder.com/head/0t.png   注:从 0 - 1000 都是图片
        user.setHeaderUrl(String.format("https://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000))); //设置图片的路径
        user.setCreateTime(new Date());     // 设置注册时间

        userMapper.insertUser(user);        // 存入数据库,本来user的id是null,直行这句之后mysql生成id赋值给了它

        // 给用户发送 html 激活邮件,好带有链接
        // 给用户发送发送邮件

        // 给 themeleaf 模板传参
        Context context = new Context();       // themeleaf 包下的 Context
        context.setVariable("email", user.getEmail());


        // 项目路径下某个功能哪个用户在激活激活码是什么
        // http://localhost:8080/community/activation/101/code
        String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode();

        // 由于拼接url可能会造成空格问题,空格在浏览器地址栏中会解析成 %20 ,造成错误,所以我们要将url中的空格去掉
        url = url.replaceAll(" ", "");
        context.setVariable("url", url);


        // 调模板引擎生成动态网页   参数1:模板引擎的路径   参数2:数据
        // 会生成一个动态网页,其实就是一个字符串,模板引擎主要的作用就是生成动态网页
        String content = templateEngine.process("/mail/activation", context);

        // 发邮件    参数1:收件人    参数2:邮件标题      参数3:邮件内容
        System.out.println(user.getEmail());

        try {
            mailClient.sendMail(user.getEmail(), "激活账号", content);
        } catch (Exception e) {
            e.printStackTrace();
        }

        // 最后没有问题的话也返回map,且这里map是空的

        return map;
    }

    // 激活方法   参数1:用户id      参数2:激活码
    public int activation(int userId, String code){
        User user = userMapper.selectById(userId);

        if(user.getStatus() == 1){
            // 已经激活过了,说明这次是重复激活的。
            return ACTIVATION_REPEAT;      // 返回重复激活的激活码
        } else if(user.getActivationCode().equals(code)){
            // 还没有激活,且激活码正确,那么激活,并返回激活成功的激活码
            userMapper.updateStatus(userId, 1);
            return ACTIVATION_SUCCESS;
        } else {
            // 激活失败返回激活失败的激活码
            return ACTIVATION_FAILURE;
        }
    }

    // 参数1:用户名  参数2:密码  参数3:过期的秒数
    public Map<String, Object> login(String username, String password, int 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);       // 0 表示当前凭证有效
        loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000)); // *1000 是从毫秒换算成秒
        loginTicketMapper.insertLoginTicket(loginTicket);

        map.put("ticket", loginTicket.getTicket());  // 返回登录凭证给客户端
        return map;
    }
}

image-20220711163608928

image-20220711163721654

image-20220711163913712

视图层(Controller、themeleaf)

因为是登录,所以我们在LoginController里写Controller

UserService以前已经注入过了,不需要重新注入了。

注:controller中的不同方法的请求路径可以相同,但是请求方式一定要区分开

@Controller
public class LoginController implements CommunityConstant {

    private static Logger logger = LoggerFactory.getLogger(LoginController.class);

    @Autowired
    private UserService userService;

    @Autowired
    private Producer kaptchaProducer;

    @Value("${server.servlet.context-path}")
    private String contextPath;

    @RequestMapping(path = "/register", method = RequestMethod.GET)
    public String getRegisterPage(){
        return "/site/register";
    }

    // 处理请求,因为是浏览器向我们提交数据,所以请求是POST方式
    @RequestMapping(path = "/register", method = RequestMethod.POST)
    public String register(Model model, User user){
        // 注意:因为User声明在了方法的参数里,SpringMVC会自动把user存到model里

        Map<String, Object> map = userService.register(user);

        // map为空说明注册成功,我们应该提示浏览器注册成功,然后跳转到首页页面,之后激活之后才跳转到登录页面
        if(map == null || map.isEmpty()){
            model.addAttribute("msg", "注册成功,我们已经向您的邮箱发送了一封激活邮件,请尽快激活!");
            model.addAttribute("target", "/index");
            return "/site/operate-result";
        } else {
            // 有错误,传给页面信息并返回登录页面
            model.addAttribute("usernameMsg", map.get("usernameMsg"));
            model.addAttribute("passwordMsg", map.get("passwordMsg"));
            model.addAttribute("emailMsg", map.get("emailMsg"));
            return "/site/register";
        }
    }

    // http://localhost:8080/community/activation/101/code
    @RequestMapping(path = "activation/{userId}/{code}", method = RequestMethod.GET)
    public String activation(Model model,
                             @PathVariable("userId") int userId,
                             @PathVariable("code") String code){
        // 这个结果的含义可以从结果中识别,所以也需让LoginController实现CommunityConstant接口
        int result = userService.activation(userId, code);
        // 无论成功还是失败,都跳转到中转页面只是返回给中转页面的提示信息不同,然后从中转页面跳转到哪里根据激活是否成功决定
        if(result == ACTIVATION_SUCCESS){
            // 激活成功跳转到登录页面
            model.addAttribute("msg", "激活成功,您的账号已经可以正常使用了!");
            model.addAttribute("target", "/login");  // 返回给服务器,服务器跳转到登录的controller
        } else if(result == ACTIVATION_REPEAT){
            // 邮箱之前已经激活过了,重复了
            model.addAttribute("msg", "无效操作,该账号已经激活过了!");
            model.addAttribute("target", "/index");  // 跳转到展示首页的controller
        } else {
            // 激活失败
            model.addAttribute("msg", "激活失败,您提供的激活码不正确!");
            model.addAttribute("target", "/index");  // 跳转到展示首页的controller
        }
        return "/site/operate-result";
    }

    @RequestMapping(path = "/login", method = RequestMethod.GET)
    public String getLoginPage(){
        return "/site/login";
    }

    @RequestMapping(path = "/kaptcha", method = RequestMethod.GET)
    public void getLKaptcha(HttpServletResponse response, HttpSession session){
        // 生成验证码
        String text = kaptchaProducer.createText();
        BufferedImage image = kaptchaProducer.createImage(text);    // 将验证码传入生成图片

        // 将验证码存入session
        session.setAttribute("kaptcha", text);

        // 将图片输出给浏览器
        response.setContentType("image/png");       // 声明返回给浏览器的是什么可视的数据
        // response向浏览器做响应我们需要获取它的输出流
        try {
            ServletOutputStream os = response.getOutputStream();
            // 这个流不用关,因为是SpringMVC维护,会自动关
            ImageIO.write(image, "png", os);    // 输出哪个图片; 格式; 哪个流输出
        } catch (IOException e) {
            logger.error("响应验证码失败:" + e.getMessage());
        }
    }

    @RequestMapping(path = "/login", method = RequestMethod.POST)
    //              参数1:用户名   参数2:密码   参数3:验证码  参数4:是否勾上(登录页面有一个"记住我",这个参数表示)
    // 用户打开登录页面生成了验证码,放到了session里,所以也要声明session,把验证码从session里取出来
    // 如果登陆成功了,我们最终要把ticket发放给客户端好让它保存,要想使用cookie,我们还需要HttpServletResponse对象
    public String login(String username, String password, String code, boolean rememberme,
                        Model model, HttpSession session, HttpServletResponse response){
        /**
         * 如果方法中有复杂参数,会自动添加到model,但是String、boolean这样的简单参数不会自动加到model里
         * 解决方法:1. 手动加到model里  2. 或者从request里面取
         */
        // 检查验证码
        // 获取session中的验证码
        String kaptcha = (String)session.getAttribute("kaptcha");
        if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){
            // 如果获取的验证码为空或者用户传入的验证码为空或者获取的验证码和用户输入的验证码不同
            model.addAttribute("codeMsg", "验证码不正确!");
            return "/site/login";       // 返回登录页面
        }

        // 检查账号、密码(交给Service处理)
        int expiredSeconds = rememberme ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
        Map<String, Object> map = userService.login(username, password, expiredSeconds);
        if(map.containsKey("ticket")){
            // 成功登录
            // 给客户端发一个cookie,里面包含登录凭证
            Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
            // 设置这个cookie生效的路径,凭证有效的路径应该包含在整个项目之内
            cookie.setPath(contextPath);
            cookie.setMaxAge(expiredSeconds);       // 设置cookie的生存时间
            response.addCookie(cookie);
            // 重定向到首页
            return "redirect:/index";
        } else {
            // 登陆失败
            model.addAttribute("usernameMsg", map.get("usernameMsg"));
            model.addAttribute("passwordMsg", map.get("passwordMsg"));
            return "/site/login";       // 返回登录页面
        }
    }

}

登陆的时候没有勾上“记住我”,那依然是把它存到库里,只是存的时间短一点;如果勾上“记住我”,也是把它存到库里,但是存的时间很长, 所以我们定义两个常量,常量我们还是写到CommunityConstant这个接口中因为之前 LoginController 已经实现了CommunityConstant这个接口,所以我们可以直接开始用这个里面的两个时间常量了。

image-20220711175614042

image-20220711181711348

参数不要写死,注入 contextPath

image-20220711193610007

最后处理一下 login.html 表单,

image-20220711193823108

image-20220711193921355

param.username 相当于 request.getUserName()

开发到这里之后就可以进行测试了,看是否可以登陆成功

3. 退出

因为之前dao层已经实现过更新,所以这里直接写业务层(service)

public void logout(String ticket){
    loginTicketMapper.updateStatus(ticket, 1);      // 改变凭证状态为1,表示凭证无效
}

image-20220711195445756

接下来是Controller

@RequestMapping(path = "/logout", method = RequestMethod.GET)
public String logout(@CookieValue("ticket") String ticket){
    userService.logout(ticket);
    return "redirect:/login";   // 重定向的时候默认就是get请求的login
}

image-20220711195532055

最后我们还要修改一下 index.html 的退出按钮

image-20220711195634830

之后进行测试就可以了