REEEEX

KEEP GOING


  • 首页

  • 归档

  • 标签

  • 关于

Interview | Note-1

发表于 2019-03-04

Interview | Note-1

网络基础知识

OSI开放式模型(七层协议、参考的国际模型)

先自上而下,后自下而上的处理数据的头部

  • 物理层:解决物理介质的传输、原始比特流传输、模数转换、数模转换、网卡工作层

  • 数据链路层:物理寻址、将比特流转变为逻辑传输线路、错误检测、确保数据传输的可靠性、交换机工作层

  • 网络层:将网络地址翻译成对应的物理地址、并且决定数据从发送方路由到接收方、保证数据发送优先权、网络阻塞程度、路由器、数据包、TCP/IP协议、分组传输
  • 传输层:必要时对数据进行分割,并且交由网络层,保证数据段有效到达对端、1500字节、TCP/UDP协议
  • 会话层:自动收发包、自动寻址、建立及管理会话、解决不同系统的语法
  • 应用层:固定消息头、消息体、信息的语法语义及其关联、HTTP协议

TCP/IP(四层模型、OSI的实现)

先自上而下,后自下而上的处理数据的头部

OSI七层 TCP/IP四层 功能 TCP/IP协议族
应用层 应用层 文件传输、EMAIL、文件、虚拟终端 HTTP/FTP/SMTP/DNS/Telnet
表示层 \ 数据格式化、代码转换、数据加密 \
会话层 \ 建立以及管理会话 \
传输层 传输层 提供端对端的接口 TCP/UDP
网络层 网络层 为数据包选择路由 IP/ICMP/IGMP/RIP
数据链路层 链路层 传输有地址的帧、错误检测 PPP/MTU
物理层 \ 二进制形式在物理媒体传输 ISO02110/IEEE802/IEEE802.2

TCP/IP重点在于,实际应用中应该开发的应用和协议


参考链接:https://blog.csdn.net/qq_38950316/article/details/81087809

TCP三次握手

TCP 传输控制协议

  • 面向连接的、可靠的、基于字节流的传输层通信协议
  • 将应用层的数据分割成适当长度的报文段(MTU)并且发送给目标节点的TCP层
  • 数据包有序号Sequence Number,对象收到则发送ACK确认,未收到则重传
  • 使用奇偶校验和检验数据在传输过程中是否有误

  1. URG:紧急指针 1-有效 0-忽略
  2. ACK:确认序号 1-有效 0-不包含确认信息
    1. 确认号:其数值等于发送方的发送序号 +1(即接收方期望接收的下一个序列号)
  3. PSH:push标志 尽快提交到应用层,而不是在缓存中
  4. RST:重置连接 重置崩溃
  5. SYN:同步序号,用于建立连接过程
  6. FIN:finish,用于释放连接
三次握手流程
  • 第一次握手:建立连接时,客户端发送SYN包(SYN=J)到服务器,并且进入SYN_SEND状态,等待服务器Query
  • 第二次握手:服务器收到SYN包,必须确认客户的SYN(ACK=J+1),同时自己也发送一个SYN包(SYN=K),即SYN+ACK包,此时服务器进入SYN_RECV状态
  • 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认ACK(ACK=K+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成
为什么需要三次握手
  • 为了初始化Sequence Number的初始值,作为往后的通讯序号
首次握手的隐患——SYN超时
  • Server收到Client的SYN,回复SYN+ACK的时候,Server未收到ACK确认
  • Server不断重试至超时(Linux默认等待63秒断开)
  • 可能导致服务器遭受SYN Flood的风险
    • 针对SYN Flood的防护措施
      • SYN队列满后,通过tcp_syncookies参数回发SYN Cookie
      • 若为正常连接,则Client会回发SYN Cookie,直接建立连接
建立连接后,Client出现故障
  • 保活机制
    • 向对方发送保活检测报文,如果未收到响应则继续发送
    • 尝试次数达到保活检测数仍未收到响应则中断连接

TCP四次挥手

用于TCP连接是全双工,每个方向都必须单独进行关闭,发送方和接收方都需要FIN报文和ACK报文,首先进行关闭的一方主动关闭,另一方则被动关闭。

任何一方执行close()操作即可产生挥手(经过时间2MSL才真正释放)

为什么有TIME_WAIT
  • 确保有足够时间让对端收到ACK包
  • 避免新旧连接混淆
服务器出现大量CLOSE_WAIT状态

CLOSE_WAIT:被动关闭的一端收到FIN后,但未发出ACK的TCP状态

  • 对方关闭socket连接,我方忙于读或写,没有及时关闭连接
    • 检查代码:被动关闭一端的代码,尤其是释放资源的
    • 检查配置:处理请求的线程配置

TCP & UDP区别

UDP特点
  • 面向非连接
  • 不维护连接状态,支持同时向多个客户端传输相同的消息
  • 数据包报头只有8个字节,额外开销较小
  • 吞吐量只受限于数据生成速率、传输速率以及机器性能
  • 尽最大努力交付,不保证可靠交付,不需要维持复杂的链接状态表
  • 面向报文,不对应用程序提交的报文信息进行拆分或合并
区别
  • 面向连接 & 无连接
  • 可靠性(TCP)
  • 有序性(TCP-序列号,到达无序)
  • 速度(UDP更快)
  • 量级(TCP-重量级、UDP-轻量级)

TCP滑动窗口

RTT:发送一个数据包到收到对应ACK所花费的时间

RTO:重传时间间隔

TCP使用滑动窗口做流量控制与乱序重排

  • 保证TCP可靠性和流控特性

AdvertisedWindow = MaxRcvBuffer - (LastByteRcvd - LastByteRead)

EffectiveWindow = AdvertisedWindow - (LastByteSent - LastByteAcked)

kO1AoV.png

TCP会话的发送方

kO1jmR.png

TCP会话的接收方

kO3ptK.png


HTTP

HTTP特点
  • 支持C(Client)/S(Server)模式
  • 简单快速
  • 灵活(任意类型数据对象)
  • 无连接(限制每次只处理一个请求)
  • 无状态(处理事务没有记忆)

HTTP请求结构

kO30c4.png

HTTP响应结构

kO3gN6.png

请求/响应的步骤
  • 客户端连接到Web服务器
  • 发送HTTP请求
  • 服务器接受请求并且返回HTTP响应
  • 释放TCP连接
  • 客户端解析HTML内容
在浏览器输入URL,键入回车的流程
  • DNS解析
  • 根据IP地址和Port建立TCP连接
  • 发送HTTP请求
  • 服务器处理请求并且返回HTTP报文
  • 浏览器解析HTML
  • 连接接受
HTTP常见状态码
  • 1xx 指示信息 - 请求已接收,继续处理
  • 2xx 成功 - 请求已被成功接收、理解、接受
  • 3xx 重定向 - 要完成请求必须进行更进一步的操作
  • 4xx 客户端错误 - 请求有语法错误或请求无法实现
  • 5xx 服务端错误 - 服务器未能实现合法的请求

GET / POST

区别
  • HTTP报文:GET将请求信息放在URL,POST放在报文体中
  • 数据库:GET符合幂等性和安全性,POST不符合(每次请求都不同)
  • GET可以被缓存,POST不行

Cookie / Session

Cookie
  • Cookie由服务器发给客户端的特殊信息,以文本的形式存放在客户端
  • 客户端再次请求时,会把Cookie回发
  • 服务器接收到Cookie,会解析与客户端相对应的内容
Session
  • 服务器端的机制,在服务器上保存的信息
  • 解析客户端请求并操作session id,按需保存状态信息
  • 实现方式
    • Cookie - JSESSIONID = xxx
    • URL回写
区别
  • Cookie数据存放在客户端的浏览器,Session数据存放在服务器
  • Session相对于Cookie更安全
  • 考虑减少服务器负担,应使用Cookie

HTTP / HTTPS

kOGkdI.png

SSL(Security Sockets Layer)
  • 为网络通信提供安全及数据完整性的一种安全协议
  • 操作系统对外的API(SSL3.0更名TLS)
  • 采用身份验证和数据加密,保证网络通信的安全和数据的完整性
加密的方式
  • 对称加密、非对称加密、哈希算法(算法不可逆-MD5)、数字签名
HTTPS数据传输流程
  • 浏览器将支持的加密算法信息发送给服务器
  • 服务器选择支持的加密算法,以证书的形式回发浏览器
  • 浏览器校验证书合法性,并且结合证书公钥加密信息发送给服务器
  • 服务器使用私钥解密信息,验证哈希,加密响应消息回发浏览器
  • 浏览器解密响应消息,并对消息进行验真,进行加密交互数据
区别
  • HTTPS需要CA申请证书
  • HTTPS密文传输,HTTP明文传输
  • 连接方式不同,HTTPS默认443,HTTP默认80
  • HTTPS = HTTP + 加密 + 认证 + 完整性保护

Socket

  • Socket是对TCP/IP协议的抽象,是操作系统对外开放的接口
  • IP + PID
Socket通信流程

kXCjMD.png

面试题
  • 客户端向服务器发送一个字符串,服务器收到该字符串后将其打印到命令行上,然后向客户端返回该字符串的长度,最后,客户端输出服务器端返回的该字符串的长度,分别用TCP和UDP实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class LengthCalculator extends Thread {
private Socket socket;

public LengthCalculator(Socket socket) {
this.socket = socket;
}

@Override
public void run() {
try {
// 获取socket的输出流
OutputStream os = socket.getOutputStream();
// 获取socket的输入流
InputStream is = socket.getInputStream();
int ch = 0;
byte[] buff = new byte[1024];
ch = is.read(buff);
String content = new String(buff, 0, ch);
System.out.println(content);
// 往输出流写入获得的字符串,回发客户端
os.write(String.valueOf(content.length()).getBytes());
// 关闭流
is.close();
os.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
TCP
1
2
3
4
5
6
7
8
9
10
11
12
13
public class TCPServer {
public static void main(String[] args) throws IOException {
// 创建socket,绑定端口
ServerSocket serverSocket = new ServerSocket(65000);
// 死循环,一直等待并且处理Client发送的请求
while (true){
// 监听端口,知道客户端返回连接信息才返回
Socket socket = serverSocket.accept();
// 获取客户端的请求信息后,执行相关业务逻辑
new LengthCalculator(socket).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class TCPClient {
public static void main(String[] args) throws IOException {
// 创建socket,指定连接65000端口的服务器socket
Socket socket = new Socket("127.0.0.1",65000);
// 获取输出流
OutputStream os = socket.getOutputStream();
// 获取输入流
InputStream is = socket.getInputStream();
// 字符串转换byte,并且写入到输出流中
os.write("hello world".getBytes());
int ch = 0;
byte[] buff = new byte[1024];
ch = is.read(buff);
// 将接收流的byte数组转换为字符串(从服务器端发回的字符串长度数据)
String content = new String(buff,0,ch);
System.out.println(content);
// 关闭流
is.close();
os.close();
socket.close();
}
}
UDP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class UDPServer {
public static void main(String[] args) throws Exception {
// 服务端接受客户端发送的数据报
DatagramSocket socket = new DatagramSocket(65001);
byte[] buff = new byte[128];
DatagramPacket packet = new DatagramPacket(buff, buff.length);
// 接受客户端发送过来的内容,并且将内容封装到DatagramPacket中
socket.receive(packet);
// 从DatagramPacket对象中获取真正存储的数据
byte[] data = packet.getData();
// 将数据从二进制转换成字符串
String content = new String(data,0,packet.getLength());
System.out.println(content);
// 将要发送给客户端的数据转换成二进制
byte[] sendedContent = String.valueOf(content.length()).getBytes();
// 服务端给客户端发送数据报
DatagramPacket packetToClient = new DatagramPacket(sendedContent,sendedContent.length,packet.getAddress(),packet.getPort());
// 发送数据给客户端
socket.send(packetToClient);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class UDPClient {
public static void main(String[] args) throws Exception {
// 客户端发送数据给服务端
DatagramSocket socket = new DatagramSocket();
// 要发送给服务端的数据
byte[] buff = "hello world".getBytes();
// 将IP地址封装
InetAddress address = InetAddress.getByName("127.0.0.1");
// 将要发送的数据封装
DatagramPacket packet = new DatagramPacket(buff, buff.length, address, 65001);
// 发送
socket.send(packet);
// 客户端接收服务端发来的数据报
byte[] data = new byte[128];
// 创建DatagramPacket对象存储服务端发送的数据
DatagramPacket receivedPacket = new DatagramPacket(data, data.length);
// 将接收到的数据存储到DatagramPacket对象中
socket.receive(receivedPacket);
// 将服务端发送的数据打印
String content = new String(receivedPacket.getData(), 0, receivedPacket.getLength());
System.out.println(content);
}
}

Interview | Note-2

发表于 2019-03-04

Interview | Note-2 数据库

Q:如何设计一个关系型数据库?

  • RDBMS
    • 存储模块
    • 程序实例(用逻辑结构映射物理结构)
      • 存储管理
        • 数据的格式和分隔进行统一的管理,即把数据通过逻辑的形式给组织和表示出来
        • 多行数据组成块、页,减少逐行读取浪费的效率
      • 缓存机制
        • 直接从内存中取出已使用过的高频数据,减少I/O
        • 优化访问速率(数据多行组成块或页)
      • SQL解析
        • 将SQL编译解析成机器可识别的语言
        • 缓存到内存中,提升SQL执行的效率(已编译好)
      • 日志管理
        • 对操作做记录
      • 权限划分
      • 容灾机制
        • 考虑异常情况的处理、恢复、恢复的程度
      • 索引管理、锁管理
        • 提高数据库查询的速度,支持并发

kjdMg1.png

索引模块

为什么要使用索引?
  • 块/页:由多行数据组成

  • 全表扫描

    • 将全表的数据一次性全部或分批次的加载到数据当中
    • 将块、页轮询查找
  • 数据之间挑选关键信息,依据字典的设计思路,将相关数据分类,按照特定的关键信息进行索引查询,以提高查询速度
什么样的信息能成为索引?
  • 能够将查询限定在一定查找范围内的字段(主键、唯一键、普通键)
索引的数据结构
  • 生成索引、建立二叉查找树进行二分查找
  • 建立B-Tree、红黑树、B+-Tree(MySQL)、Hash、BitMap

二叉查找树

  • 每一次的数据操作,都与I/O有关,随着数据增多,树的高度也增加,随之I/O增加

B-Tree

1552043089150

  • 定义
    • 根节点至少包括两个孩子
    • 树中每个节点最多含有m个孩子
    • 除根节点和叶节点外,其他每个节点至少由ceil(m/2)个孩子
    • 所有叶子节点都在同一层
    • 假设每个非终端结点中包含有n个关键字(蓝色)信息,其中
      • Ki为关键字,且关键字按顺序升序
      • 关键字的个数n必须满足,[ceil(m/2)-1] <= n <= m-1
      • 非叶子结点的指针,左指针子树的关键字都小于该左关键字,右指针子树的关键字都大于该右关键字,其余在该范围子树内
  • 目的
    • 使得每个索引块存储更多的信息、树的高度尽可能矮,减少I/O次数
    • 拒绝变为线性情况
  • 时间效率
    • O(logn)

B+-Tree

1552043866565

  • B+树是B树的变体,定义基本与B树相同,还包括
    • 非叶子节点的子树与关键字个数相同
      • 可以存储较B树更多的关键字
    • 非叶子界面的子树指针P[i],指向关键字值[K[i] , K[i+1])的子树
      • 例如10,最后一个关键字的大小18,一定小于20;或者18可以是该子树的关键字值
    • 非叶子节点仅用于索引,数据都保存在叶子节点中
      • 所有检索从根开始,到叶子结束
      • 非叶子节点不存储数据,所有可以存储更多的关键字,更矮,更快
    • 所有叶子节点均有一个链指针指向下一个叶子节点,按升序
      • 例如5-8-9,10-15-18
      • 方便做范围统计
  • B+树,更适合用来做存储索引
    • 磁盘读写代价更低(不存放数据,只存放索引)
    • 查询效率更加稳定(必须从根到叶子,长度相同)
    • 更利于对数据库的扫描(遍历叶子节点即可完成对全部关键字信息的扫描)

Hash & BitMap

1552044462517

  • Hash索引,根据Hash函数的计算,一次定位找到所需数据所在的bucket
    • 例如,对Sandra Dee的keys进行一次Hash运算,便能找到所在的buckets
    • 将152的entries全部加载到内存当中
    • 由于该entries是一个链表,顺着指针找到Sandra Dee
  • Hash索引的缺点在于
    • 仅能等值查询,不能使用范围查询
    • 无法被用来避免数据的排序操作
    • 不能利用部分索引键查询(组合索引键计算的Hash值,而不是单独计算)
    • 不能避免表扫描
    • 遇到大量Hash值相等的情况下,性能不一定高于B树
      • 大量记录指针信息存放在同一个buckets的情况,导致整体性能低下

1552044844178

  • BitMap位图索引
    • 当表中的某个字段,只有几种值的时候(性别-男/女)
    • 位图表示行是否存在该值
    • 锁的粒度非常大(适合并发少、统计多的系统)

密集索引和稀疏索引

1552045037555

  • 区别
    • 密集索引文件中的每个搜索码值都对应一个索引值
      • 叶子节点不仅保存键值,还保存位于同一行其它列的信息,用于密集索引决定了表的物理排列顺序;一个表只能有一个物理排列顺序(即只能有一个密集索引)
    • 稀疏索引文件只为索引码的某些值建立索引项
      • 叶子节点仅保存了键位信息(或主键信息),以及该行数据的地址

1552045444604

  • 举例MySQL的存储引擎
    • MyIsam
      • 其索引均属于稀疏索引
    • InnoDB
      • 有且仅有一个密集索引(选取规则如下)
        • 若一个主键被定义,该主键则作为密集索引
        • 若没有主键被定义,该表的第一个唯一非空索引则作为密集索引
        • 若不满足以上条件,innodb内部会生成一个隐藏主键(密集索引)
        • 非主键索引存储相关键位和其对应的主键值,包含两次查找(一次是次级索引自身,再查找主键)
  • 表的结构信息存储在.frm文件内
  • Innodb的.ibd文件,存储数据和索引
  • MyIsam的.MYI存储索引、.MYD存储数据

调优SQL

定位并优化慢查询SQL

思路
  • 根据慢日志定位慢查询SQL
1
2
3
4
5
6
7
8
9
10
11
show variablies like '%query%';
# 慢查询是否开启
show_query_log;
-> set global slow_query_log = on;
# 慢查询时间
long_query_time;
-> set global long_query_time = 1;
# 慢查询输入日志地址
show_query_log_file;
# 慢查询SQL的数量
show status like '%slow_queries%';
1
2
3
4
5
# 例200W条数据
select name from person order by name desc;
# 查询使用时间3.36s
show status like '%slow_queries%';
# 结果为1,查看慢查询日志,记录到SQL
  • 使用explain等工具分析SQL
1
2
3
4
5
6
explain select name from person order by name desc;
# id(越大越先执行)
# 关键字段type index > all 执行的全表扫描
# 关键字段extra
# Using filesort(对结果使用一个外部索引排序,而不是从表里按索引次序读取相关内容)
# Using temporary(对查询结果排序时使用临时表)
  • 修改SQL或尽量让SQL使用索引
1
2
3
# 查询其他更具有唯一性的字段
# 查询含有索引的字段
# 给某个字段添加索引
1
2
3
4
explain select count(id) from person;
# 解释器使用的type是index、key是account
# 不使用主键的原因是,MySQL的查询优化器做决定的
# 最重要的目标是:尽可能使用索引,并且是最严格的索引,来消除尽可能多的数据行

联合索引的最左匹配原则的成因

联合索引:由多列组成的索引

最左匹配原则:对A、B两列设置联合索引,使用语句where a = b 或 where a时,会使用该联合索引进行查询;但是使用where b的时候则不会使用该联合索引进行查询

1
2
3
4
5
6
7
8
9
KEY 'index_area_title'('area','title');
explain select * from person where area = 'X' and title = 'Y';
# 调用联合索引 type = ref key = index_area_title
explain select * from person where area = 'X';
# 调用联合索引 type = ref key = index_area_title
explain select * from person where title = 'Y';
# key = null type = all(全表扫描)
explain select * from person where title = 'Y' and area = 'X';
# 调用联合索引 type = ref key = index_area_title
  • 最左匹配原则
    • MySQL会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配
    • 例如a = 3 and b = 4 and c > 5 and d = 6
      • 如果建立(a,b,c,d)顺序的索引,d是用不到索引的
      • 如果建立(a,b,d,c)顺序的索引,则全部都可以用到索引,a,b,d的顺序可以任意调整
  • =和in可以乱序
    • 比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,MySQL的查询优化器可以优化索引可以识别的形式
成因

1552099317770

  • MySQL建立联合索引的规则是首先会对联合索引的最左侧(即索引的第一个字段进行排序)
    • 在第一个字段排序的基础上,再对索引上第二个字段进行排序(类似order by col1,col2)
    • 那么第一个字段是绝对有序的,而第二个字段则是无序的
    • 因此在一般情况下,直接只用第二个字段判断是使用不到索引的

索引是越多越好吗

  • 数据量小的表不需要建立索引,建立会增加额外的索引开销
  • 数据变更需要维护索引,更多的索引意味更多的维护成本
  • 更多的索引意味更多的存储空间

锁模块

MyISAM与InnoDB关于锁的区别

  • MyISAM默认表级锁,不支持行级锁
  • InnoDB默认用行级锁,也支持表级锁
  • 读锁(共享锁)、写锁(排它锁)
MyISAM
  • MyISAM在进行查询的时候,会自动给表加上一个读锁,而对数据进行增删改的时候,会加上表级的写锁
    • 当读锁没有被释放的时候,另外一个session想要对同一个表加上写锁,就会被阻塞,直到所有的读锁被释放为止
1
2
3
4
5
6
# 显式添加读锁
lock tables person_myisam read;
# 释放锁
unlock tables;
# 对于select语句显式上排它锁
select * from person_myisam where id between 1 and 2000000 for update;
InnoDB
  • 支持事务,可以通过session获取锁,暂时不自动提交的方式,模拟并发访问的过程
  • 不走索引的操作,会执行表级锁
  • MySQL默认自动提交事务
1
2
# 两个session对同一行数据进行修改
update person set title = 'test' where id = 1;
  • 结果发现,并没有出现第二个session在执行时出现阻塞的情况
    • InnoDB使用的是二段锁,即加锁和解锁两个步骤,即先对同一个事务里的一批操作分别进行加锁,然后commit的时候,再对事务里的加锁统一进行解锁,而当前的commit是自动操作
1
2
3
4
5
6
7
8
9
10
11
12
# 关闭session的自动提交
set autocommit = 0;
# 其中一个session上共享锁
select * from person where id = 3;
# 另一个session对同一行进行update操作
update person set title = 'test3' where id = 3;
# 结果第二个session的修改数据成功
# 再分别对两个session
commit;
# 发现数据已经修改成功 title = 'test3'
# 显式的给select加共享锁
select * from person where id = 3 lock in share mode;
  • 因为InnoDB对select进行了改进,第一个session中的select操作并没有对该行上锁,所以导致更新成功
  • 检验InnoDB支持行级锁
1
2
3
4
5
# session1对id=3的数据加共享锁
select * from person where id = 3 lock in share mode;
# session2对id=4的数据做更新
update person set title = 'test4' where id = 4;
# 该语句并没有被阻塞
适用场景
  • MyISAM
    • 频繁执行全表count语句
    • 对数据进行增删改的频率不高,查询非常频繁
    • 没有事务
  • InnoDB
    • 数据CRUD频繁
    • 可靠性要求比较高,要求支持事务

数据库锁的分类

  • 按锁的粒度:表级锁、行级锁、页级锁
  • 按锁的级别:共享锁、排它锁
  • 按加锁的方式:自动锁、显式锁
  • 按操作划分:DML锁、DDL锁
  • 按使用方式:乐观锁、悲观锁(https://www.imooc.com/article/details/id/44217)

数据库事务的四大特性ACID

  • 原子性:要么全做,要么全不做
  • 一致性:数据库应保证从一个一致性状态到另一个一致性状态(数据间的完整性约束)
  • 隔离性:多个事务并发执行时,一个事务的执行,不能影响另一个事务的执行
  • 持久性:一个事务一旦提交,对数据库数据的修改应该是永久影响

事务隔离级别以及各级别下的并发访问问题

ORACLE(READ-COMMITED)/ MYSQL(REPETABLE-READ)

更新丢失
  • MySQL所有事务隔离级别在数据库层面上均可避免

  • 定义:事务的更新,覆盖了另一个事务的更新

  • 1552122048065
脏读
  • READ-COMMITED事务隔离级别以上可避免

  • 定义:一个事务读取到另一个未提交事务的数据(即:读到了别的事务回滚前的脏数据)

  • 导致:网络原因修改失败失败情况下,回滚会失败,数据修改错误
不可重复读
  • REPETABLE-READ事务隔离级别以上可避免

  • 定义:事务A首先读取一条数据,然后执行事务逻辑时,事务B将该数据改变,然后事务A再次读取,发现数据不匹配

  • 导致:多次数据不同的情况下,进行某次获取数据的结果进行修改,则会造成数据紊乱的问题
幻读
  • SERIALIZABLE事务隔离级别可避免
  • 定义:事务A首先根据条件索引得到N条数据,然后事务B改变了这N条数据之外的M条或者增添了M条符合事务A搜索条件的数据,导致事务A再次搜索发现有N+M条数据(即:当前事务读第一次取到的数据比后来读取到数据条目少)

1552123889777


InnoDB可重复读隔离级别下如何避免幻读

当前读和快照读
  • 当前读
    • 读取当前记录,而且读取之后还需要保证其他并发事务不能修改当前记录,对读取的记录加锁
    • select … lock in share mode;
    • select … for update;
    • update,delete,insert
  • 快照读
    • 不加锁的非阻塞读,select
    • 有可能读取的数据不是最新版本

RC级别下的InnoDB的非阻塞读如何实现

  • 数据行里的DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID字段
    • DB_TRX_ID:表示最后一个事务的更新和插入
    • DB_ROLL_PTR:指向当前记录项undo log信息
    • DB_ROW_ID:标识插入的新数据行的ID
  • undo日志
    • 1552125758042
    • insert-undo-log
      • 事务对新记录的undo-log,只在事务回滚时需要,在事务提交后可以立即丢弃
    • update-undo-log
      • 事务进行delete或者update操作,不仅在事务回滚时需要,快照读也需要,不可随意删除;只有数据库的快照读不涉及该undo-log时,才可删除
  • read view

RR级别下的InnoDB的非阻塞读如何实现——使用next-key锁

  • 表象:快照读(非阻塞读)——伪MVCC(行级锁的变种)
  • 内在:next-key锁(行锁 + Gap锁)
    • 行锁:对单个行记录上锁
    • Gap锁:索引树中,插入记录的空隙,锁定一定范围,但不包括记录本身;防止同一事务的两次当前读,出现幻读的情况
对主键索引或唯一索引会用到Gap锁吗
  • 如果where条件全部命中,则不用Gap锁,只会加记录锁
  • 如果where条件部分命中或者全都不命中,则会加Gap锁
    • 部分命中情况下,Gap锁的范围,则是条件的最小和最大情况的范围
Gap锁会用在非唯一索引或者不走索引的当前读中
  • 非唯一索引(左开右闭区间)
    • 1552126635765
  • 不走索引
    • 1552126846557
    • 当前读不走索引的时候,都所有的Gap都上锁(类似锁表),防止幻读的效果

关键语法

GROUP BY

  • 满足SELECT子句中的列名必须为分组列或列函数(要么是GROUP BY需要用到的列;要么是统计关键字的列)
  • 列函数对于GROUP BY子句定义的每个组各返回一个结果

1552144173879

1
2
3
4
5
6
7
8
9
10
11
12
13
# 查询所有同学的学号、选课数、总成绩
select student_id,count(course_id),sum(score)
from score
group by student_id;

# 查询所有同学的学号、姓名、选课数、总成绩
select s.student_id,stu.name,count(s.course_id),sum(s.score)
from
score s,
student stu
where
s.student_id = stu.student_id
group by s.student_id;

HAVING

  • 通常与GROUP BY子句一起使用(指对GROUP BY的内容再筛选)
  • WHERE过滤行,HAVING过滤组
  • 出现在同一SQL的顺序:WHERE > GROUP BY > HAVING
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 查询平均成绩大于60分的学号和平均成绩
select student_id,avg(score)
from score
group by student_id
having avg(score) > 60;

# 查询没有学全所有课的同学的学号、姓名
select
stu.student_id,stu.name
from
student stu,score s
where
stu.student_id = s.student_id
group by
stu.student_id
having
count(*) < (
select count(*) from course
)

Spring Security | Note-18

发表于 2018-09-09

Spring Security Note-18


总结

1.引入依赖(pom.xml)

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<dependency>
<groupId>com.imooc.security</groupId>
<artifactId>imooc-security-browser</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.imooc.security</groupId>
<artifactId>imooc-security-authorize</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
</dependencies>

2.配置系统(参见 application-example.properties)

1
application.properties

3.增加UserDetailsService接口实现

1
2
3
4
5
6
7
8
9
10
@Component
public class TestUserDetailsService implements UserDetailsService{
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new User(username,passwordEncoder.encode("123456"),
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
}

4.如果需要记住我功能,需要创建数据库表(参见 db.sql)

1
2
3
4
5
6
7
-- 记住我功能用的表
create table persistent_logins (
username varchar(64) not null,
series varchar(64) primary key,
token varchar(64) not null,
last_used timestamp not null
);

5.如果需要社交登录功能,需要以下额外的步骤
1).配置appId和appSecret(qq & weixin)

1
2
3
4
# 微信登录配置,参见WeixinProperties
imooc.security.social.weixin.app-id = wxd99431bbff8305a0
imooc.security.social.weixin.app-secret = 60f78681d063590a469f1b297feff3c4
#imooc.security.social.weixin.providerId = weixin

2).创建并配置用户注册页面,并实现注册服务(需要配置访问权限),注意在服务中要调用ProviderSignInUtils的doPostSignUp方法

1
2
3
4
5
6
7
8
@Component
public class TestAuthorizeConfigProvider implements AuthorizeConfigProvider {
@Override
public boolean config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
config.antMatchers(HttpMethod.POST,"/user/regist").permitAll();
return false;
}
}

3).添加SocialUserDetailsService接口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class TestUserDetailsService implements UserDetailsService,SocialUserDetailsService{
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new User(username,passwordEncoder.encode("123456"),
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}

@Override
public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
return new SocialUser(userId,passwordEncoder.encode("123456"),
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
}

4).创建社交登录用的表 (参见 db.sql)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 社交登录用的表
create table imooc_UserConnection (
userId varchar(255) not null,
providerId varchar(255) not null,
providerUserId varchar(255),
rank int not null,
displayName varchar(255),
profileUrl varchar(512),
imageUrl varchar(512),
accessToken varchar(512) not null,
secret varchar(512),
refreshToken varchar(512),
expireTime bigint,
primary key (userId, providerId, providerUserId));
create unique index UserConnectionRank on imooc_UserConnection(userId, providerId, rank
);

Spring Security | Note-17

发表于 2018-09-08

Spring Security Note-17


Spring Security授权简介

Spring Security授权定义

我们所谓的菜单,按钮触发的对应的一个后台的URL,授权并不是看见与否,而是能否访问;

安全问题,涉及的是访问与否的问题;

在用户体验的情况下,会给出所谓的按钮和菜单,但是在没有权限访问的前提下,最终依然没有访问的能力,给出的是提示;

涉及的用户体验和安全,要求不同,给出的解决方案是不同,最终我们只需要将自定义开发权限模块和Spring Security对接即可;

举例

业务系统(普通用户)、内管系统(公司运营人员),对于这两个系统来说,授权是不同的;

在业务系统当中,只区分是否登录(浏览、操作)或区分简单的角色(普通、VIP),权限规则基本不变;

在内管系统当中,角色众多,权限复杂,权限规则随着体系和业务的发展不断变化;

在应用系统内部,需要两份数据,一份是系统配置信息(每个URL需要的权限),一份是用户权限信息(用户1有ABC权限),对于请求,将会对两份数据进行比对以获取访问请求;

应用

在BrowserSecurityConfig的authorizeRequests()已经进行URL的配置;

接下去对于角色的验证

1
2
3
4
// 指定URL的角色
.antMatchers("/user").hasRole("ADMIN")
// 指定请求方法
.antMatchers(HttpMethod.GET,"/user/*").hasRole("ADMIN")

在MyUserDetailService,可以指定返回用户的权限

1
2
3
4
5
6
7
8
9
private SocialUserDetails buildUser(String username) {
// TODO 根据用户名(数据库)查找用户信息
// 根据查找到的用户信息,判断用户是否被冻结
String password = passwordEncoder.encode("123456");
logger.info("PASSWORD FROM DB : " + password);
return new SocialUser(username, password,
true, true, true, true,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER,ROLE_ADMIN"));
}

Spring Security源码解析

在这里的学习中,我们重点关注橙色的部分Filter Security Interceptor,它最终决定你的请求是否能够通过,访问到REST API ;

如果不能访问,它会抛出异常,并且说明原因,由蓝色部分Exception Translation Filter来处理;

Anonymous Authentication Filter匿名认证过滤器,位于绿色过滤器链段最后一端;

Anonymous Authentication Filter

这个类十分简单,它主要关注你当前的SecurityContextHolder是否有Authentication,判断是否为空,实际上就是在判断,前面绿色过滤器链是否完成身份认证,如果为空,那么就会创建一个Authentication;

这个由匿名认证过滤器创建的Authentication,是匿名的principal;

通过Anonymous Authentication Filter,最终都会带有你的身份,去到Filter Security Interceptor,由它来判断你的身份是否有对API的访问权限;

1536113901081

上面此图,是与Spring Security授权相关的类和接口;

最核心的三个类FilterSecurityInterceptor & AccessDecisionManager & AccessDecisionVoter;

Filter Security Interceptor

整个控制授权的主入口;

Access Decision Manager

访问决定的管理者,它由一个抽象类Abstract Access Decision Manager,和三个具体实现AffirmativeBased & ConsensusBased & UnanimouosBased;

它在抽象类中,管理一组AccessDecisionVoter;

Abstract Access Decision Manager则会综合所有投票者的决定,给出一个最终的结果;

最终的结果,是有三套具体实现的逻辑;

AffirmativeBased 只要有一个通过就通过;

ConsensusBased 多数通过逻辑;

UnanimouosBased只要有一个不通过就不通过;

默认Spring Security使用的是第一个逻辑,只要有一个通过就通过的逻辑;

Access Decision Voter

Voter是投票者,有一组投票者,每个投票者有不同的处理逻辑;

判断Authentication是否有某种角色,是否经过完全的身份认证;

每个投票者根据自己的逻辑,投出自己的一票,投出过还是不过;

Security Config

判断你的授权是否通过,需要两块数据的配置信息;

Security Config会将系统配置信息读取出来,封装成一组Config Attribute的对象;

这一组对象中,每一个Config Attribute都会带有每一个URL所需要的权限;

Security Context Holder

当前用户的权限信息,会封装在Authentication当中;

-

在进入到Access Decision Manager之前,会封装三部分的信息Config Attribute,Authentication,请求信息,成一个对象,进行授权认证的判断;

在Spring Security3.x以后,新产生的Wen Expression Voter包办了所有的Voter的工作,在WEB的环境下,所有的Voter的工作由它承担;


权限表达式

Spring Security允许我们在定义URL访问或方法访问所应有的权限时使用Spring EL表达式,在定义所需的访问权限时如果对应的表达式返回结果为true则表示拥有对应的权限,反之则无;

表达式 描述
hasRole([role]) 当前用户是否拥有指定角色。
hasAnyRole([role1,role2]) 多个角色是一个以逗号进行分隔的字符串。如果当前用户拥有指定角色中的任意一个则返回true。
hasAuthority([auth]) 等同于hasRole
hasAnyAuthority([auth1,auth2]) 等同于hasAnyRole
Principle 代表当前用户的principle对象
authentication 直接从SecurityContext获取的当前Authentication对象
permitAll 总是返回true,表示允许所有的
denyAll 总是返回false,表示拒绝所有的
isAnonymous() 当前用户是否是一个匿名用户
isRememberMe() 表示当前用户是否是通过Remember-Me自动登录的
isAuthenticated() 表示当前用户是否已经登录认证成功了。
isFullyAuthenticated() 如果当前用户既不是一个匿名用户,同时又不是通过Remember-Me自动登录的,则返回true。

-

多权限表达式

img

-

实现

配置提供者
1
2
3
public interface AuthorizeConfigProvider {
void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config);
}
具体实现配置提供者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class ImoocAuthorizeConfigProvider implements AuthorizeConfigProvider{
@Autowired
private SecurityProperties securityProperties;
@Override
public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
config.antMatchers(
SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE,
SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX+"/*",
securityProperties.getBrowser().getLoginPage(),
securityProperties.getBrowser().getSignUpUrl(),
securityProperties.getBrowser().getSession().getSessionInvalidUrl(),
securityProperties.getBrowser().getSignOutUrl()
).permitAll();
}
}
配置管理器
1
2
3
public interface AuthorizeConfigManager {
void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config);
}
具体实现配置管理器
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class ImoocAuthorizeConfigManager implements AuthorizeConfigManager {
@Autowired
private Set<AuthorizeConfigProvider> authorizeConfigProviders;

@Override
public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
for (AuthorizeConfigProvider authorizeConfigProvider : authorizeConfigProviders) {
authorizeConfigProvider.config(config);
}
config.anyRequest().authenticated();
}
}
修改浏览器安全配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class BrowserSecurityConfig extends AbstractChannelSecurityConfig {
@Autowired
private AuthorizeConfigManager authorizeConfigManager;

@Override
protected void configure(HttpSecurity http) throws Exception {
applyPasswordAuthenticationConfig(http);
http.apply(validateCodeSecurityConfig)
...
// 删除Cookies
.deleteCookies("JSESSIONID")
.and()
.csrf().disable();
authorizeConfigManager.config(http.authorizeRequests());
}
}
修改APP资源管理器安全配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
@EnableResourceServer
public class ImoocResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private AuthorizeConfigManager authorizeConfigManager;
@Override
public void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL)
.loginProcessingUrl(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM)
.successHandler(imoocAuthenticationSuccessHandler)
.failureHandler(imoocAuthenticationFailureHandler);
http
.apply(validateCodeSecurityConfig)
.and()
.apply(smsCodeAuthenticationSecurityConfig)
.and()
.apply(imoocSocialSecurityConfig)
.and()
.apply(openIdAuthenticationSecurityConfig)
.and().csrf().disable();
authorizeConfigManager.config(http.authorizeRequests());
}
}

基于数据库RBAC数据模型控制权限

在内管系统中,以满足角色众多,权限复杂,权限规则随着业务的发展不断变化的问题,我们需要将权限放入到数据库当中,以添加,修改,删除数据的方式满足需要;

通用RBAC数据模型

RBAC(Role-Based Access Control):有大约5张表组成,3张实体表,2张关系表,分别是:

用户表:存储用户信息,由业务人员维护;

角色表:存储角色信息,由业务人员维护;

资源表:存储资源信息,菜单,按钮及其URL等,由开发人员维护;

用户-角色关系表(n-n):存储用户和角色的对应关系,由业务人员维护;

角色-资源关系表(n-n):存储角色和资源的对应关系,由业务人员维护;

声明权限服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class RbacServiceImpl implements RbacService {
private AntPathMatcher antPathMatcher = new AntPathMatcher();

@Override
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
Object principal = authentication.getPrincipal();
boolean hasPermission = false;
if (principal instanceof UserDetails) {
String username = ((UserDetails) principal).getUsername();
// 读取用户所拥有权限的所有URL
Set<String> urls = new HashSet<>();
for (String url : urls) {
if (antPathMatcher.match(url, request.getRequestURI())) {
hasPermission = true;
break;
}
}
}
return hasPermission;
}
}
1
2
3
4
5
6
7
8
@Component
@Order(Integer.MAX_VALUE)
public class DemoAuthorizeConfigProvider implements AuthorizeConfigProvider {
@Override
public void config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
config.anyRequest().access("@rbacService.hasPermission(request,authentication)");
}
}

Spring Security | Note-16

发表于 2018-09-07

Spring Security Note-16


基于JWT实现SSO单点登录


问题

在实际生活的应用中,我们举个例子而言,对于淘宝和天猫这两个完全不同的域名,是两个完全不同的服务器上;

我们在淘宝进行登录的时候,跳转到了一个域名为

login.taobao.com

意味着,服务器又换了一个,到此已经出现了三个服务器;

登录后,页面又跳转回到了淘宝,此时我们重新刷新天猫,天猫也以直接完成了登录状态;

虽然这两个网站是独立的域名,独立的服务器,但是在其中一个网站登录,在另一个网站上实现了登录;


步骤流程

应用A(淘宝) 应用B(天猫) 和认证服务器(登录服务器);

0.用户访问请求登录应用A;

1.应用A将会向认证服务器发起请求授权的请求;

2.此时在认证服务器中获取认证并授权(登录)之后;

3.认证服务区将会返回一个授权码给应用A;

4.获得授权码后,应用A会再次请求认证服务器请求令牌;

5.认证服务器接受请求,返回JWT配置下的令牌,返回给应用A;

6.应用A获得JWT令牌后,进行解析,并且完成登录;

7.当用户访问另一个系统(应用B);

8.应用B也会去认证服务器请求授权;

9.但是在认证服务器中,已存在授权记录,那么认证服务器是知道用户是谁;

10.完成整个OAuth流程,并且返回应用B一个JWT令牌(不同于应用A);

11.应用B获取到认证服务器返回给应用B的JWT进行解析并登录;

12.真正的业务是部署在一个独立的资源服务器,我们只需要使用JWT访问资源服务器即可;

PS:尽管应用A和应用B的JWT不同,但是解析出来的用户信息是一致的;


实现

我们需要重新新建项目sso-demo,在sso这个项目中,我们需要对认证服务器进行一个重新的构造,这个认证服务器是基于浏览器,session的技术;

创建四个项目;

认证服务器配置SSOAuthorizationServerConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 认证服务器
* @Author: REX
* @Date: Create in 14:34 2018/9/4
*/
@Configuration
@EnableAuthorizationServer
public class SSOAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 配置两个应用
clients.inMemory()
.withClient("app1").secret("appsecret1")
.authorizedGrantTypes("authorization_code", "refresh_token").scopes("all")
.and()
.withClient("app2").secret("appsecret2")
.authorizedGrantTypes("authorization_code", "refresh_token").scopes("all");
}

// 安全配置
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
// 授权表达式
security.tokenKeyAccess("isAuthenticated()");
}

// 配置入口
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter());
}

// 配置JWT
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// 指定密签
converter.setSigningKey("REX");
return converter;
}
}
配置文件
1
2
3
server.port=9999
server.context-path=/server
security.user.password=123123

应用A

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootApplication
@RestController
@EnableOAuth2Sso
public class SSOClient1Application {
public static void main(String[] args) {
SpringApplication.run(SSOClient1Application.class,args);
}
@GetMapping("/user")
public Authentication user(Authentication user){
return user;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
# OAuth认证(与服务器配置要一致)
security.oauth2.client.client-id=app1
security.oauth2.client.client-secret=appsecret1
# 认证服务器
security.oauth2.client.user-authorization-uri=http://127.0.0.1:9999/server/oauth/authorize
# 返回授权码
security.oauth2.client.access-token-uri=http://127.0.0.1:9999/server/oauth/token
# 解析的密钥URL
security.oauth2.resource.jwt.key-uri=http://127.0.0.1:9999/server/oauth/token_key
# SERVER
server.port=8080
server.context-path=/client1

应用B

1
2
3
4
5
6
7
8
9
10
11
12
# OAuth认证(与服务器配置要一致)
security.oauth2.client.client-id=app2
security.oauth2.client.client-secret=appsecret2
# 认证服务器
security.oauth2.client.user-authorization-uri=http://127.0.0.1:9999/server/oauth/authorize
# 返回授权码
security.oauth2.client.access-token-uri=http://127.0.0.1:9999/server/oauth/token
# 解析的密钥URL
security.oauth2.resource.jwt.key-uri=http://127.0.0.1:9999/server/oauth/token_key
# SERVER
server.port=8060
server.context-path=/client2

测试

1.首先访问127.0.0.1:8080/client1/index.html

2.跳转登录认证(9999)

username:user

password:123123

3.是否授权给APP1访问受保护的资源?

4.得到授权后返回,此时已获得认证服务器的授权

5.访问/user,获取用户信息

6.访问Client2,这时候,认证服务器已经知道我的用户信息,不需要重新登录,只需要授权即可

7.此时即可完成Client1和Client2页面之间任意的跳转

8.这时候我们会发现Client1和Client2访问/user所获得的tokenValue是不同的,但是用户信息是一样的;


改善

登录页面和A跳转B的隐藏授权;

登录

在Server项目中,重新configure为表单登录的方式;

1
2
3
4
5
6
7
@Configuration
public class SSOSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin().and().authorizeRequests().anyRequest().authenticated();
}
}

不将用户名和密码写死,通过重新实现UserDetailsService的方式,从数据库中读写用户名和密码;

1
2
3
4
5
6
7
@Component
public class SSOUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new User(username,"", AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
}

覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class SSOSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}

-

授权

找到授权的表单的页面在WhitelabelApprovalEndpoint类,在进入时直接跳转授权,不需要点击;

重新写一个WhitelabelApprovalEndpoint,将@FrameworkEndpoint改为@RestController进行处理;

并且复制一份SpelView为SsoSpelView;

我们直接将confirmationForm提交,并且隐藏<body></body>;

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<body>
<div style='display:none;'>
<h1>OAuth Approval</h1>
<p>Do you authorize '${authorizationRequest.clientId}' to access your protected resources?</p>
<form id='confirmationForm' name='confirmationForm' action='${path}/oauth/authorize' method='post'>
<input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input name='authorize' value='Authorize' type='submit'/></label>
</form>
%denial%
</div>
<script>document.getElementById('confirmationForm').submit()</script>
</body>
</html>"

Spring Security | Note-15

发表于 2018-09-06

Spring Security Note-15


重构注册逻辑

在之前浏览器的社交账号登录注册操作逻辑时,发现用户是第一次用社交帐号登录时,它会跳转至配置的注册页上,跳转到注册页之前,会将第三方用户信息放到SESSION当中;

跳转到注册页之后,可以访问/social/user服务,然后将用户信息从SESSION中提出来,并且提供了providerSignInUtils的根据,从SESSION中拿出用户数据来,一旦用户注册(绑定)完成之后,拿到一个唯一的用户标识,providerSignInUtils再把之前的第三方用户信息拿出来,做一个绑定,存到数据库中;

问题

在APP当中,之前的逻辑是想不通的,因为浏览器是基于SESSION的;

APP是一种属于无SESSION的环境,我们需要对注册逻辑进行改造;

解决

基本的操作思路与验证码的重构一致,我们不将信息存到SESSION当中,而是在传输信息时,先将用户信息存到一个外部存储(REDIS)当中,携带一个deviceId;

存放社交信息工具类AppSignUpUtils
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Component
public class AppSignUpUtils {
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
@Autowired
private UsersConnectionRepository usersConnectionRepository;
@Autowired
private ConnectionFactoryLocator connectionFactoryLocator;

public void saveConnectionData(WebRequest request, ConnectionData connectionData) {
redisTemplate.opsForValue().set(getKey(request), connectionData, 10, TimeUnit.MINUTES);
}

public void doPostSignUp(WebRequest request, String userId) {
String key = getKey(request);
if (!redisTemplate.hasKey(key)) {
throw new AppSecretException("无法找到缓存的用户社交账号信息");
}

ConnectionData connectionData = (ConnectionData) redisTemplate.opsForValue().get(key);
Connection<?> connection = connectionFactoryLocator.getConnectionFactory(connectionData.getProviderId()).createConnection(connectionData);

usersConnectionRepository.createConnectionRepository(userId).addConnection(connection);
redisTemplate.delete(key);
}

private String getKey(WebRequest request) {
String deviceId = request.getHeader("deviceId");
if (StringUtils.isBlank(deviceId)) {
throw new AppSecretException("设备ID参数不能为空");
}
return "imooc:security:social.connect." + deviceId;
}
}
SpringSocialConfigurerPostProcessor

SpringSocialConfigurerPostProcessor ,在所有的Bean初始化之前,如果是配置了imoocSocialSecurityConfig,就重行定义注册的处理器;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class SpringSocialConfigurerPostProcessor implements BeanPostProcessor {

@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if(StringUtils.equals(beanName,"imoocSocialSecurityConfig")){
ImoocSpringSocialConfigurer configurer = (ImoocSpringSocialConfigurer)bean;
configurer.signupUrl("/social/signUp");
return configurer;
}
return bean;
}
}
处理注册处理器AppSecurityController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class AppSecurityController {
@Autowired
private ProviderSignInUtils providerSignInUtils;
@Autowired
private AppSignUpUtils appSignUpUtils;

@GetMapping("/social/signUp")
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public SocialUserInfo getSocialUserInfo(HttpServletRequest request) {
SocialUserInfo userInfo = new SocialUserInfo();
Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));
userInfo.setProviderId(connection.getKey().getProviderId());
userInfo.setProviderUserId(connection.getKey().getProviderUserId());
userInfo.setNickname(connection.getDisplayName());
userInfo.setHeadimg(connection.getImageUrl());

appSignUpUtils.saveConnectionData(new ServletWebRequest(request), connection.createData());
return userInfo;
}
}

这样如果在使用社交帐号进行登录时,如果在数据库中没有对应的用户信息,就会引导进行注册,而且不是之前配置的注册页面的逻辑;

此时用户信息已经封装在UserInfo中,并存放在redis中了,在执行注册的请求;


Token处理

基本的Token参数配置

Token的处理都在认证服务器内完成,对ImoocAuthorizationServerConfig进行配置;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
@EnableAuthorizationServer
public class ImoocAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsService userDetailsService;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager).userDetailsService(userDetailsService);
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory() // Token存储信息的位置
.withClient("imooc") // 指定client-id 配置文件就不会起作用
.secret("imoocsecret") // 指定client-secret 配置文件就不会起作用
.accessTokenValiditySeconds(7200) // 令牌有效期
.authorizedGrantTypes("password", "refresh_token") // 所支持的授权模式
.scopes("all"); // 发出的权限
}
}

返回的结果当中,最明显的就是expires_in;

如果发送时,不带有scope的参数,则会返回所有的scope类型;

-

通用配置类代替
1
2
3
4
5
public class OAuth2ClientProperties {
private String clientId;
private String clientSecret;
private int accessTokenValidateSeconds = 7200;
}
1
2
3
public class OAuth2Properties {
private OAuth2ClientProperties[] clients = {};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
@EnableAuthorizationServer
public class ImoocAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private SecurityProperties securityProperties;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager).userDetailsService(userDetailsService);
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
InMemoryClientDetailsServiceBuilder builder = clients.inMemory();
if(ArrayUtils.isNotEmpty(securityProperties.getOauth2().getClients())){
for(OAuth2ClientProperties config:securityProperties.getOauth2().getClients()){
builder.withClient(config.getClientId()) // 指定client-id 配置文件就不会起作用
.secret(config.getClientSecret()) // 指定client-secret 配置文件就不会起作用
.accessTokenValiditySeconds(config.getAccessTokenValidateSeconds()) // 令牌有效期
.authorizedGrantTypes("password", "refresh_token") // 所支持的授权模式
.scopes("all","read","write"); // 发出的权限
}
}
}
}

-

配置存储方式TokenStoreConfig

现在的存储方式是存储在内存当中的,当服务重启时,就会清除;

此时我们需要将它存储在独立的容器中,例如数据库或Redis;

1
2
3
4
5
6
7
8
9
@Configuration
public class TokenStoreConfig {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Bean
public TokenStore redisTokenStore(){
return new RedisTokenStore(redisConnectionFactory);
}
}
1
2
3
4
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore).authenticationManager(authenticationManager).userDetailsService(userDetailsService);
}

-

测试


JWT替换默认令牌

JWT(Json Web Token):是JSON开放的Token标准,与默认的区别在于:

自包含:里面包含有意义的信息。Spring默认的Token是UUID生成的Token,本身无任何意义,本身不包含任何信息,信息是单独保存的。JWT的Token的是包含有意义信息的,如果存放Token的依赖储存器(Redis)奔溃或不可测情况,Token进行解读即可;

密签:为了自包含中的信息不可被随意的修改,并且不包含相关的业务信息,可用指定的密钥进行签名,防止篡改,修改即可知道;

可拓展:所包含的信息可用根据业务需求进行自定义;

-

配置TokenStoreConfig
1
2
3
4
public class OAuth2Properties {
private OAuth2ClientProperties[] clients = {};
private String jwtSigningKey = "imooc";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Configuration
public class TokenStoreConfig {
@Autowired
private RedisConnectionFactory redisConnectionFactory;

@Bean
@ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "storeType", havingValue = "redis")
public TokenStore redisTokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}

@Configuration
@ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "storeType", havingValue = "jwt", matchIfMissing = true)
public static class JwtTokenConfig {
@Autowired
private SecurityProperties securityProperties;
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// 指定密签
converter.setSigningKey(securityProperties.getOauth2().getJwtSigningKey());
return converter;
}
}
}
修改ImoocAuthorizationServerConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
@EnableAuthorizationServer
public class ImoocAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired(required = false)
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore).authenticationManager(authenticationManager).userDetailsService(userDetailsService);
if(jwtAccessTokenConverter != null){
endpoints.accessTokenConverter(jwtAccessTokenConverter);
}
}
}
返回结果(JWT)

自包含解码结果

-

此时再通过access_token去获取用户信息,发现无返回内容;

实际,我们在用户信息/user/me中的参数UserDetails,实际我们传入的参数并不是UserDetails,而是一个字符串,所以无法获取到对应的用户信息;

参数修改为Authentication
1
2
3
4
@GetMapping("/me")
public Object getCurrentUser(Authentication user){
return user;
}

-

自拓展ImoocJwtTokenEnhancer
1
2
3
4
5
6
7
8
9
public class ImoocJwtTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String, Object> info = new HashMap<>();
info.put("company", "imooc");
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
return accessToken;
}
}
添加配置TokenStoreConfig
1
2
3
4
5
@Bean
@ConditionalOnMissingBean(name = "jwtTokenEnhancer")
public TokenEnhancer jwtTokenEnhancer() {
return new ImoocJwtTokenEnhancer();
}
修改认证服务器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Autowired(required = false)
private TokenEnhancer jwtTokenEnhancer;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore).authenticationManager(authenticationManager).userDetailsService(userDetailsService);
if (jwtAccessTokenConverter != null && jwtTokenEnhancer != null) {
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancers = new ArrayList<>();
enhancers.add(jwtTokenEnhancer);
enhancers.add(jwtAccessTokenConverter);
enhancerChain.setTokenEnhancers(enhancers);
endpoints.tokenEnhancer(enhancerChain).accessTokenConverter(jwtAccessTokenConverter);
}
}
返回结果

再次访问用户信息,实际上不存在我们自拓展的信息,只包含规范内的信息;

如果需要解析自拓展的信息,则需要以下操作;

解析
添加依赖
1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>
1
2
3
4
5
6
7
8
9
@GetMapping("/me")
public Object getCurrentUser(Authentication user, HttpServletRequest request) throws UnsupportedEncodingException {
String header = request.getHeader("Authorization");
String token = StringUtils.substringAfter(header,"bearer ");
Claims claims = Jwts.parser().setSigningKey(securityProperties.getOauth2().getJwtSigningKey().getBytes("UTF-8")).parseClaimsJws(token).getBody();
String company = (String)claims.get("company");
logger.info(company);
return user;
}

令牌刷新

令牌无效后,不能反复登录去重新获取令牌,因为用户会用户体验极差;

在返回refresh_token,在无感知的情况下,获取一个新的access_token;

Spring Security | Note-14

发表于 2018-09-05

Spring Security Note-14


Spring Security OAuth核心源码

接下来,通过源码解析的方式,我们去了解如何将之前开发的

用户名密码登录方式

手机短信登录方式

社交帐号登录方式

以上三种自定义的认证方式,嫁接到Spring Security OAuth认证服务器中,并且实现Token的生成存储;

-

步骤略解

/oauth/token的请求会被TokenEndpoint拦截,通过ClientDetailsService获取ClientDetails(第三方相信),并一起封装在TokenRequest(请求的信息)中;

用这个TokenRequest会去调用一个TokenGranter的接口,在这后面,封装的是四种授权方式不同的实现,在这个接口里面,会根据你传入的grant_type具体选择一个Token的生成逻辑;

生成的逻辑,都会产生两个东西OAuth2Request(是之前两个信息ClientDetails和TokenRequest的整合)和Authentication;

这两个对象组合起来会生成一个叫OAuth2Authentication的对象,最终产生的OAuth2Authentication对象这个包含哪个第三方应用,请求哪个用户授权,授权模式,授权参数等所有的信息;

这个OAuth2Authentication对象会传入AuthorizationServerTokenServices接口的实现,最终生成一个OAuth2AccessToken令牌;

TokenStore实现令牌的存取;

TokenEnhancer实现令牌的增强;

-

密码模式

首先进入到TokenEndpoint中,处理/oauth/token的POST请求,拿到的第三方信息,根据第三方信息去创建一个TokenRequest;

拿到TokenRequest之后,去进行一系列的判断,最终会传到TokenGranter的grant当中产生最终的Token;

CompositeTokenGranter
1
2
3
4
5
6
7
8
9
10
// 四种授权模式 & 刷新令牌的模式根据grant_type判断
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
for (TokenGranter granter : tokenGranters) {
OAuth2AccessToken grant = granter.grant(grantType, tokenRequest);
if (grant!=null) {
return grant;
}
}
return null;
}
AbstractTokenGranter

getAccessToken产生最终的Token;

1
2
3
protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
}
DefaultTokenServices
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
// 从tokenStore获取OAuth2AccessToken(如果令牌存在,不同的授权模式下将返回同一个令牌)
OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
OAuth2RefreshToken refreshToken = null;
// 判断是否过期
if (existingAccessToken != null) {
if (existingAccessToken.isExpired()) {
if (existingAccessToken.getRefreshToken() != null) {
// 删除过期的令牌
refreshToken = existingAccessToken.getRefreshToken();
tokenStore.removeRefreshToken(refreshToken);
}
tokenStore.removeAccessToken(existingAccessToken);
}
else {
// 如果令牌存在则重新存储
tokenStore.storeAccessToken(existingAccessToken, authentication);
// 存储后直接返回
return existingAccessToken;
}
}
// 判断刷新令牌不存在
if (refreshToken == null) {
// 创建刷新令牌
refreshToken = createRefreshToken(authentication);
}
else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
// 过期
ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
refreshToken = createRefreshToken(authentication);
}
}
// 根据刷新令牌创建OAuth2AccessToken
OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
tokenStore.storeAccessToken(accessToken, authentication);
refreshToken = accessToken.getRefreshToken();
if (refreshToken != null) {
tokenStore.storeRefreshToken(refreshToken, authentication);
}
//返 回OAuth2AccessToken
return accessToken;
}

重构用户名密码登录

我们通过登录请求,到自定义的Filter重新到登录逻辑进行处理,在自定义的AuthenticationSuccessHandler处理之后,再获得OAuth2Request和Authentication,再达到最后的Token的获取;

通过请求参数,获取CilentId,根据ClientId通过ClientDetailsService获取ClientDetails,让ClientDetails和TokenRequest一同封装成OAuth2Request;

最终目标是得到OAuth2Request对象;

-

改造Token的生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Component("imoocAuthenticationSuccessHandler")
public class ImoocAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private ClientDetailsService clientDetailsService;
@Autowired
private AuthorizationServerTokenServices authorizationServerTokenServices;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
logger.info("登录成功");
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Basic ")) {
throw new UnapprovedClientAuthenticationException("请求头中无CLIENT信息");
}
// 解码获取ClientId
String[] tokens = extractAndDecodeHeader(header, request);
assert tokens.length == 2;
String clientId = tokens[0];
String clientSecret = tokens[1];
// 获取ClientDetails
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
if (clientDetails == null) {
throw new UnapprovedClientAuthenticationException("CLIENT_ID对应配置信息不存在:" + clientId);
} else if (!StringUtils.equals(clientDetails.getClientSecret(), clientSecret)) {
throw new UnapprovedClientAuthenticationException("CLIENT_SECRET信息不匹配:" + clientSecret);
}
// TokenRequest
TokenRequest tokenRequest = new TokenRequest(MapUtils.EMPTY_MAP, clientId, clientDetails.getScope(), "custom");
// OAuth2Request
OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
// 用OAuth2Request和Authentication封装成OAuth2Authentication
OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication);
// 传给AuthorizationServerTokenServices
OAuth2AccessToken token = authorizationServerTokenServices.createAccessToken(oAuth2Authentication);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(token));
}

private String[] extractAndDecodeHeader(String header, HttpServletRequest request) throws IOException {
byte[] base64Token = header.substring(6).getBytes("UTF-8");
byte[] decoded;
try {
decoded = Base64.decode(base64Token);
} catch (IllegalArgumentException e) {
throw new BadCredentialsException("Failed to decode basic authentication token");
}
String token = new String(decoded, "UTF-8");
int delim = token.indexOf(":");
if (delim == -1) {
throw new BadCredentialsException("Invalid basic authentication token");
}
return new String[]{token.substring(0, delim), token.substring(delim + 1)};
}
}

改造完成,我们还需要在安全配置类中去配置,保护我们的资源安全;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Configuration
@EnableResourceServer
public class ImoocResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
@Autowired
private SpringSocialConfigurer imoocSocialSecurityConfig;

@Override
public void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL)
.loginProcessingUrl(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM)
.successHandler(imoocAuthenticationSuccessHandler)
.failureHandler(imoocAuthenticationFailureHandler);
http
// .apply(validateCodeSecurityConfig)
// .and()
.apply(smsCodeAuthenticationSecurityConfig)
.and()
.apply(imoocSocialSecurityConfig)
.and()
.authorizeRequests()
// 当访问以下URL,不需要身份认证
.antMatchers(
SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE,
SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX+"/*",
securityProperties.getBrowser().getLoginPage(),
securityProperties.getBrowser().getSignUpUrl(),
securityProperties.getBrowser().getSession().getSessionInvalidUrl(),
securityProperties.getBrowser().getSignOutUrl(),
"/user/regist"
).permitAll()
// 任何请求
.anyRequest()
// 认证后才能访问
.authenticated().and().csrf().disable();
}
}

启动项目测试,做一个登陆的请求,携带用户名和密码并携带:authentication(封装clientId和clientSecret)的信息,因为项目的用户名密码的登录请求路径是authentication/form;


重构短信登录

问题

上部分在用户名密码的登录方式中,我们用Token的方式已经重新实现了,但是依然有许多的问题;

短信验证码登录的逻辑,设计到验证码存储的逻辑,

在这之前,我们是通过浏览器去发起请求,然后在服务器中生成,存储在SESSION当中的;

在之后的请求当中,再对验证码进行校验;

-

解决

在APP的架构下,服务器是不存在SESSION的,那么验证码是无处可放的;

解决的思路,我们是在APP发起请求时,在生成和校验验证码逻辑的过程中,加上deviceId(设备ID),根据你的deviceId放到外部存储当中,进行生成和校验;

我们需要重构验证码的相关代码;

1
2
3
4
5
public interface ValidateCodeProcessor {
String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";
void create(ServletWebRequest request) throws Exception;
void validate(ServletWebRequest servletWebRequest);
}
1
2
3
4
5
6
7
8
public interface ValidateCodeRepository {
// 保存验证码
void save(ServletWebRequest request,ValidateCode code,ValidateCodeType validateCodeType);
// 获取验证码
ValidateCode get(ServletWebRequest request,ValidateCodeType validateCodeType);
// 删除验证码
void remove(ServletWebRequest request,ValidateCodeType validateCodeType);
}

在APP项目中的验证码逻辑;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Component
public class RedisValidateCodeRepository implements ValidateCodeRepository {
@Autowired
private RedisTemplate<Object, Object> redisTemplate;

@Override
public void save(ServletWebRequest request, ValidateCode code, ValidateCodeType type) {
redisTemplate.opsForValue().set(buildKey(request, type), code, 30, TimeUnit.MINUTES);
}

@Override
public ValidateCode get(ServletWebRequest request, ValidateCodeType type) {
Object value = redisTemplate.opsForValue().get(buildKey(request, type));
if (value == null) {
return null;
}
return (ValidateCode) value;
}

@Override
public void remove(ServletWebRequest request, ValidateCodeType type) {
redisTemplate.delete(buildKey(request, type));
}

private String buildKey(ServletWebRequest request, ValidateCodeType type) {
String deviceId = request.getHeader("deviceId");
if (StringUtils.isBlank(deviceId)) {
throw new ValidateCodeException("请在请求头中携带deviceId参数");
}
return "code:" + type.toString().toLowerCase() + ":" + deviceId;
}
}

在Browser项目中的验证码逻辑;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class SessionValidateCodeRepository implements ValidateCodeRepository {
String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";
/**
* 操作session的工具类
*/
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Override
public void save(ServletWebRequest request, ValidateCode code, ValidateCodeType validateCodeType) {
sessionStrategy.setAttribute(request, getSessionKey(request, validateCodeType), code);
}
/**
* 构建验证码放入session时的key
*/
private String getSessionKey(ServletWebRequest request, ValidateCodeType validateCodeType) {
return SESSION_KEY_PREFIX + validateCodeType.toString().toUpperCase();
}
@Override
public ValidateCode get(ServletWebRequest request, ValidateCodeType validateCodeType) {
return (ValidateCode) sessionStrategy.getAttribute(request, getSessionKey(request, validateCodeType));
}

@Override
public void remove(ServletWebRequest request, ValidateCodeType codeType) {
sessionStrategy.removeAttribute(request, getSessionKey(request, codeType));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public abstract class AbstractValidateCodeProcessor<C extends ValidateCode> implements ValidateCodeProcessor {
/**
* 收集系统中所有的 {@link ValidateCodeGenerator} 接口的实现
*/
@Autowired
private Map<String, ValidateCodeGenerator> validateCodeGenerators;
@Autowired
private ValidateCodeRepository validateCodeRepository;

@Override
public void create(ServletWebRequest request) throws Exception {
C validateCode = generate(request);
save(request, validateCode);
send(request, validateCode);
}

/**
* 生成校验码
*/
@SuppressWarnings("unchecked")
private C generate(ServletWebRequest request) {
String type = getValidateCodeType(request).toString().toLowerCase();
String generatorName = type + ValidateCodeGenerator.class.getSimpleName();
ValidateCodeGenerator validateCodeGenerator = validateCodeGenerators.get(generatorName);
if (validateCodeGenerator == null) {
throw new ValidateCodeException("验证码生成器" + generatorName + "不存在");
}
return (C) validateCodeGenerator.generate(request);
}

/**
* 保存校验码
*/
private void save(ServletWebRequest request, C validateCode) {
ValidateCode code = new ValidateCode(validateCode.getCode(), validateCode.getExpireTime());
validateCodeRepository.save(request, code, getValidateCodeType(request));
}

/**
* 发送校验码,由子类实现
*/
protected abstract void send(ServletWebRequest request, C validateCode) throws Exception;

/**
* 根据请求的url获取校验码的类型
*/
private ValidateCodeType getValidateCodeType(ServletWebRequest request) {
String type = StringUtils.substringBefore(getClass().getSimpleName(), "CodeProcessor");
return ValidateCodeType.valueOf(type.toUpperCase());
}

@SuppressWarnings("unchecked")
@Override
public void validate(ServletWebRequest request) {
ValidateCodeType codeType = getValidateCodeType(request);
C codeInSession = (C) validateCodeRepository.get(request, codeType);

String codeInRequest;
try {
codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), codeType.getParamNameOnValidate());
} catch (ServletRequestBindingException e) {
throw new ValidateCodeException("获取验证码失败");
}

if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException(codeType + "验证码不能为空");
}
if (codeInSession == null) {
throw new ValidateCodeException(codeType + "验证码不存在");
}
if (codeInSession.isExpired()) {
validateCodeRepository.remove(request, codeType);
throw new ValidateCodeException(codeType + "验证码已过期");
}

if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException(codeType + "验证码不匹配");
}

validateCodeRepository.remove(request, codeType);
}

}

测试

向手机:13012345678. 发送短信验证码801409

-

-

-

在工具里发送的验证码登录请求,实际上以上是带上Cookies的HTTP请求,所以我们需要通过一段CMD代码,转化为APP的请求;

1
2
3
4
5
6
7
curl -X POST \
http://127.0.0.1:8060/authentication/mobile \
-H 'authorization: Basic aW1vb2M6aW1vb2NzZWNyZXQ=' \
-H 'cache-control: no-cache' \
-H 'content-type: application/x-www-form-urlencoded' \
-H 'deviceid: 007' \
-H 'postman-token: 3646c210-6b71-316d-f1a2-f7cf91a242a6'

重构社交登录

简化模式

用户直接访问app,在app里面点击QQ或微信的第三方登录,QQ或微信返回openId和accessToken,app用openId换取令牌,然后返回令牌;

封装请求信息TokenOpenIdAuthenticationToken
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class OpenIdAuthenticationToken extends AbstractAuthenticationToken {

private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

private final Object principal;
private String providerId;

public OpenIdAuthenticationToken(String openId, String providerId) {
super(null);
this.principal = openId;
this.providerId = providerId;
setAuthenticated(false);
}

public OpenIdAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}

public Object getCredentials() {
return null;
}

public Object getPrincipal() {
return this.principal;
}

public String getProviderId() {
return providerId;
}

public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}
super.setAuthenticated(false);
}

@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}

需要一个过滤器,拦截登录请求,然后把信息封装,交到OpenIdAuthenticationToken里面;

-

拦截请求OpenIdAuthenticationFilter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface SecurityConstants {
/**
* openid参数名
*/
String DEFAULT_PARAMETER_NAME_OPENID = "openId";
/**
* providerId参数名
*/
String DEFAULT_PARAMETER_NAME_PROVIDERID = "providerId";
/**
* 默认的OPENID登录请求处理url
*/
String DEFAULT_LOGIN_PROCESSING_URL_OPENID = "/authentication/openid";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class OpenIdAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private String openIdParameter = SecurityConstants.DEFAULT_PARAMETER_NAME_OPENID;
private String providerIdParameter = SecurityConstants.DEFAULT_PARAMETER_NAME_PROVIDERID;
private boolean postOnly = true;

public OpenIdAuthenticationFilter() {
super(new AntPathRequestMatcher(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_OPENID, "POST"));
}

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String openid = obtainOpenId(request);
String providerId = obtainProviderId(request);
if (openid == null) {
openid = "";
}
if (providerId == null) {
providerId = "";
}
openid = openid.trim();
providerId = providerId.trim();
OpenIdAuthenticationToken authRequest = new OpenIdAuthenticationToken(openid, providerId);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* 获取openId
*/
protected String obtainOpenId(HttpServletRequest request) {
return request.getParameter(openIdParameter);
}
/**
* 获取提供商id
*/
protected String obtainProviderId(HttpServletRequest request) {
return request.getParameter(providerIdParameter);
}

protected void setDetails(HttpServletRequest request, OpenIdAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}

public void setOpenIdParameter(String openIdParameter) {
Assert.hasText(openIdParameter, "Username parameter must not be empty or null");
this.openIdParameter = openIdParameter;
}

public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}

public final String getOpenIdParameter() {
return openIdParameter;
}

public String getProviderIdParameter() {
return providerIdParameter;
}

public void setProviderIdParameter(String providerIdParameter) {
this.providerIdParameter = providerIdParameter;
}
}
验证服务器OpenIdAuthenticationProvider
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class OpenIdAuthenticationProvider implements AuthenticationProvider {
private SocialUserDetailsService userDetailsService;

private UsersConnectionRepository usersConnectionRepository;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {

OpenIdAuthenticationToken authenticationToken = (OpenIdAuthenticationToken) authentication;

Set<String> providerUserIds = new HashSet<>();
providerUserIds.add((String) authenticationToken.getPrincipal());
Set<String> userIds = usersConnectionRepository.findUserIdsConnectedTo(authenticationToken.getProviderId(), providerUserIds);

if(CollectionUtils.isEmpty(userIds) || userIds.size() != 1) {
throw new InternalAuthenticationServiceException("无法获取用户信息");
}

String userId = userIds.iterator().next();

UserDetails user = userDetailsService.loadUserByUserId(userId);

if (user == null) {
throw new InternalAuthenticationServiceException("无法获取用户信息");
}

OpenIdAuthenticationToken authenticationResult = new OpenIdAuthenticationToken(user, user.getAuthorities());

authenticationResult.setDetails(authenticationToken.getDetails());

return authenticationResult;
}

@Override
public boolean supports(Class<?> authentication) {
return OpenIdAuthenticationToken.class.isAssignableFrom(authentication);
}
}
安全配置类OpenIdAuthenticationSecurityConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Component
public class OpenIdAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private SocialUserDetailsService userDetailsService;
@Autowired
private UsersConnectionRepository usersConnectionRepository;

@Override
public void configure(HttpSecurity http) throws Exception {

OpenIdAuthenticationFilter OpenIdAuthenticationFilter = new OpenIdAuthenticationFilter();
OpenIdAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
OpenIdAuthenticationFilter.setAuthenticationSuccessHandler(imoocAuthenticationSuccessHandler);
OpenIdAuthenticationFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler);

OpenIdAuthenticationProvider OpenIdAuthenticationProvider = new OpenIdAuthenticationProvider();
OpenIdAuthenticationProvider.setUserDetailsService(userDetailsService);
OpenIdAuthenticationProvider.setUsersConnectionRepository(usersConnectionRepository);

http.authenticationProvider(OpenIdAuthenticationProvider).addFilterAfter(OpenIdAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
资源服务器配置ImoocResourceServerConfig

将刚刚写好的OpenId安全配置类加入到配置当中;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@EnableResourceServer
public class ImoocResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private OpenIdAuthenticationSecurityConfig openIdAuthenticationSecurityConfig;
@Override
public void configure(HttpSecurity http) throws Exception {
...
http.
...
.apply(openIdAuthenticationSecurityConfig)
.and()
...
}
}

发送请求,通过OpenId获取信息,成功登录;


标准授权码模式

标准的授权码模式,APP请求QQ或微信去获取授权码;

然后将授权码交给我们自己的第三方client,由第三方client带着授权码去QQ;

或微信申请令牌,然后发放令牌给第三方应用,然后读取用户数据;

然后第三方应用重新生成自己的令牌返回给APP;

在此处换令牌之前获取授权码,停止服务;

http://xxx/qqLogin/weixin

?code=061f8yj728JKBJ0DADl72sMEj72f8yjh

&state=344fadca-77e7-47a4-a3cd-7cd988f1953d

1
2
3
public interface SocialAuthenticationFilterPostProcessor {
void process(SocialAuthenticationFilter socialAuthenticationFilter);
}
1
2
3
4
5
6
7
8
9
10
11
public class ImoocSpringSocialConfigurer extends SpringSocialConfigurer {
private SocialAuthenticationFilterPostProcessor socialAuthenticationFilterPostProcessor;
@Override
protected <T> T postProcess(T object) {
...
if(socialAuthenticationFilterPostProcessor != null){
socialAuthenticationFilterPostProcessor.process(filter);
}
return (T) filter;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Order(1)
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
@Autowired(required = false)
private SocialAuthenticationFilterPostProcessor socialAuthenticationFilterPostProcessor;

// 将SpringSocialFilter添加到安全配置的Bean
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
... configurer.setSocialAuthenticationFilterPostProcessor(socialAuthenticationFilterPostProcessor);
return configurer;
}
}

将成功处理器设置进来imoocAuthenticationSuccessHandler

1
2
3
4
5
6
7
8
9
10
@Component
public class AppSocialAuthenticationFilterPostProcessor implements SocialAuthenticationFilterPostProcessor {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;

@Override
public void process(SocialAuthenticationFilter socialAuthenticationFilter) {
socialAuthenticationFilter.setAuthenticationSuccessHandler(imoocAuthenticationSuccessHandler);
}
}

完成返回第三方应用生成的Token给APP;

Spring Security | Note-13

发表于 2018-09-04

Spring Security Note-13


开发APP认证框架

基于服务器

在之前的学习中,我们所保存的用户信息,Session等都是保存在服务器中的;

用户通过浏览器,每次访问服务的时候,服务器都会检查浏览器的Cookies中是否包含JSESSIONID;

如果不包含JSESSIONID,就会新建一个SESSION,新建的SESSION就会写到Cookies中,每次用户请求时,都会去检查SESSION,拿出用户的信息;

更新换代

前后端分离的服务,将HTML服务与应用服务分离开,单独部署到Web Server上;

在这种部署模式下,用户通过浏览器直接访问的是Web Server而不是Application Server,然后针对HTML的渲染由Web Server完成,ajax请求发给Web Server之后,再向Application Server发送请求,拿到数据;

不管是APP还是前后端分离的部署,最根本的出现就是:

用户不再是通过浏览器直接访问应用

而是通过第三方应用(APP,Web Server)

在这种架构模式下,继续使用Cookies和Session就会出现问题:

1.开发繁琐(浏览器已经封装好)

2.安全性和客户体验差(认证工作由服务器完成,SESSION失效与登录)

3.有些前端技术不支持Cookies(微信小程序)

Spring Security OAuth

解决这种架构下的方法,其中一种是通过令牌(Token)的方式解决;

令牌是一种类似SESSION的唯一标识,在SESSION模式下,是往Cookies中写入JSESSIONID,而令牌是直接发给用户一个唯一标识的Token,用户每次访问带着令牌,Application Server去认证自身发出的令牌;

-

在接下来的开发过程中,开发和认证的方式,就改变成了令牌的收发和认证,使用OAuth协议来解决开发问题;

在OAuth协议中,Spring Social封装了大部分的开发内容,直接使用就可以完成第三方应用Client基本功能;

接下来学习的Spring Security OAuth则是封装了服务提供商的绝大部分行为,可以实现发令牌,认证令牌;

-

为了完成服务提供商的功能,我们需要实现认证服务器和资源服务器的功能;

在认证服务器中,需要完成4种授权模式,确认用户的身份和权限,通过4种授权模式,根据这些信息生成Token和存储;

在资源服务器下,我们需要保护资源(REST服务),通过Spring Security的过滤器链进行保护,在Spring Security OAuth中,通过在过滤器链上加入OAuth2AuthenticationProcessingFilter的方式,对请求中拿出Token,在存储策略中进行认证;

在实际开发中,我们不希望用户通过4种标准的授权模式(手机短信),我们希望通过自定义的认证方式,在认证服务器中实现认证;


实现标准的OAuth2服务提供商(Provider)

1.首先我们将SimpleRespon移动到Core项目当中;

2.将BrowserSecurityConfig的PasswordEncoder移动到Core项目的SecurityCoreConfig;

认证服务器
1
2
3
4
@Configuration
@EnableAuthorizationServer
public class ImoocAuthorizationServerConfig {
}
资源服务器
1
2
3
4
@Configuration
@EnableResourceServer
public class ImoocResourceServerConfig {
}

-

访问四种授权模式

访问/oauth/authorize 需要附带参数:

response_type : code

client_id : xxx

redirect_uri : http://

scope : all

启动时默认生成的

1
2
security.oauth2.client.client-id = 64f9b36e-d819-4526-8047-d6f80cf16123
security.oauth2.client.client-secret = 1d5527b0-33f8-4a9d-8536-312ff78d69d0
在配置文件中配置
1
2
security.oauth2.client.client-id=imooc
security.oauth2.client.client-secret=imoocsecret
修改MyUserDetailService

添加ROLE_USER的角色(默认必须有这个角色)

1
2
3
4
5
6
7
8
9
private SocialUserDetails buildUser(String username) {
// TODO 根据用户名(数据库)查找用户信息
// 根据查找到的用户信息,判断用户是否被冻结
String password = passwordEncoder.encode("123456");
logger.info("PASSWORD FROM DB : " + password);
return new SocialUser(username, password,
true, true, true, true,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
}

-

授权码模式
获取授权码

http://localhost:8060/oauth/authorize?response_type=code&client_id=imooc&redirect_uri=http://example.com&scope=all

在工具内验证授权码模式请求:

返回结果

-

密码模式
发送请求

返回结果

-

获取用户信息

对比我们可以发现,在expires_in没过期的前提下,同一个用户在两种授权模式下的access_token是一样的;

401

在不附带参数的情况下,获取用户数据的话,会报出401的错误;

-

200

当我们获取到access_token之后,就可以通过access_token & token_type获取用户信息;

Spring Security | Note-12

发表于 2018-09-04

Spring Security Note-12


退出登录

如何退出登录

退出登录,需要访问一个特定的服务,默认情况下,服务的路径是/logout;

并且退出成功后跳转的URL是登录URL + ?logout的路径;

Spring Security默认的退出处理逻辑

使当前session失效;

清除与当前用户相关的remember-me记录;

清空当前的SecurityContext;

重定向到登录页;

与退出登录相关的配置
自定义成功退出的逻辑处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ImoocLogoutSuccessHandler implements LogoutSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
private String signOutUrl;
private ObjectMapper objectMapper = new ObjectMapper();

public ImoocLogoutSuccessHandler(String signOutUrl) {
this.signOutUrl = signOutUrl;
}

@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("退出成功");
// 自定义退出成功的逻辑
if (StringUtils.isBlank(signOutUrl)) {
// 无页面,返回JSON
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponse("退出成功")));
} else {
response.sendRedirect(signOutUrl);
}
}
}
注入Bean
1
2
3
4
5
6
7
8
9
10
@Configuration
public class BrowserSecurityBeanConfig {
@Autowired
private SecurityProperties securityProperties;
@Bean
@ConditionalOnMissingBean(LogoutSuccessHandler.class)
public LogoutSuccessHandler logoutSuccessHandler(){
return new ImoocLogoutSuccessHandler(securityProperties.getBrowser().getSignOutUrl());
}
}
BrowserProperties配置URL
1
2
3
public class BrowserProperties {
private String signOutUrl = "/logout.html";
}
配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void configure(HttpSecurity http) throws Exception {
applyPasswordAuthenticationConfig(http);
http.
...
// 默认
.and()
.logout().logoutUrl("/signOut")
// 成功退出的自定义URL
// .logoutSuccessUrl("/imooc-logout.html")
// 成功退出的自定义逻辑(与URL冲突)
.logoutSuccessHandler(logoutSuccessHandler)
// 删除Cookies
.deleteCookies("JSESSIONID")
.and()
// 都需要认证
...
}

Spring Security | Note-11

发表于 2018-09-03

Spring Security Note-11


Session管理

Session超时管理
1
server.session.timeout=10

在设置了超时时间之后,等待片刻,重新刷新页面;

实际上发现,我们依然能够获取到用户的信息,session并没有清除;

在SpringBoot的源码当中,我们可以发现,默认最少超时时间是一分钟,所以我们设置的时间,应该要大于60秒;

在某些情况下,我们需要提示用户session失效,让用户重新登录;

添加配置
1
2
3
4
5
6
7
8
@Override
protected void configure(HttpSecurity http) throws Exception {
applyPasswordAuthenticationConfig(http);
http.
...
.and().sessionManagement().invalidSessionUrl("/session/invalid")
...
}
1
2
3
4
5
6
7
8
9
@RestController
public class BrowserSecurityController {
@GetMapping("/session/invalid")
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponse sessionInvalid() {
String message = "SESSION 失效";
return new SimpleResponse(message);
}
}
Session并发控制
添加配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void configure(HttpSecurity http) throws Exception {
applyPasswordAuthenticationConfig(http);
http.
...
.and().sessionManagement()
// session 过期返回URL
.invalidSessionUrl("/session/invalid")
// 最大session并发数量
.maximumSessions(1)
// false之后登录将会踢下之前的登录,true则是不允许之后登录
.maxSessionsPreventsLogin(false)
// 登录被踢掉时的自定义操作
.expiredSessionStrategy(new ImoocExpiredSessionStrategy())
.and()
// 默认
...
}
1
2
3
4
5
6
7
public class ImoocExpiredSessionStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
event.getResponse().setContentType("application/json;charset=UTF-8");
event.getResponse().getWriter().write("并发登录");
}
}
重构
1
2
3
4
5
6
7
8
public class SessionProperties {
// 最大session数 默认1
private int maximumSessions = 1;
// 并发登录的默认阻止设置 默认false
private boolean maxSessionsPreventsLogin;
// session失效的跳转地址
private String sessionInvalidUrl = SecurityConstants.DEFAULT_SESSION_INVALID_URL;
}
session失效以及并发控制的自定义的策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class AbstractSessionStrategy {
private final Logger logger = LoggerFactory.getLogger(getClass());
/**
* 跳转的url
*/
private String destinationUrl;
/**
* 重定向策略
*/
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
/**
* 跳转前是否创建新的session
*/
private boolean createNewSession = true;

private ObjectMapper objectMapper = new ObjectMapper();

public AbstractSessionStrategy(String invalidSessionUrl) {
Assert.isTrue(UrlUtils.isValidRedirectUrl(invalidSessionUrl), "url must start with '/' or with 'http(s)'");
this.destinationUrl = invalidSessionUrl;
}

protected void onSessionInvalid(HttpServletRequest request, HttpServletResponse response) throws IOException {

if (createNewSession) {
request.getSession();
}

String sourceUrl = request.getRequestURI();
String targetUrl;

if (StringUtils.endsWithIgnoreCase(sourceUrl, ".html")) {
targetUrl = destinationUrl + ".html";
logger.info("session失效,跳转到" + targetUrl);
redirectStrategy.sendRedirect(request, response, targetUrl);
} else {
String message = "session已失效";
if (isConcurrency()) {
message = message + ",有可能是并发登录导致的";
}
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponse(message)));
}

}
protected boolean isConcurrency() {
return false;
}
public void setCreateNewSession(boolean createNewSession) {
this.createNewSession = createNewSession;
}
}
session超时的配置处理类
1
2
3
4
5
6
7
8
9
10
11
12
13
public class ImoocExpiredSessionStrategy extends AbstractSessionStrategy implements SessionInformationExpiredStrategy {
public ImoocExpiredSessionStrategy(String invalidSessionUrl) {
super(invalidSessionUrl);
}
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
onSessionInvalid(event.getRequest(), event.getResponse());
}
@Override
protected boolean isConcurrency() {
return true;
}
}
session失效的配置处理类
1
2
3
4
5
6
7
8
9
10
public class ImoocInvalidSessionStrategy extends AbstractSessionStrategy implements InvalidSessionStrategy {
public ImoocInvalidSessionStrategy(String invalidSessionUrl) {
super(invalidSessionUrl);
}
@Override
public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
onSessionInvalid(request, response);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void configure(HttpSecurity http) throws Exception {
applyPasswordAuthenticationConfig(http);
http.
...
.and().sessionManagement()
// session 过期返回URL
.invalidSessionStrategy(invalidSessionStrategy)
// 最大session并发数量
.maximumSessions(securityProperties.getBrowser().getSession().getMaximumSessions())
// false之后登录将会踢下之前的登录,true则是不允许之后登录
.maxSessionsPreventsLogin(securityProperties.getBrowser().getSession().isMaxSessionsPreventsLogin())
// 登录被踢掉时的自定义操作
.expiredSessionStrategy(expiredSessionStrategy)
.and()
// 默认
...
}
配置注入Bean
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class BrowserSecurityBeanConfig {
@Autowired
private SecurityProperties securityProperties;
@Bean
@ConditionalOnMissingBean(InvalidSessionStrategy.class)
public InvalidSessionStrategy invalidSessionStrategy(){
return new ImoocInvalidSessionStrategy(securityProperties.getBrowser().getSession().getSessionInvalidUrl());
}

@Bean
@ConditionalOnMissingBean(SessionInformationExpiredStrategy.class)
public SessionInformationExpiredStrategy sessionInformationExpiredStrategy(){
return new ImoocExpiredSessionStrategy(securityProperties.getBrowser().getSession().getSessionInvalidUrl());
}
}

集群Session管理
问题:

面对一个问题,任何一个软件在面向用户使用的时候,至少部署两台机器;

在集群环境下,基于Session的认证就会存在问题;

用户的登录请求发送到Server1时,登录成功后,所有的session等信息,存储在Server1中;

在后续用户发送的服务请求中, 如果在负载均衡上没有做出处理的话,请求可能会发送到Server2中;

那么当后面的请求发送到Server2上,在Server2中不存在用户的session等信息认证,那么Server2将会拒绝用户的服务请求,需要再次登录;

解决方案:

将session抽取出来,放在一个独立的存取中,不放在服务器上;

在Browser添加依赖
1
2
3
4
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>

Spring Social提供的支持Session的类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public enum StoreType {
/**
* Redis backed sessions.
*/
REDIS,
/**
* Mongo backed sessions.
*/
MONGO,
/**
* JDBC backed sessions.
*/
JDBC,
/**
* Hazelcast backed sessions.
*/
HAZELCAST,
/**
* Simple in-memory map of sessions.
*/
HASH_MAP,
/**
* No session data-store.
*/
NONE;

}
配置
1
2
# SESSION
spring.session.store-type=redis

这样就能做到将session存储在redis中;

图形验证码

使ValidateCode实现Serializable接口,才能将图形验证码放入到session当中;

1
public class ValidateCode implements Serializable{}

不将图片放入session当中;

1
2
3
4
5
6
7
/**
* 保存校验码
*/
private void save(ServletWebRequest request, C validateCode) {
ValidateCode code = new ValidateCode(validateCode.getCode(),validateCode.getExpireTime());
sessionStrategy.setAttribute(request, getSessionKey(request), code);
}
12…5

REX CHEN

日常记录

47 日志
20 标签
© 2019 REX CHEN