手把手教你用Java实现一套简单的鉴权服务(SpringBoot,SSM)(万字长文)

分类专栏:
经验分享

文章标签:
Java
SSM
鉴权服务
原创

前言

时遇JavaEE作业,题目要求写个简单web登录程序,按照老师的意思是用servlet、jsp和jdbc完成。本着要么不做,要做就要做好的原则,我开始着手完成此次作业(其实也是写实训作业的用户鉴权部分),而之前写项目的时候也有相关经验,这次正好能派上用场。

一、何为鉴权服务

引用百度百科的话说

鉴权(authentication)是指验证用户是否拥有访问系统的权利。

鉴权包括两个方面:

  • 用户鉴权,网络对用户进行鉴权,防止非法用户占用网络资源。
  • 网络鉴权,用户对网络进行鉴权,防止用户接入了非法的网络,被骗取关键信息。

而我们这里的鉴权主要指用户鉴权,即如何确认“你是你”。最简单的体现便是平常用的用户登录登出。

现今大部分系统都会有自己的鉴权服务,它是用户与系统交互的第一步,系统需要一系列步骤明白你是谁,你可以做哪些事,明白了这些之后它才能更好的服务于你。

二、利用servlet+jdbc实现简单的用户登录程序

1.明确思路

首先,我们要仔细思考一下我们到底需要什么?

先让我们回想一下一般的登录是如何做的呢?

对于网页,首先会出现一个登录页面,然后呢,输入账号密码,点击登录,就会弹出成功/失败的页面。

那如何去判断成功/失败呢?

思考一下,最简单的方法便是拿到前端传来的数据之后便将其拿到数据中去查,看看密码是不是一样,然后给前端回复说——我找到了,他就是XXX或者我找不到他的记录,让他重新输入账号密码。

然后前端对此回复做出相应的操作,比如登录成功便跳转到首页,失败让用户重新输入。

2.手把手教你实现一个简单的web登录程序

出于某些原因,我这里手把手教你如何实现一个简单的web登录程序。

①创建web项目

打开idea,新建一个web项目

在这里插入图片描述

这里为了方便jar包的管理,选择maven结构的项目(至于什么是maven结构,不懂的可以百度,了解概念即可),然后选择从原型创建,选择webapp(这里只是方便,你也可以选择空项目,不过会费点时间)。

在这里插入图片描述

点击下一步,输入项目名称

在这里插入图片描述

这里选择相应的maven,idea里有自带的maven和jar包仓库,不过我是自己去官网下了一个(不下也完全可以)。

在这里插入图片描述

选择完成,这样一个最简单的项目结构就出来了。

在这里插入图片描述

接下来需要配置一下pom.xml,因为要用到jdbc和tomcat的jar包(毕竟都是调用人家的接口(笑哭))

<dependencies>
    <dependency>
      <groupId>org.apache.tomcat.embed</groupId>
      <artifactId>tomcat-embed-core</artifactId>
      <version>9.0.37</version>
    </dependency>
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>8.0.20</version>
    </dependency>
  </dependencies>

(加在project标签里就行),上面配置的意思就是导入两个第三方工具包

②编写简单的登录页面

这里我既想要好看,又想偷懒,所以用了layui框架的模板

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>后台管理-登陆</title>
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta http-equiv="Access-Control-Allow-Origin" content="*">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <meta name="apple-mobile-web-app-status-bar-style" content="black">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="format-detection" content="telephone=no">
    <link rel="stylesheet" href="../lib/layui-v2.6.3/css/layui.css" media="all">
    <!--[if lt IE 9]>
    <script src="https://cdn.staticfile.org/html5shiv/r29/html5.min.js"></script>
    <script src="https://cdn.staticfile.org/respond.js/1.4.2/respond.min.js"></script>
    <![endif]-->
    <style>
        .main-body {top:50%;left:50%;position:absolute;-webkit-transform:translate(-50%,-50%);-moz-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);-o-transform:translate(-50%,-50%);transform:translate(-50%,-50%);overflow:hidden;}
        .login-main .login-bottom .center .item input {display:inline-block;width:227px;height:22px;padding:0;position:absolute;border:0;outline:0;font-size:14px;letter-spacing:0;}
        .login-main .login-bottom .center .item .icon-1 {background:url(../images/icon-login.png) no-repeat 1px 0;}
        .login-main .login-bottom .center .item .icon-2 {background:url(../images/icon-login.png) no-repeat -54px 0;}
        .login-main .login-bottom .center .item .icon-3 {background:url(../images/icon-login.png) no-repeat -106px 0;}
        .login-main .login-bottom .center .item .icon-4 {background:url(../images/icon-login.png) no-repeat 0 -43px;position:absolute;right:-10px;cursor:pointer;}
        .login-main .login-bottom .center .item .icon-5 {background:url(../images/icon-login.png) no-repeat -55px -43px;}
        .login-main .login-bottom .center .item .icon-6 {background:url(../images/icon-login.png) no-repeat 0 -93px;position:absolute;right:-10px;margin-top:8px;cursor:pointer;}
        .login-main .login-bottom .tip .icon-nocheck {display:inline-block;width:10px;height:10px;border-radius:2px;border:solid 1px #9abcda;position:relative;top:2px;margin:1px 8px 1px 1px;cursor:pointer;}
        .login-main .login-bottom .tip .icon-check {margin:0 7px 0 0;width:14px;height:14px;border:none;background:url(../images/icon-login.png) no-repeat -111px -48px;}
        .login-main .login-bottom .center .item .icon {display:inline-block;width:33px;height:22px;}
        .login-main .login-bottom .center .item {width:288px;height:35px;border-bottom:1px solid #dae1e6;margin-bottom:35px;}
        .login-main {width:428px;position:relative;float:left;}
        .login-main .login-top {height:117px;background-color:#148be4;border-radius:12px 12px 0 0;font-family:SourceHanSansCN-Regular;font-size:30px;font-weight:400;font-stretch:normal;letter-spacing:0;color:#fff;line-height:117px;text-align:center;overflow:hidden;-webkit-transform:rotate(0);-moz-transform:rotate(0);-ms-transform:rotate(0);-o-transform:rotate(0);transform:rotate(0);}
        .login-main .login-top .bg1 {display:inline-block;width:74px;height:74px;background:#fff;opacity:.1;border-radius:0 74px 0 0;position:absolute;left:0;top:43px;}
        .login-main .login-top .bg2 {display:inline-block;width:94px;height:94px;background:#fff;opacity:.1;border-radius:50%;position:absolute;right:-16px;top:-16px;}
        .login-main .login-bottom {width:428px;background:#fff;border-radius:0 0 12px 12px;padding-bottom:53px;}
        .login-main .login-bottom .center {width:288px;margin:0 auto;padding-top:40px;padding-bottom:15px;position:relative;}
        .login-main .login-bottom .tip {clear:both;height:16px;line-height:16px;width:288px;margin:0 auto;}
        body {background:url(../images/loginbg.png) 0% 0% / cover no-repeat;position:static;font-size:12px;}
        input::-webkit-input-placeholder {color:#a6aebf;}
        input::-moz-placeholder {/* Mozilla Firefox 19+ */            color:#a6aebf;}
        input:-moz-placeholder {/* Mozilla Firefox 4 to 18 */            color:#a6aebf;}
        input:-ms-input-placeholder {/* Internet Explorer 10-11 */            color:#a6aebf;}
        input:-webkit-autofill {/* 取消Chrome记住密码的背景颜色 */            -webkit-box-shadow:0 0 0 1000px white inset !important;}
        html {height:100%;}
        .login-main .login-bottom .tip {clear:both;height:16px;line-height:16px;width:288px;margin:0 auto;}
        .login-main .login-bottom .tip .login-tip {font-family:MicrosoftYaHei;font-size:12px;font-weight:400;font-stretch:normal;letter-spacing:0;color:#9abcda;cursor:pointer;}
        .login-main .login-bottom .tip .forget-password {font-stretch:normal;letter-spacing:0;color:#1391ff;text-decoration:none;position:absolute;right:62px;}
        .login-main .login-bottom .login-btn {width:288px;height:40px;background-color:#1E9FFF;border-radius:16px;margin:24px auto 0;text-align:center;line-height:40px;color:#fff;font-size:14px;letter-spacing:0;cursor:pointer;border:none;}
        .login-main .login-bottom .center .item .validateImg {position:absolute;right:1px;cursor:pointer;height:36px;border:1px solid #e6e6e6;}
        .footer {left:0;bottom:0;color:#fff;width:100%;position:absolute;text-align:center;line-height:30px;padding-bottom:10px;text-shadow:#000 0.1em 0.1em 0.1em;font-size:14px;}
        .padding-5 {padding:5px !important;}
        .footer a,.footer span {color:#fff;}
        @media screen and (max-width:428px) {.login-main {width:360px !important;}
            .login-main .login-top {width:360px !important;}
            .login-main .login-bottom {width:360px !important;}
        }
    </style>
</head>
<body>
<div class="main-body">
    <div class="login-main">
        <div class="login-top">
            <span>LayuiMini后台登录</span>
            <span class="bg1"></span>
            <span class="bg2"></span>
        </div>
        <form class="layui-form login-bottom" action="/login" method="post">
            <div class="center">
                <div class="item">
                    <span class="icon icon-2"></span>
                    <input type="text" name="uname" lay-verify="required"  placeholder="请输入登录账号" maxlength="24"/>
                </div>

                <div class="item">
                    <span class="icon icon-3"></span>
                    <input type="password" name="pwd" lay-verify="required"  placeholder="请输入密码" maxlength="20">
                    <span class="bind-password icon icon-4"></span>
                </div>

            </div>
            <div class="tip">
                <span class="icon-nocheck"></span>
                <span class="login-tip">保持登录</span>
                <a href="javascript:" class="forget-password">忘记密码?</a>
            </div>
            <div class="layui-form-item" style="text-align:center; width:100%;height:100%;margin:0px;">
                <button class="login-btn" type="submit" lay-submit="" lay-filter="login">立即登录</button>
            </div>
        </form>
    </div>
</div>
<div class="footer">
    ©版权所有 2014-2018 叁贰柒工作室<span class="padding-5">|</span><a target="_blank" href="http://www.miitbeian.gov.cn">粤ICP备16006642号-2</a>
</div>
<script src="../lib/layui-v2.6.3/layui.js" charset="utf-8"></script>
<script>
    //原本想用json的post发送,结果发现后端数据得自己解析,为了降低难度,直接用form表单的post提交,这样后端直接拿数据即可(不然还得解析Json数据)
    // layui.use(['form','jquery'], function () {
    //     var $ = layui.jquery,
    //         form = layui.form,
    //         layer = layui.layer;
    //
    //     // 登录过期的时候,跳出ifram框架
    //     if (top.location != self.location) top.location = self.location;
    //
    //     $('.bind-password').on('click', function () {
    //         if ($(this).hasClass('icon-5')) {
    //             $(this).removeClass('icon-5');
    //             $("input[name='pwd']").attr('type', 'password');
    //         } else {
    //             $(this).addClass('icon-5');
    //             $("input[name='pwd']").attr('type', 'text');
    //         }
    //     });
    //
    //     $('.icon-nocheck').on('click', function () {
    //         if ($(this).hasClass('icon-check')) {
    //             $(this).removeClass('icon-check');
    //         } else {
    //             $(this).addClass('icon-check');
    //         }
    //     });
    //
    //     // 进行登录操作
    //     form.on('submit(login)', function (data) {
    //         data = data.field;
    //         if (data.uname == '') {
    //             layer.msg('用户名不能为空');
    //             return false;
    //         }
    //         if (data.pwd == '') {
    //             layer.msg('密码不能为空');
    //             return false;
    //         }
    //         $.ajax({
    //             url:'/login',
    //             method:'post',
    //             data:data,
    //             dataType:'JSON',
    //             success:function(res){
    //                 if (res.msg==='登录成功'){
    //                     layer.msg('登录成功', function () {
    //                         window.location = '../index.html';
    //                     });
    //                 }else {
    //                     layer.msg("登录失败");
    //                 }
    //             },
    //             error:function (data) {
    //             }
    //         }) ;
    //
    //
    //         return false;
    //     });
    // });
</script>
</body>
</html>

当然以上代码有一部分注释掉了,原因是如果用JSON格式发送post请求,后端的servlet(准确的说是Tomcat的解析)并没有帮我们解析封装这部分数据,所以我们无法直接get到,得自己另外解析数据,当然也有一些第三方的工具包可以帮我们做这些事情(如阿里的fastjson等),这里为了使其更加简单,所以采用表单提交post请求的方式,这样解析的工作就不用我们做了。

效果是这样的: 在这里插入图片描述

如果你没学过layui或者对前端不太行,你也可以这样

<!DOCTYPE html>
<htmllang="en">
<head>
    <meta charset="UTF-8">
    <title>用户登录</title>
</head>
<body>
<form action="/login" method="post">
    用户名:<input type="text" name="uname">
    密码:<input type="password" name="pwd">
    <input type="submit" value="login">
</form>
 
</body>
</html>

一样的功能,不过看上去的效果就不怎么好了。

③编写servlet程序

当有了前端的页面,看上去好了很多,但实质校验的程序我们还没有写。

想象一下我们就是后端程序,当前端的数据历经艰险,从错综复杂的网络中到达我们的服务器,然后经过系统分发到相应端口,这时恰在此端口的tomcat程序接受到了HTTP请求并对其封装,经过一系列骚操作后分发到了我们手中,而我们要做的就是拿着这个封装好的请求进行校验操作,然后对返回对象进行相应修改。

而这也是servlet类所需要做的(如果你想更好的理解servlet,可以看看bravo1988的回答),

package com.dreamchaser.loginTest;

import com.dreamchaser.loginTest.mapper.UserMapper;

import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class LoginServlet extends HttpServlet {
    static UserMapper userMapper=UserMapper.getUserMapper();

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        doGet(request,response);
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String uname=req.getParameter("uname");
        String pwd=req.getParameter("pwd");
        ServletOutputStream outputStream = resp.getOutputStream();
        String result;
        if (pwd.equals(userMapper.getPwdByName(uname))){
            //响应
            result="登录成功";
        }else {
            result="登录失败";
        }
        outputStream.write(result.getBytes());
    }
}

你可能会疑惑这个UserMapper是什么,别急,后面会介绍。

④封装jdbc操作,编写简单的数据库连接池

在操作数据库之前,最好写个简单的数据库连接池。一个是简化我们的操作,一个是节省开销,提高性能(Connection是个非常耗费资源的对象,频繁的创建和回收将会是一笔巨大的开销)

package com.dreamchaser.loginTest.utils;

import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;

/**
 * 一个简单的数据库连接池
 */
public class Pool {
    private static Driver driver;

    static {
        try {
            driver = new com.mysql.cj.jdbc.Driver();
            DriverManager.registerDriver(driver);
        } catch (SQLException throwables) {
            throwables.printStackTrace();
        }
    }

    private static Map<Connection,Integer> pool=new HashMap<>();
    private static String url="jdbc:mysql://localhost:3306/depository?serverTimezone=Asia/Shanghai";
    private static String user="root";
    private static String password="jinhaolin";


    /**
     * 从连接池中获取一个空闲连接,如果没有则创建一个新的连接返回
     * synchronized确保并发请求时,数据库连接的正确性
     * @return
     */
    public synchronized static Connection getConnection(){
        for (Map.Entry entry:pool.entrySet()){
            if (entry.getValue().equals(1)) {
                entry.setValue(0);
                return (Connection) entry.getKey();
            }
        }

        Connection connection=null;
        try {
            connection=DriverManager.getConnection(url,user,password);
            pool.put(connection,0);
        } catch (SQLException throwables) {
            throwables.printStackTrace();
        }
        return connection;
    }

    /**
     * 释放connection连接对象
     * @param connection
     */
    public synchronized static void releaseConnection(Connection connection){
        pool.put(connection,1);
    }

}

当然上述实现非常简陋,并发性能也不是很好,高并发时可能还会发生OOM,不过凑活着用吧(笑哭)。

⑤操作数据库

package com.dreamchaser.loginTest.mapper;

import com.dreamchaser.loginTest.utils.Pool;

import java.sql.*;

/**
 * 查询用户的Mapper
 */
public class UserMapper {
    static UserMapper userMapper=new UserMapper();
    //单例
    public static UserMapper getUserMapper(){
        return userMapper;
    }
    private UserMapper(){
    }
    //默认数据库中用户名唯一
    public String getPwdByName(String name){
        Connection connection= Pool.getConnection();
        try {
            PreparedStatement statement=connection.prepareStatement("select pwd from `user` where uname=?");
            statement.setString(1,name);
            ResultSet rs=statement.executeQuery();
            //resultSet初始下标无法访问,要调用next方法后移一位
            rs.next();
            return rs.getString(1);
        } catch (SQLException throwables) {
            throwables.printStackTrace();
        }
        return null;
    }
}

这里采用单例的设计模式,保证UserMapper对象只有一个。(非常简陋,实现也不优雅,看着自己的代码,突然感觉框架好方便啊(笑哭))

这里的作用就是根据用户名查询密码。

⑥配置web.xml

虽然写了servlet,但是tomcat并不知道你这个servlet的类在哪啊,所以必须让tomcat知道,配置web.xml的目的就是通知tomcat在哪(更准确的说是servlet容器)的一种方式(当然也可以用注解)。 配置如下:

<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>
  <servlet>
    <servlet-name>LoginServlet</servlet-name>
    <servlet-class>com.dreamchaser.loginTest.LoginServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>LoginServlet</servlet-name>
    <url-pattern>/login</url-pattern>
  </servlet-mapping>

</web-app>

servlet-class里写你这个Servlet类的路径即可。

⑦idea运行配置

idea配置还是比较方便的。

点击编辑配置, 在这里插入图片描述

点击+(添加按钮),选择tomcat服务器(选哪个都可以,我选了tomcat本地) 在这里插入图片描述

然后选择相应的服务器程序,配置项目访问的端口,就是tomcat在哪个端口运行(注意不要占用已有端口,默认8080,我这里是因为8080被占了,所以用了9090) 在这里插入图片描述

这里还得配置一下工件,因为项目要运行一般有两种方式:

  • 一种是打成war包放在tomcat的webapps目录下
  • 一种是打成jar包直接运行(SpringBoot就是用这种方式,因为它内置tomcat)

在这里插入图片描述

这里工件的作用就是打成war包,至于每次运行部署?idea都会帮你搞定!是不是很方便?

这里那个应用程序上下文的作用就是给访问路径加个前缀 在这里插入图片描述 一般直接写成"/"就行了,这样我们要访问login.html,只需访问http://localhost:9090/login.html就行了,是不是很方便?

⑧运行程序

点击运行 在这里插入图片描述

访问localhost:9090/login.html(我因为是在login.html外面放了一个pages包,所以路径是http://localhost:9090/pages/login.html) 在这里插入图片描述

访问成功,试试账号密码 在这里插入图片描述

我现在数据库里只有root这一条数据,试试效果

输入错误的密码 在这里插入图片描述

输入正确的密码

在这里插入图片描述

到这里,我们松了一口气,终于完成了简单的登录功能。

三、回顾

别急,我们虽然实现了登录这个功能,但是这个实现是在太简陋了,各方各面都没考虑,返回页面也只登录成功,登录失败的提示。 我们回顾一下,仔细想想有哪些问题。

1.密码未加密裸奔

我们在做上面的登录时查询时,密码是查询出来直接比对的,也就是说数据库的密码是明文存储,而注册登录请求中密码都是明文传输,这样做的安全性极低,当黑客破解进入了你的数据库时,你的数据库的账户信息都在“裸奔”,比如前些年的csdn密码泄露事件 在这里插入图片描述

如果我们存储在数据库的用户密码是加密过的,那么就算黑客进入了你的数据库,损失也不会像明文存储那样大。

2.登录信息未存储

对于这个登录操作,登录成功后并未做其他处理,也就是说每次访问都要登录(如果对请求进行了拦截),或者这个登录操作就是摆设,用户访问其他资源依旧畅通无阻。

3.对于其他资源并未进行权限管理

对于其他资源,如果不进行权限管理,那么登录认证便失去了意义,不如做成一个静态网页来的省事。

四、优化设计

针对上述缺点,我们可以进行以下改进:

1.密码加密存储

针对密码未加密裸奔的问题,我们可以选择在注册的时候对密码进行加密,然后存储;对于登录功能,我们对前端传过来的密码进行加密,再根据这个密码去数据库中取数据,这样我们就实现了对密码的加密存储

2.存储登录信息

对于登录操作,我们必须记录下此次登录状态,并在该用户继续访问其他资源时予以放行,避免用户多次进行登录操作

3.对资源进行管理

对于系统资源我们必须进行管理,在用户没有相应权限时拒绝用户的访问请求,这个可以用过滤器或者SpringBoot的拦截器实现。

五、关于鉴权问题

在正式讲思路之前,我还是想聊聊鉴权问题。

1.Cookie/Session机制

关于这个问题我不得不说说cookie/session机制(想了解的具体可以看这篇cookie和session的详解与区别)。

总的来说,就是浏览器中有个叫做cookie的东西(其实就是个文件),它可以用来存储一些信息,每次发送请求时,浏览器会自动把cookie字段信息加在请求头里发送出去 在这里插入图片描述

这有什么用呢?

学过计算机网络的人应该都清楚我们的http请求是无法保存状态的,通俗点来讲就是这次的请求无法知道上次的请求是什么,而这也对一些场景带来的一些不便,就比如说登录,我们就需要保存上次登录的信息。

可http请求无法保存状态,所以我们必须把一些信息写入到下次的请求里,保证服务器知道之前的关键信息,以便对之后的请求做出特定的操作。

而cookie便是解决这个问题而出现的,当我们需要存储一些信息(状态),就可以把信息存入cookie,浏览器每次发送请求时都会把cookie放在请求头中(但这个要注意跨域问题,cookie在遇到跨域访问时会失效,不过这个无关此次主题,就不细讲了,感兴趣的自行百度吧)。

总而言之,cookie就是存储在浏览器(客户端)的数据(文件),每次访问时会带上对应的cookie。

而session是什么呢? session和cookie类似,也是用来存放信息的,不过它是放在服务器上的。不过呢,session的本质是存在于服务器内存中的对象,阅读源码我们可以发现其对应的就是一个ConcurrentMap(线程安全的map容器)

在这里插入图片描述

每一个客户端会对应服务端一个session对象,而如何得到的关键就在于cookie中的JSESSIONID(tomcat默认是这个名字,名称可以变,但用法是一样的),其值便对应这map容器的键,而map的值便是session对象。这样每次用户发送请求来时,服务器就能准确的找到对应的session对象了。 在这里插入图片描述

2.用Cookie/Session解决鉴权问题?

明白了Cookie/Session的机制以后,我们不难设计出一套简单的登录方案——登录成功后在对应的session对象中存放User信息并设置失效时间,每次访问资源都看看session中有没有对应user对象,如果有就说明之前登录过了,直接通过即可,否则说明未登录,此时可以跳转至登录页面让用户进行登录。

这一切看似都很完美,从某种角度上来说确实如此,但它没有缺点吗?

Cookie/Session机制的缺点

1.无法解决跨域问题

在跨域访问时,cookie会失效,这是为了防止csrf攻击(跨站请求伪造),但对于开发者来说造成了一定的困扰,因为现实中的服务器不可能只有一台,大概率是集群分布,虽然可以用反向代理避免跨域访问,但终究是有局限之处的。

2.session机制依赖于cookie

从cookie/session机制中我们不难看出,session的实现依赖于前端的cookie,因为其session的确定必须要前端请求中cookie,没有了cookie,session是无法确定的。

而这会带来什么问题呢?那就是对于多端访问,如手机App端,其并没有cookie的直接实现(可以实现,其实也就是在请求头中加入cookie字段,但使用此方式并不普遍,也挺麻烦的),如果cookie很难使用,那么session也无法使用。

3.可拓展性不强

如果将来搭建了多个服务器,虽然每个服务器都执行的是同样的业务逻辑,但是session数据是保存在内存中的(不是共享的),用户第一次访问的是服务器1,当用户再次请求时可能访问的是另外一台服务器2,服务器2获取不到session信息,就判定用户没有登陆过。

与此同时,当你使用session的时候你会发现一个很尴尬的事情——你无法直接获取到存放session的map(除非你用反射),这样就导致你的操作受限,比如你想以某个身份强制下线某个用户时,session将会变得力不从心。

4.服务器压力增大

session存在于服务器内存中,如果session很多,那么服务器压力便会很大。会频繁触发gc操作,导致服务器响应变慢,吞吐量下降。

5.安全性问题

Cookie/Session机制并不是绝对安全,你必须小心应对,当然我接下来说的token方式同样也有这样那样的问题,但是我们要明白一件事情——没有绝对安全的系统!

当前的所谓安全措施不过是在增加黑客入侵系统的成本,但你要注意的是你在增加黑客入侵的难度和成本的同时,也同样在增加自己系统的维护成本,它必然是以一定的性能作为代价的

所以如何权衡安全和性能,这是永远是一件值得我们深思的事情

3.使用token机制解决鉴权问题

什么是token呢?

事实上它只是我们自己实现的一套类似cookie/Session的机制。

至于为啥叫token?

你也可以叫它cat,dog之类的,只要你喜欢,随便你怎么取名字(笑哭)。

好了,开个玩笑,咱们回到正题,在我看来,token只是脱胎于cookie/session的一套机制,它的实现原理几乎是和cookie/session一模一样的(9成像,当然也有很多根据自己业务的变种)。

如果说cookie/session机制可以描述为下图:

在这里插入图片描述

那么token机制可以描述为以下形式:

在这里插入图片描述

怎么样?是不是很像?其实它们核心原理是一样的。

那token机制相较于cookie/session机制有啥好处呢?

  • 1.可以直接操作token令牌池
  • 2.对于手机App端友好
  • 3.跨域问题可以间接解决
  • 4.对于服务器集群,token令牌池可以放在redis数据库中(当然也可以是其他方案),这样可以实现用户登录状态多服务器共享

其实,总的来说,就只有一条(笑哭),那就是灵活!因为token机制是我们自己实现的(当然也可以借助框架),这样操作这些东西的时候就不必拘泥于条条框框,可以根据自己的业务需求制定适合的鉴权方案。

当然,相较于cookie/session机制而言,它也有个巨大的弊端——在网页应用中,使用token机制会比使用cookie/session机制麻烦很多,所有都得“从头再来”,不像cookie/session可以开箱即用。

六、用SpringBoot+SSM实现一套简单的鉴权服务(注册,登录,权限控制)

这里我是用token来实现鉴权服务的。 以下是我画的大致流程图(可能有点丑,有点乱) 在这里插入图片描述

在展示代码实现时,你可能会对某些类比较疑惑,以下是对这些类的说明:

  • RestResponse 这是我用来封装响应格式的,Status用来封装响应状态
  • CrudUtil 这是我用来封装CRUD操作的工具类,该类主要为了简化controller的响应操作

同时我会省略Service层和Dao层实现

1.注册服务

①注册页面

<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <title>layui</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <link rel="stylesheet"  href="static/css/public.css">
    <link rel="stylesheet"  href="static/lib/layui-v2.6.3/css/layui.css">
    <style>
        body {
            background: url("static/images/loginbg.png") 0% 0% / cover no-repeat;
            position: static;
            font-size: 12px;
        }
    </style>
</head>
<body>
<div class="layui-container">
    <div class="layui-main layui-card" style="width: 500px;border-radius: 10px">
        <fieldset class="layui-elem-field" style="margin-top: 20%">
            <legend style="font-size: 30px;padding-top: 20px;text-align: center">用户注册</legend>
            <div class="layui-field-box">
                <div class="layui-form layuimini-form" style="margin: 20px;margin-top: 30px">
                    <div class="layui-form-item">
                        <label class="layui-form-label required">用户名</label>
                        <div class="layui-input-block">
                            <input type="text" name="uname" lay-verify="required" lay-reqtext="用户名不能为空"
                                   placeholder="请输入用户名" value="" class="layui-input">
                            <tip>填写自己真实姓名</tip>
                        </div>
                    </div>
                    <div class="layui-form-item">
                        <label class="layui-form-label required">性别</label>
                        <div class="layui-input-block">
                            <input type="radio" name="sex" value="男" title="男" checked="">
                            <input type="radio" name="sex" value="女" title="女">
                        </div>
                    </div>

                    <div class="layui-form-item">
                        <label class="layui-form-label required">手机</label>
                        <div class="layui-input-block">
                            <input type="number" name="phone" lay-verify="phone" placeholder="请输入手机号" value=""
                                   class="layui-input">
                        </div>
                    </div>
                    <div class="layui-form-item">
                        <label class="layui-form-label required">邮箱</label>
                        <div class="layui-input-block">
                            <input id="email" type="email" name="email" lay-verify="email" placeholder="请输入邮箱" value=""
                                   class="layui-input">
                        </div>
                    </div>
                    <div class="layui-form-item">
                        <label class="layui-form-label required">密码</label>
                        <div class="layui-input-block">
                            <input type="text" name="pwd" lay-verify="required" placeholder="请输入密码" value=""
                                   class="layui-input">
                        </div>
                    </div>
                    <div class="layui-form-item">
                        <label class="layui-form-label required">入职时间</label>
                        <div class="layui-input-block">
                            <input type="text" name="entryDate" id="date" lay-verify="date" placeholder="请选择入职时间"
                                   autocomplete="off" class="layui-input">
                        </div>
                    </div>
                    <div class="layui-form-item">
                        <label class="layui-form-label required" style="display: inline">邮箱验证码</label>
                        <input type="text" class="layui-input" name="code" placeholder="请输入验证码" lay-verify="required"
                               maxlength="5" style="width:160px;display: inline">
                        <button id="saveBtn" lay-filter="saveBtn" class="layui-btn layui-btn-normal layui-btn-sm"
                                style="display: inline;margin-left: 10px">发送验证码
                        </button>
                    </div>


                    <div class="layui-form-item" style="margin-top: 20px">
                        <div class="layui-input-block">
                            <button class="layui-btn layui-btn-lg" style="width: 150px" lay-submit
                                    lay-filter="registerBtn">注册
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </fieldset>


    </div>
</div>

<script src="static/lib/layui-v2.6.3/layui.js" charset="utf-8"></script>
<script>
    layui.use(['form', 'layer', 'laydate','element'], function () {
        var form = layui.form,
            layer = layui.layer,
            laydate = layui.laydate,
            element=layui.element,
            $ = layui.$;

        //日期
        laydate.render({
            elem: '#date'
        });

        //监听提交
        $('#saveBtn').bind('click', function () {
            var email = $('#email').val();
            if (email===''||email==null){
                layer.msg("请输入正确的邮箱!");
            }else {
                $.ajax({
                    url: "/sendCode",
                    data:'{"email":'+JSON.stringify(email)+'}',
                    type: "post",
                    dataType: 'JSON',
                    contentType: "application/json;charset=utf-8",
                    success: function (data) {
                        if (data.status !== 200) {
                            layer.msg(data.statusInfo.message);//失败的表情
                            return;
                        } else {
                            layer.msg("验证码发送成功,请前往邮箱查看", {
                                icon: 6,//成功的表情
                                time: 1000 //1秒关闭(如果不配置,默认是3秒)
                            }, function () {

                            });
                        }
                    }
                });
            }
        });
        //监听提交
        form.on('submit(registerBtn)', function (data) {
            $.ajax({
                url: "/register",
                data: JSON.stringify(data.field),
                type: "post",
                dataType: 'JSON',
                contentType: "application/json;charset=utf-8",
                success: function (data) {
                    if (data.status !== 200) {
                        layer.msg(data.statusInfo.message);//失败的表情
                        return;
                    } else {
                        layer.msg("注册成功", {
                            icon: 6,//成功的表情
                            time: 1000 //1秒关闭(如果不配置,默认是3秒)
                        }, function () {
                            window.location = '/login';
                        });
                    }
                }
            });
            return false;
        });

    });
</script>
</body>
</html>

②发送验证码

sendcode接口

/**
     * 验证是否有此账号,然后发送验证码
     * @param map 主要认证主体,如账号,邮箱,qq的openID,wechat的code等
     * @return restResponse,附带凭证token
     */
    @PostMapping("/sendCode")
    public RestResponse sendCode(@RequestBody Map<String,Object> map){
        if (userService.findUserByCondition(map)==null){
            String principal;
            if (map.get("phone")!=null){
                principal=String.valueOf(map.get("phone"));

            }else if (map.get("email")!=null){
                principal=String.valueOf(map.get("email"));
            }else {
                return CrudUtil.ID_MISS_RESPONSE;
            }
            //创建一个验证码
            VerificationCode v=new VerificationCode();
            //将验证码存入验证码等待池
            VerificationCodePool.addCode(principal,v);
            //发送邮箱验证码
            sendEmail(principal,v.getCode());
            return new RestResponse();
        }
        return new RestResponse("",304,new StatusInfo("发送验证码失败,该账户已存在!","发送验证码失败,该账户已存在!"));
    }

邮件发送方法(调用SpringBoot提供的mail服务(需要导包))

/**
     * 发送带有验证码的邮件信息
     */
    private void sendEmail(String email,String code){
        //发送验证邮件
        try {
            SimpleMailMessage mailMessage = new SimpleMailMessage();

            //主题
            mailMessage.setSubject("仓库管理系统的验证码邮件");

            //内容
            mailMessage.setText("欢迎使用仓库管理系统,您正在注册此账户。" +
                    "\n您收到的验证码是: "+code+" ,请不要将此验证码透露给别人。");

            //发送的邮箱地址
            mailMessage.setTo(email);
            //默认发送邮箱邮箱
            mailMessage.setFrom(fromEmail);

            //发送
            mailSender.send(mailMessage);
        }catch (Exception e){
            throw new MyException(e.toString());
        }
    }

验证码对象

package com.dreamchaser.depository_manage.security.bean;

import lombok.Data;

import java.time.Instant;
import java.util.Random;

/**
 * 验证码,默认有效期为五分钟
 * @author 金昊霖
 */
@Data
public class VerificationCode {
    /**
     * 默认持续时间
     */
    private final long DEFAULT_TERM=60*5;
    /**
     * 验证码
     */
    private String code;
    /**
     * 创建时刻
     */
    private Instant instant;
    /**
     * 有效期
     */
    private long term;

    /**
     * 根据时间判断是否有效
     * @return boolean值
     */
    public boolean isValid(){
        return Instant.now().getEpochSecond()-instant.getEpochSecond()<=term;
    }

    public VerificationCode(Instant instant, long term) {
        //生成随机验证码code
        generateCode();
        this.instant = instant;
        this.term = term;
    }


    public VerificationCode(Instant instant) {
        //生成随机验证码code
        generateCode();
        this.instant = instant;
        this.term=DEFAULT_TERM;
    }

    public VerificationCode() {
        //生成随机验证码code
        generateCode();
        this.instant=Instant.now();
        this.term=DEFAULT_TERM;
    }

    private void generateCode(){
        StringBuilder codeNum = new StringBuilder();
        int [] numbers = {0,1,2,3,4,5,6,7,8,9};
        Random random = new Random();
        for (int i = 0; i < 5; i++) {
            //目的是产生足够随机的数,避免产生的数字重复率高的问题
            int next = random.nextInt(10000);
            codeNum.append(numbers[next % 10]);
        }
        this.code= codeNum.toString();
    }


}

验证码池

package com.dreamchaser.depository_manage.security.pool;

import com.dreamchaser.depository_manage.security.bean.VerificationCode;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 验证码等待池
 * @author 金昊霖
 */
public class VerificationCodePool {
    private static Map<String, VerificationCode> pool=new ConcurrentHashMap<>(10);

    /**
     * 增加一条验证码
     * @param principal 主要内容,如邮箱,电话号码等
     * @param verificationCode 验证码
     */
    public static void addCode(String principal,VerificationCode verificationCode){
        pool.put(principal, verificationCode);
    }

    /**
     * 根据principal主要信息获取未过期的验证码,如果没有未过期的令牌则返回null
     * @param principal 主要内容,如邮箱,电话号码等
     * @return verificationCode 未过期的验证码或者null
     */
    public static VerificationCode getCode(String principal){
        VerificationCode verificationCode=pool.get(principal);

        //如果没有相应验证码则直接返回null
        if (verificationCode==null){
            return null;
        }

        //判断令牌是否过期
        if (verificationCode.isValid()){
            //将验证码取出
            pool.remove(principal);
            return verificationCode;
        }else{
            //清除过期验证码
            pool.remove(principal);
            return null;
        }
    }

    /**
     * 根据主要信息principal删除对应的验证码
     * @param principal 主要信息
     */
    public static void removeCode(String principal){
        pool.remove(principal);
    }
}

③注册用户

MD5加密类

	/*
	 * Copyright (c) JForum Team All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1) Redistributions of
	 * source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2) Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
	 * following disclaimer in the documentation and/or other materials provided with the distribution. 3) Neither the name of "Rafael Steil" nor the names of its contributors may be used to endorse or promote products
	 * derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
	 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
	 * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
	 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE This file creation date: Mar 29, 2003 /
	 * 1:15:50 AM The JForum Project http://www.jforum.net
	 */
	package com.dreamchaser.depository_manage.utils;
	
	import java.security.MessageDigest;
	import java.security.NoSuchAlgorithmException;
	
	/**
	 * MD5加密
	 */
	public class Md5 {
	
		/**
		 * Encodes a string
		 * @param str String to encode
		 * @return Encoded String
		 */
		public static String crypt(String str) {
			if (str == null || str.length() == 0) {
				throw new IllegalArgumentException("String to encript cannot be null or zero length");
			}
			StringBuilder hexString = new StringBuilder();
			try {
				MessageDigest md = MessageDigest.getInstance("MD5");
				md.update(str.getBytes());
				byte[] hash = md.digest();
				for (byte b : hash) {
					if ((0xff & b) < 0x10) {
						hexString.append("0").append(Integer.toHexString((0xFF & b)));
					} else {
						hexString.append(Integer.toHexString(0xFF & b));
					}
				}
			} catch (NoSuchAlgorithmException e) {
				e.printStackTrace();
			}
			return hexString.toString();
		}
	
	}

注册用户接口

/**
     * 注册用户(通常为手机或者邮箱注册)
     * @param map 参数列表,包括账号(手机注册就是phone,邮箱就是email)、密码
     * @return 成功则返回凭证,否则返回验证失败
     */
    @PostMapping("/register")
    public RestResponse register(@RequestBody Map<String,Object>map){
        String principal;
        Object password=map.get("pwd");
        Object code=map.get("code");
        UserToken userToken;
        //判断必要参数是否满足
        if (password==null||code==null){
            return CrudUtil.ID_MISS_RESPONSE;
        }

        //从map中获取对应参数
        if (map.get("email")!=null){
            principal=String.valueOf(map.get("email"));
            userToken=new UserToken(LoginType.EMAIl_PASSWORD,principal,String.valueOf(password));
        }else {
            return CrudUtil.ID_MISS_RESPONSE;
        }
        //验证码正确且成功插入数据
        if (checkCode(principal,String.valueOf(code))){
            //对密码进行加密然后存储用户信息
            map.put("pwd",Md5.crypt(String.valueOf(map.get("pwd"))));
            //如果用户记录插入成功
            if (userService.insertUser(map)==1){
                String token= Md5.crypt(userToken.getPrincipal()+userToken.getInstant());
                //返回凭证
                return new RestResponse().setData(token);
            }
        }else {
            //验证码错误
            return CrudUtil.CODE_ERROR;
        }
        return 

这里的LoginType是登录方式,这个之后会提到

检验验证码方法

/**
     * 用于注册用户的方法,主要为号码验证和邮箱验证提供验证码核对的服务
     * @param principal 认证主体
     * @param code 验证码
     * @return 是否验证通过
     */
    private boolean checkCode(String principal,String code){
        if (code!=null){
            VerificationCode verificationCode=VerificationCodePool.getCode(principal);
            if (verificationCode!=null){
                return code.equals(verificationCode.getCode());
            }
        }
        return false;
    }

2.登录服务

登录界面

这里为了方便起见,我把token存储在cookie中

<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>后台管理-登陆</title>
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta http-equiv="Access-Control-Allow-Origin" content="*">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <meta name="apple-mobile-web-app-status-bar-style" content="black">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="format-detection" content="telephone=no">
    <link rel="stylesheet" href="static/lib/layui-v2.6.3/css/layui.css" media="all">
    <!--[if lt IE 9]>
    <script src="https://cdn.staticfile.org/html5shiv/r29/html5.min.js"></script>
    <script src="https://cdn.staticfile.org/respond.js/1.4.2/respond.min.js"></script>
    <![endif]-->
    <style>
        html, body {width: 100%;height: 100%;overflow: hidden}
        body {background: #1E9FFF;}
        body:after {content:'';background-repeat:no-repeat;background-size:cover;-webkit-filter:blur(3px);-moz-filter:blur(3px);-o-filter:blur(3px);-ms-filter:blur(3px);filter:blur(3px);position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1;}
        .layui-container {width: 100%;height: 100%;overflow: hidden}
        .admin-login-background {width:360px;height:300px;position:absolute;left:50%;top:40%;margin-left:-180px;margin-top:-100px;}
        .logo-title {text-align:center;letter-spacing:2px;padding:14px 0;}
        .logo-title h1 {color:#1E9FFF;font-size:25px;font-weight:bold;}
        .login-form {background-color:#fff;border:1px solid #fff;border-radius:3px;padding:14px 20px;box-shadow:0 0 8px #eeeeee;}
        .login-form .layui-form-item {position:relative;}
        .login-form .layui-form-item label {position:absolute;left:1px;top:1px;width:38px;line-height:36px;text-align:center;color:#d2d2d2;}
        .login-form .layui-form-item input {padding-left:36px;}
        .captcha {width:60%;display:inline-block;}
        .captcha-img {display:inline-block;width:34%;float:right;}
        .captcha-img img {height:34px;border:1px solid #e6e6e6;height:36px;width:100%;}
    </style>
</head>
<body>
<div class="layui-container">
    <div class="admin-login-background">
        <div class="layui-form login-form">
            <form class="layui-form" action="">
                <div class="layui-form-item logo-title">
                    <h1>仓库信息管理系统登录</h1>
                </div>
                <div class="layui-form-item">
                    <label class="layui-icon layui-icon-username" ></label>
                    <input type="text" name="principal" lay-verify="required|account" placeholder="请输入邮箱" autocomplete="off" class="layui-input" >
                </div>
                <div class="layui-form-item">
                    <label class="layui-icon layui-icon-password" ></label>
                    <input type="password" name="credentials" lay-verify="required|password" placeholder="密码" autocomplete="off" class="layui-input">
                </div>
                <!-- 徒有其表的验证码,主要是不想另外弄了 -->
                <div class="layui-form-item">
                    <label class="layui-icon layui-icon-vercode" ></label>
                    <input type="text" name="captcha" lay-verify="required|captcha" placeholder="图形验证码" autocomplete="off" class="layui-input verification captcha">
                    <div class="captcha-img">
                        <img id="captchaPic" src="static/images/captcha.jpg">
                    </div>
                </div>
                <div class="layui-form-item">
                    <input type="checkbox" name="rememberMe" value="true" lay-skin="primary" title="记住密码">
                </div>
                <div class="layui-form-item">
                    <button class="layui-btn layui-btn layui-btn-normal layui-btn-fluid" lay-submit="" lay-filter="login">登 入</button>
                </div>
            </form>
        </div>
    </div>
</div>
<script  src="static/lib/jquery-3.4.1/jquery-3.4.1.min.js" charset="utf-8"></script>
<script  src="static/lib/layui-v2.6.3/layui.js" charset="utf-8"></script>
<script  src="static/lib/jq-module/jquery.particleground.min.js" charset="utf-8"></script>
<script  src="static/js/cookie.js" charset="utf-8"></script>
<script>
    layui.use(['layer','form'], function () {
        var form = layui.form,
            layer = layui.layer;


        // 登录过期的时候,跳出ifram框架
        if (top.location != self.location) top.location = self.location;

        // 粒子线条背景
        $(document).ready(function(){
            $('.layui-container').particleground({
                dotColor:'#7ec7fd',
                lineColor:'#7ec7fd'
            });
        });

        // 进行登录操作
        form.on('submit(login)', function (data) {
            data = data.field;
            if (data.principal === '') {
                layer.msg('用户名不能为空');
                return false;
            }
            if (data.credentials === '') {
                layer.msg('密码不能为空');
                return false;
            }
            if (data.captcha === '') {
                layer.msg('验证码不能为空');
                return false;
            }
            data.loginType="email";
            $.ajax({
                url:"/login",
                type:'post',
                dataType:'json',
                contentType: "application/json;charset=utf-8",
                data:JSON.stringify(data),
                beforeSend:function () {
                    this.layerIndex = layer.load(0, { shade: [0.5, '#393D49'] });
                },
                success:function(data){
                    if(data.status !== 200){
                        layer.msg(data.statusInfo.message);//失败的表情
                        return;
                    }else{
                        layer.msg("登录成功", {
                            icon: 6,//成功的表情
                            time: 1000 //1秒关闭(如果不配置,默认是3秒)
                        }, function(){
                            cookieUtil.createCookie("token",data.data)
                            window.location = '/index';
                        });

                    }
                },
                complete: function () {
                    layer.close(this.layerIndex);
                }
            })
            return false;
        });
    });
</script>
</body>
</html>

当然这里封装了cookie的操作

var cookieUtil={
    createCookie:function (name,value,days){
        var expires="";
        if (days){
            var date=new Date();
            date.setTime(date.getTime()+(days*14*24*3600*1000));
            expires=";expires="+date.toGMTString();
        }
        document.cookie=name+"="+value+expires+";path=/";
    },
    /*设置cookie*/
    set:function(name,value,expires,path,domain,secure){
        var cookie=encodeURIComponent(name)+"="+encodeURIComponent(value);
        if(expires instanceof Date){
            cookie+="; expires="+expires.toGMTString();
        }else{
            var date=new Date();
            date.setTime(date.getTime()+expires*24*3600*1000);
            cookie+="; expires="+date.toGMTString();
        }
        if(path){
            cookie+="; path="+path;
        }
        if(domain){
            cookie+="; domain="+domain;
        }
        if (secure) {
            cookie+="; "+secure;
        }
        document.cookie=cookie;
    },
    /*获取cookie*/
    get:function(name){
        var cookieName=encodeURIComponent(name);
        /*正则表达式获取cookie*/
        var restr="(^| )"+cookieName+"=([^;]*)(;|$)";
        var reg=new RegExp(restr);
        var cookieValue=document.cookie.match(reg)[2];
        /*字符串截取cookie*/
        /*var cookieStart=document.cookie.indexOf(cookieName+“=”);
        var cookieValue=null;
        if(cookieStart>-1){
            var cookieEnd=document.cookie.indexOf(";",cookieStart);
            if(cookieEnd==-1){
                cookieEnd=document.cookie.length;
            }
            cookieValue=decodeURIComponent(document.cookie.substring(cookieStart
            +cookieName.length,cookieEnd));
        }*/
        return cookieValue;
    }
}

登录接口

这里的token凭证是根据用户密码+当前时刻(盐)加密得到的

/**
     * 登录接口
     * @param map 登录信息
     *  loginType 登录方式,目前支持的有email,qq,wechat
     *  principal 主要认证主体,如账号,邮箱,qq的openID,wechat的code等
     *  credentials 类似于密码,如果是qq,wechat则不需要传改参数
     *  restResponse,附带凭证token
     */
    @PostMapping("/login")
    public RestResponse login(@RequestBody Map<String,String> map) {
        UserToken userToken=new UserToken(LoginType.getType(map.get("loginType"))
                ,map.get("principal"),map.get("credentials"));
        return login(userToken);
    }

认证方法

/**
     * 将生成的令牌拿去认证,如果认证成功则返回带有token凭证响应,否则返回用户密码错误的响应
     * @param userToken 未认证的令牌
     * @return restResponse 如果认证成功则返回带有token凭证响应,否则返回用户密码错误的响应
     */
    private RestResponse login(UserToken userToken) {
        String token=loginRealms.authenticate(userToken);
        if (token!=null){
            return new RestResponse(token);
        }else {
            return CrudUtil.NOT_EXIST_USER_OR_ERROR_PWD_RESPONSE;
        }
    }

登录方式enum类

这里可以看到我里面有多种方式登录,不过我的代码里只实现了邮箱登录,其余方式可以自己去实现拓展

package com.dreamchaser.depository_manage.security.bean;

/**
 * 登录方式枚举类
 * @author 金昊霖
 */

public enum LoginType {
    /**
     * 通用
     */
    COMMON("common_realm"),
    /**
     * 用户密码登录
     */
    EMAIl_PASSWORD("user_password_realm"),
    /**
     * 手机验证码登录
     */
    USER_PHONE("user_phone_realm"),
    /**
     * 第三方登录(微信登录)
     */
    WECHAT_LOGIN("wechat_login_realm"),
    /**
     * 第三方登录(qq登录)
     */
    QQ_LOGIN("qq_login_realm");


    private String type;

    LoginType(String type) {
        this.type = type;
    }

    public String getType() {
        return type;
    }

    /**
     * 根据简单的字符串返回对应的LoginType
     * @param s 简单的字符串
     * @return 对应的LoginType
     */
    public static LoginType getType(String s){
        switch (s) {
            case "email":
                return EMAIl_PASSWORD;
            case "qq":
                return QQ_LOGIN;
            case "wechat":
                return WECHAT_LOGIN;
            case "phone":
                return USER_PHONE;
            default:
                return null;
        }

    }

    @Override
    public String toString() {
        return this.type;
    }
}

登录方式类

这里面可以根据自己的业务拓展,我只实现了邮箱登录

package com.dreamchaser.depository_manage.security.bean;

import com.dreamchaser.depository_manage.entity.User;
import com.dreamchaser.depository_manage.exception.MyException;
import com.dreamchaser.depository_manage.security.pool.AuthenticationTokenPool;
import com.dreamchaser.depository_manage.service.UserService;
import com.dreamchaser.depository_manage.utils.Md5;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * 内置多种登录方式,和shiro中的realm类似
 * @author 金昊霖
 */
@Component
public class LoginRealms {
    @Autowired
    private UserService userService;

    /**
     * 认证,如果认证成功则返回凭证,否则返回null
     * @param userToken 未认证的令牌
     * @return 如果认证成功则返回凭证,否则返回null
     */
    public String authenticate(UserToken userToken){
        if (userToken.getCredentials()!=null){
            //对密码加密
            userToken.setCredentials(Md5.crypt(userToken.getCredentials()));
        }
        if (userToken.getLoginType().equals(LoginType.EMAIl_PASSWORD)){
            return handle(userToken,emailLogin(userToken));
        }
        //else if (其他登录方式...)
        //如果无匹配的认证方式则视为验证失败
        return null;
    }

    /**
     * 邮箱登录方式
     * @param userToken 令牌
     * @return 认证成功返回SimpleUser
     */
    private User emailLogin(UserToken userToken){
        return userService.findUserByEmail(userToken.getPrincipal());
    }

    /**
     * 根据传入的user是否为null(是否认证通过)来对令牌做剩下的操作(将user刻入令牌,并将该令牌放入令牌池中)
     * @param userToken 经过验证后的令牌
     * @return token 根据令牌生成的凭证 ,如果认证未成功则返回null
     */
    private String handle(UserToken userToken,User user){
        if (user==null){
            //说明账户不存在
            throw new MyException(409,"该用户不存在,请注册后再登录!");
        }
        //判断密码是否正确
        if (user.getPwd().equals(userToken.getCredentials())){
            //将User信息刻入令牌
            userToken.setUser(user);
            //获取token凭证
            String token=Md5.crypt(userToken.getPrincipal()+userToken.getInstant());
            //将令牌放入认证令牌池
            AuthenticationTokenPool.addToken(token,userToken);
            return token;
        }
        return null;
    }
}

认证令牌类

package com.dreamchaser.depository_manage.security.bean;


import com.dreamchaser.depository_manage.entity.User;
import lombok.Data;

import java.time.Instant;


/**
 * 登录令牌,默认有效期为7天
 * @author 金昊霖
 */
@Data
public class UserToken{

    final long DEFAULT_TERM=60*60*24*7;
    /**
     * 登录方式
     */
    private LoginType loginType;
    /**
     * 微信、qq的code,邮箱,或者用户名之类的
     */
    private String principal;

    /**
     * 相当于密码(一般是加密过的)
     */
    private String credentials;

    /**
     * 放入的时间
     */
    private Instant instant;

    /**
     * 有效期(单位:秒)
     */
    private long term;

    /**
     * 可以放一些不敏感的信息,以便下次访问时可以直接取出,如果user属性太多可以另外写个类,比如SimpleUser,
     * 存放一些经常需要用到的信息。
     */
    private User User;

    /**
     * 根据时间判断是否有效
     * @return 有效则返回true,否则返回false
     */
    public boolean isValid(){
        return Instant.now().getEpochSecond()-instant.getEpochSecond()<=term;
    }

    public UserToken(LoginType loginType, String principal, String credentials, Instant instant, long term, User user) {
        this.loginType = loginType;
        this.principal = principal;
        this.credentials = credentials;
        this.instant = instant;
        this.term = term;
        this.User = user;
    }

    public UserToken(LoginType loginType, String principal, String credentials, Instant instant, long term) {
        this.loginType = loginType;
        this.principal = principal;
        this.credentials = credentials;
        this.instant = instant;
        this.term = term;
    }

    public UserToken(LoginType loginType, String principal, String credentials) {
        this.loginType = loginType;
        this.principal = principal;
        this.credentials = credentials;
        this.instant = Instant.now();
        this.term=DEFAULT_TERM;
    }

    public UserToken(LoginType loginType, String principal) {
        this.loginType = loginType;
        this.principal = principal;
        this.instant=Instant.now();
        this.term=DEFAULT_TERM;
    }
}

认证令牌池

package com.dreamchaser.depository_manage.security.pool;


import com.dreamchaser.depository_manage.security.bean.UserToken;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 认证后的令牌连接池(由于获取全局的session比较麻烦,所以自己维护一个类似session的令牌池)
 * @author 金昊霖
 */
public class AuthenticationTokenPool {
    /**
     * 认证后的令牌连接池
     */
    private static Map<String, UserToken> pool=new ConcurrentHashMap<>(10);

    public static void addToken(String token,UserToken userToken){
        pool.put(token, userToken);
    }

    /**
     * 根据token凭证获取未过期的令牌,如果没有未过期的令牌则返回null
     * @param token 凭证
     * @return userToken 未过期的令牌
     */
    public static UserToken getToken(String token){
        UserToken userToken=pool.get(token);

        //如果没有相应令牌则直接返回null
        if (userToken==null){
            return null;
        }

        //判断令牌是否过期
        if (userToken.isValid()){
            return userToken;
        }else{
            //清除过期令牌
            pool.remove(token);
            return null;
        }
    }

    /**
     * 根据凭证删除对应的令牌
     * @param token 凭证
     */
    public static void removeToken(String token){
        pool.remove(token);
    }

}

3.权限控制(拦截器)

由于大作业的规模也没这么大,权限并没有划分很细,所以这里我只做了鉴权的操作,如果需要对不同资源采取不同的权限控制,我的方案是写多个拦截器,同时对于不同权限资源路径加上不同的前缀以便区分控制。(这块我并未细想,可能还有更好的方案,日后补充吧)

拦截器UserInterceptor

其实登出的操作也在这里做了,相对应的logout方法只是返回响应而已(笑哭)

package com.dreamchaser.depository_manage.intercepter;

import com.dreamchaser.depository_manage.exception.MyException;
import com.dreamchaser.depository_manage.security.pool.AuthenticationTokenPool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 认证拦截器,如果请求头中有相应凭证则放行,否则拦截返回认证失效错误
 * @author 金昊霖
 */
@Slf4j
@Component
public class UserInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws MyException {

        //拿到requset中的head
        String token =null;
        Cookie[] cookies=request.getCookies();
        for (Cookie c:cookies){
            if (c.getName().equals("token")){
                token=c.getValue();
                break;
            }
        }

        if (token==null){
            System.out.println(request.getRequestURI());
            throw new MyException(401,"未授权,请重新登录!");
        }
        //如果是访问logout则删除对应的令牌
        if ("/logout".equals(request.getServletPath())){
            AuthenticationTokenPool.removeToken(token);
            return true;
        }

        if (AuthenticationTokenPool.getToken(token)!=null){
            return true;
        }else {
            throw new MyException(407,"认证失效,请重新登录!");
        }
    }
}

MVC配置类

注意过滤掉注册,登录,登出的接口

package com.dreamchaser.depository_manage.config;

import com.dreamchaser.depository_manage.intercepter.UserInterceptor;
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 WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/login", "/register", "/sendCode", "/error")
                .excludePathPatterns("/static/**");
    }

    //    private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
//            "classpath:/META-INF/resources/", "classpath:/resources/",
//            "classpath:/static/", "classpath:/public/" };
//    @Override
//    public void addResourceHandlers(ResourceHandlerRegistry registry) {
//        if (!registry.hasMappingForPattern("/webjars/**")) {
//            registry.addResourceHandler("/webjars/**").addResourceLocations(
//                    "classpath:/META-INF/resources/webjars/");
//        }
//        if (!registry.hasMappingForPattern("/**")) {
//            registry.addResourceHandler("/**").addResourceLocations(
//                    CLASSPATH_RESOURCE_LOCATIONS);
//        }
//
//    }
}



七、效果展示

1.注册

在这里插入图片描述

发送验证码

在这里插入图片描述

注册成功

在这里插入图片描述

数据库新增一条记录,并且密码加密存储 在这里插入图片描述

2.登录

在这里插入图片描述

输入错误的密码 在这里插入图片描述

输入正确的密码 在这里插入图片描述

同时跳转至首页 在这里插入图片描述

这里为了方便起见,我把token存储在cookie中,看看cookie信息 在这里插入图片描述

可以看到cookie中已经有token凭证

3.访问其他资源

未登录访问 在这里插入图片描述

登录后访问 在这里插入图片描述

写在最后

说实话,写这篇博文花了我不少时间,光写博文都花了两个晚上,更别说自己实际去操作去找资料了。别看我现在讲的头头是道的,当初我为了解决这个问题可花了不少心力,不说四处查资料学习,光光坑我就踩了一堆。

当然了,我写的方案也并非是最好的,只是用Java实现的一套的简单的鉴权服务,如果你学过SpringSecurity或者shiro这种权限管理框架,那你肯定能或多或少看出一点它们的影子,因为我有一部分是模仿它们写的(当然写的很简陋罢了)。

如果你有什么意见或者建议,欢迎在评论区评论,对于每一条“非水”评论我都会认真回复。

最后,愿大家都能以梦为马,不负人生韶华! 与君共勉!

  • 作者:金昊霖
  • 发表时间:2020-7-04
  • 版权声明:自由转载-非商用-非衍生-保留署名(创意共享3.0许可证)
  • 评论

    james
    精彩的很,你的服务器买的多少钱
    Dreamchaser追梦
    博主
    @james:学生优惠不到100吧
    egnylgrpcn
    90oBWY <a href="http://bygjtrxchkqh.com/">bygjtrxchkqh</a>, [url=http://wrtywdoohqve.com/]wrtywdoohqve[/url], [link=http://eyhtvvlhyyak.com/]eyhtvvlhyyak[/link], http://gfoscwyuozbs.com/
    johnanz
    http://imrdsoacha.gov.co/silvitra-120mg-qrms
    留言