手把手教你用Java实现一套简单的鉴权服务

前言

时遇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机制是我们自己实现的(当然也可以借助框架),这样操作这些东西的时候就不必拘泥于条条框框,可以根据自己的业务需求制定适合的鉴权方案。

悄悄告诉你一句:csdn也是用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这种权限管理框架,那你肯定能或多或少看出一点它们的影子,因为我有一部分是模仿它们写的(当然写的很简陋罢了)。

到此这篇关于手把手教你用Java实现一套简单的鉴权服务的文章就介绍到这了,更多相关Java 鉴权内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Java中使用JWT生成Token进行接口鉴权实现方法

    先介绍下利用JWT进行鉴权的思路: 1.用户发起登录请求. 2.服务端创建一个加密后的JWT信息,作为Token返回. 3.在后续请求中JWT信息作为请求头,发给服务端. 4.服务端拿到JWT之后进行解密,正确解密表示此次请求合法,验证通过:解密失败说明Token无效或者已过期. 流程图如下: 一.用户发起登录请求 二.服务端创建一个加密后的JWT信息,作为Token返回 1.用户登录之后把生成的Token返回给前端 @Authorization @ResponseBody @GetMappin

  • 手把手教你用Java实现一套简单的鉴权服务

    前言 时遇JavaEE作业,题目要求写个简单web登录程序,按照老师的意思是用servlet.jsp和jdbc完成.本着要么不做,要做就要做好的原则,我开始着手完成此次作业(其实也是写实训作业的用户鉴权部分),而之前写项目的时候也有相关经验,这次正好能派上用场. 一.何为鉴权服务 引用百度百科的话说 鉴权(authentication)是指验证用户是否拥有访问系统的权利. 鉴权包括两个方面: 用户鉴权,网络对用户进行鉴权,防止非法用户占用网络资源. 网络鉴权,用户对网络进行鉴权,防止用户接入了非

  • 手把手教你使用Java实现在线生成pdf文档

    目录 一.介绍 二.案例实现 2.1添加iText依赖包 2.2简单实现 2.3复杂实现 2.4变量替换方式 三.总结 一.介绍 在实际的业务开发的时候,研发人员往往会碰到很多这样的一些场景,需要提供相关的电子凭证信息给用户,例如网银/支付宝/微信购物支付的电子发票.订单的库存打印单.各种电子签署合同等等,以方便用户查看.打印或者下载. 例如下图的电子发票! 熟悉这块业务的童鞋,一定特别清楚,目前最常用的解决方案是:把相关的数据信息,通过一些技术手段生成对应的 PDF 文件,然后返回给用户,以便

  • 手把手教你实现Java第三方应用登录

    目录 什么是OAuth2.0 申请网站接入 创建SpringBoot应用 实现登录流程 大家在自己做项目的时候有没有想过实现一个第三方应用登录呢?类似这种: 本篇文章就来聊一聊该如何实现第三方应用登录. 什么是OAuth2.0 OAuth是一项协议,它为用户资源的授权提供了一个安全.开放而简易的标准,OAuth的授权不会使第三方触及到用户的账号信息(比如密码),因此OAuth是相对安全的.而OAuth2.0就是OAuth的延续,不过2.0更加关注客户端开发者的简易性. 申请网站接入 常见的第三方

  • 手把手教你用Java给暗恋对象发送一份表白邮件

    目录 前言 ①创建项目 ②配置pom.xml  ④qq邮箱处理 ⑤发送邮件 附协议的基本介绍 总结 前言 如果你有喜欢的男生或者女生,想要有一个比较新颖的表白方式,作为一名合格的程序员,我们应该用我们自己独有的方式来表达,接下来我会一步一步教你用Java语言发送一份邮件,这是一种简易的方式,代码内容还可以继续扩张,废话不多说,让我们学起来吧!! ①创建项目 全程需要在连接网络的状态下进行 打开idea,File->New->Project    点击Maven->Next 创建名称-&g

  • Java开发之手把手教你搭建企业级工程SSM框架

    目录 1.在IDEA界面中创建MavenWeb工程 2.在pom.xml中添加如下相关依赖 3.web.xml 配置 Spring MVC.Spring 4.分别在main目录下创建resource包 5.在spring.xml中连接数据库 6.springmvc.xml中配置驱动和前后缀表达式 7.配置打印sql语句和指定实体类,让idea搜索需要的javaBean 8.创建与数据库相对应的实体类 9.Handler 10.Service及其接口 11.Repository 12.测试所用的j

  • 手把手教你实现Android编译期注解

    详细阐述了实现一个Android编译期注解sdk的步骤以及注意事项,并简要分析了运行时注解以及字节码技术在生成代码上与编译期注解的不同与优劣 一.编译期注解在开发中的重要性 从早期令人惊艳的ButterKnife,到后来的以ARouter为首的各种路由框架,再到现在谷歌大力推行的Jetpack组件,越来越多的第三方框架都在使用编译期注解这门技术,可以说不管你是想要深入研究这些第三方框架的原理 还是要成为一个Android高级开发工程师,编译期注解都是你不得不好好掌握的一门基础技术. 本文从基础的

  • Android消息推送:手把手教你集成小米推送(附demo)

    前言 在Android开发中,消息推送功能的使用非常常见. 为了降低开发成本,使用第三方推送是现今较为流行的解决方案. 今天,我将手把手教大家如何在你的应用里集成小米推送 目录 1. 官方Demo解析 首先,我们先对小米官方的推送Demo进行解析. 请先到官网下载官方Demo和SDK说明文档 1.1 Demo概况 目录说明: DemoApplication类 继承自Application类,其作用主要是:设置App的ID & Key.注册推送服务 DemoMessageReceiver类 继承自

  • 手把手教你写一个微信小程序(推荐)

    需求 小程序语音识别,全景图片观看,登录授权,获取个人基本信息 一:基础框架 官方开发文档:https://developers.weixin.qq.com/miniprogram/dev/ (其实官方文档写的很清楚了) 1.跟着官方文档一步一步来,新建一个小程序项目就好 2.然后呢,毕竟默认的只是基本骨架,肌肉线条还是要自己填的 app.json 是当前小程序的全局配置 小程序的所有页面路径.界面表现.网络超时时间.底部 tab 需求一:底部tab,我们要像原生APP那样要有是三个常驻的按钮,

  • 手把手教你安装Windows版本的Tensorflow

    一:安装Anaconda和Tensorflow 步骤: 1:从官方网站下载Anaconda https://www.anaconda.com/download/    注意自己电脑版本是32位还是64位.  2:进行软件安装(这个和普通的没什么特别区别) 3:安装完成Anaconda之后进行环境变量的测试 进入到windows中的命令模式: (1)检测anaconda环境是否安装成功:conda --version (2)检测目前安装了哪些环境变量:conda info --envs (3)对于

  • 手把手教你SpringBoot过滤器N种注册方式

    要说在 Spring Boot 中注册过滤器有三种方式,你都能想到哪些呢?今天松哥就来和大家聊一聊 Spring Boot 中注册过滤器的三种方式! 其实本来是想和大家聊 Spring Security 过滤器链的问题的,结果看源码看着看着就跑题了,索性就先和大家聊一聊 Spring Boot 中注册过滤器的三种方式,算是给 后面的 Spring Security 打一点基础. 1.@WebFilter 通过 @WebFilter 注解来标记一个过滤器,这种方式相信大家很容易想到.这是将 Ser

随机推荐