五、关于鉴权问题
在正式讲思路之前,我还是想聊聊鉴权问题。
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.访问其他资源
未登录访问
登录后访问