SpringBoot学习之单点登录
单点登录
-
概念
单点登录全称Single Sign On(以下简称SSO),是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分
登录
-
实现原理
sso需要一个独立的认证中心(CAS),只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。
间接授权通过令牌实现,sso认证中心验证用户的用户名密码没问题,创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。
-
登录流程图
-
登录流程详解
-
用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
-
sso认证中心发现用户未登录,将用户引导至登录页面
-
用户输入用户名密码提交登录申请
-
sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
-
sso认证中心带着令牌跳转会最初的请求地址(系统1)
-
系统1拿到令牌,去sso认证中心校验令牌是否有效
-
sso认证中心校验令牌,返回有效,注册系统1
-
系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
-
用户访问系统2的受保护资源
-
系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
-
sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
-
系统2拿到令牌,去sso认证中心校验令牌是否有效
-
sso认证中心校验令牌,返回有效,注册系统2
-
系统2使用该令牌创建与用户的局部会话,返回受保护资源
-
-
全局会话与局部会话
用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心。
全局会话与局部会话有如下约束关系:
-
局部会话存在,全局会话一定存在
-
全局会话存在,局部会话不一定存在
-
全局会话销毁,局部会话必须销毁
-
注销
-
实现原理
在一个子系统中注销,所有子系统的会话都将被销毁
-
注销流程图
-
注销流程详解
sso认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作
-
用户向系统1发起注销请求
-
系统1根据用户与系统1建立的会话id拿到令牌,向sso认证中心发起注销请求
-
sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址
-
sso认证中心向所有注册系统发起注销请求
-
各注册系统接收sso认证中心的注销请求,销毁局部会话
-
sso认证中心引导用户至登录页面
-
部署
-
实现原理
单点登录涉及sso认证中心与众子系统,子系统与sso认证中心需要通信以交换令牌、校验令牌及发起注销请求,因而子系统必须集成sso的客户端,sso认证中心则是sso服务端,整个单点登录过程实质是sso客户端与服务端通信的过程
-
部署流程图
-
注意事项
sso认证中心与sso客户端通信方式有多种,这里以简单好用的httpClient为例,web service、rpc、restful api都可以
实现
主要功能
-
sso-client
-
拦截子系统未登录用户请求,跳转至sso认证中心
-
接收并存储sso认证中心发送的令牌
-
与sso-server通信,校验令牌的有效性
-
建立局部会话
-
拦截用户注销请求,向sso认证中心发送注销请求
-
接收sso认证中心发出的注销请求,销毁局部会话
-
-
sso-server
-
验证用户的登录信息
-
创建全局会话
-
创建授权令牌
-
与sso-client通信发送令牌
-
校验sso-client令牌有效性
-
系统注册
-
接收sso-client注销请求,注销所有会话
-
重要步骤
sso-client拦截未登录请求
-
拦截请求方式
servlet、filter、listener三种方式
-
实现思路
在sso-client中创建LoginFilter.java类并实现Filter接口,在doFilter()方法中加入对未登录用户的拦截
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse res = (HttpServletResponse) response; HttpSession session = req.getSession(); if (session.getAttribute("isLogin")) { chain.doFilter(request, response); return; } //跳转至sso认证中心 res.sendRedirect("sso-server-url-with-system-url"); }
sso-server拦截未登录请求
-
实现思路
拦截从sso-client跳转至sso认证中心的未登录请求,跳转至登录页面,这个过程与sso-client完全一样
sso-server验证用户登录信息
-
实现思路
用户在登录页面输入用户名密码,请求登录,sso认证中心校验用户信息,校验成功,将会话状态标记为"已登录"
@RequestMapping("/login") public String login(String username, String password, HttpServletRequest req) { this.checkLoginInfo(username, password); req.getSession().setAttribute("isLogin", true); return "success"; }
sso-server创建授权令牌
-
实现思路
授权令牌是一串随机字符,只要不重复,不易伪造即可,建议使用JWT工具类
package util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.Date; /** * Created by Administrator on 2018/4/11. */ @ConfigurationProperties("jwt.config") public class JwtUtil { private String key ; private long ttl ;//一个小时 public String getKey() { return key; } public void setKey(String key) { this.key = key; } public long getTtl() { return ttl; } public void setTtl(long ttl) { this.ttl = ttl; } /** * 生成JWT * * @param id * @param subject * @return */ public String createJWT(String id, String subject, String roles) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); JwtBuilder builder = Jwts.builder().setId(id) .setSubject(subject) .setIssuedAt(now) .signWith(SignatureAlgorithm.HS256, key).claim("roles", roles); if (ttl > 0) { builder.setExpiration( new Date( nowMillis + ttl)); } return builder.compact(); } /** * 解析JWT * @param jwtStr * @return */ public Claims parseJWT(String jwtStr){ return Jwts.parser() .setSigningKey(key) .parseClaimsJws(jwtStr) .getBody(); } }
sso-client取得令牌并校验
-
实现思路
sso认证中心登录后,跳转回子系统并附上令牌,子系统(sso-client)取得令牌,然后去sso认证中心校验,在LoginFilter.java的doFilter()中添加几行
// 请求附带token参数 String token = req.getParameter("token"); if (token != null) { // 去sso认证中心校验token boolean verifyResult = this.verify("sso-server-verify-url", token); if (!verifyResult) { res.sendRedirect("sso-server-url"); return; } chain.doFilter(request, response); }
verify()方法使用httpClient实现,这里仅简略介绍,httpClient详细使用方法请参考官方文档
sso-server接收并处理校验令牌请求
-
实现思路
用户在sso认证中心登录成功后,sso-server创建授权令牌并存储该令牌,所以,sso-server对令牌的校验就是去查找这个令牌是否存在以及是否过期,令牌校验成功后sso-server将发送校验请求的系统注册到sso认证中心(就是存储起来的意思)
-
存储方式
令牌与注册系统地址通常存储在key-value数据库(如redis)中,redis可以为key设置有效时间也就是令牌的有效期。
-
存储原因
当用户向sso认证中心提交注销请求,sso认证中心注销全局会话,若未存储注册系统地址,将无法确定哪些系统用此全局会话建立了自己的局部会话,也不知道要向哪些子系统发送注销请求注销局部会话
sso-client校验令牌成功创建局部会话
-
实现思路
令牌校验成功后,sso-client将当前局部会话标记为“已登录”,修改LoginFilter.java
if (verifyResult) { session.setAttribute("isLogin", true); }
-
注意事项
sso-client还需将当前会话id与令牌绑定,表示这个会话的登录状态与令牌相关,此关系可以用java的hashmap保存,保存的数据用来处理sso认证中心发来的注销请求
注销过程
-
实现思路
- 用户向子系统发送带有"logout"参数的请求(注销请求),sso-client拦截器拦截该请求,向sso认证中心发起注销请求
String logout = req.getParameter("logout"); if (logout != null) { this.ssoServer.logout(token); }
- sso认证中心也用同样的方式识别出sso-client的请求是注销请求(带有“logout”参数),sso认证中心注销全局会话
@RequestMapping("/logout") public String logout(HttpServletRequest req) { HttpSession session = req.getSession(); if (session != null) { session.invalidate();//触发LogoutListener } return "redirect:/"; }
- sso认证中心有一个全局会话的监听器,一旦全局会话注销,将通知所有注册系统注销
public class LogoutListener implements HttpSessionListener { @Override public void sessionCreated(HttpSessionEvent event) {} @Override public void sessionDestroyed(HttpSessionEvent event) { //通过httpClient向所有注册系统发送注销请求 } }
用户注册
业务流程
在用户微服务编写API,生成手机验证码,存入Redis并发送到RocketMQ
用户登录
常见认证机制
HTTP Basic Auth
-
概念
说明就是每次请求API时都提供用户的username和password。但由于有把用户名密码暴露给第三方客户端的风险,在生产环境下被使用的越来越少
Cookie Auth
-
概念
Cookie认证机制就是为一次请求认证在服务端创建一个Session对象,同时在客户端的浏览器端创建了一个Cookie对象;通过客户端带上来Cookie对象来与服务器端的session对象匹配来实现状态管理
-
登录流程
适用场景
- 多服务器部署单体web项目
-
方案一
使用nginx对ip进行hash取模,使得每次该用户登录系统时,都访问到同一台服务器
存在问题
存在单点故障问题,若该服务器系统宕机,将会丢失该服务器中存储的用户信息,可以考虑搭建数据库集群,数据库和应用分别部署在不同的服务器
-
方案二
使用tomcat服务器进行session同步
存在问题
只能用于同一集群下的项目,不能在不同服务进行session同步,例如淘宝登录后,天猫还得进行登录;而且session复制会导致单个tomcat中存储压力过大
-
方案三
借助redis等第三方存储引擎存储session
存在问题
以Map<String,Object>形式存储登录信息(不同服务器(域名)之间SessionID不一致),但需要保证key唯一,可以采用UUID类似工具类生成,但是跨服务后,另外的服务也无法直接获取key,需要在登录的服务将key传给客户端,由客户端保存在本地缓存(cookie)中,再访问其他域服务时,如果没有设置跨域访问,将不会接收cookie信息
OAuth
-
概念
OAuth(开放授权)是一个开放的授权标准,允许用户让第三方应用访问该用户在某一web服务上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。
-
登录流程
-
适用场景
基于OAuth的认证机制适用于个人消费者类的互联网产品,如社交类APP等应用,但是不太适合拥有自有认证权限管理的企业应用。
Token Auth
-
概念
使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。
-
验证流程
- 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名与密码
- 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
- 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里
- 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
- 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据
- Token Auth相对于Cookie机制的优点
- 支持跨域访问: Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过HTTP头传输.
- 无状态(也称:服务端可扩展行):Token机制在服务端不需要存储session信息,因为Token 自身包含了所有登录用户的信息,只需要在客户端的cookie或本地介质存储状态信息.
- 更适用CDN: 可以通过内容分发网络请求你服务端的所有资料(如:javascript,HTML,图片等),而你的服务端只要提供API即可.
- 去耦: 不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在你的API被调用的时候,你可以进行Token生成调用即可.
- 更适用于移动应用: 当你的客户端是一个原生平台(iOS, Android,Windows 8等)时,Cookie是不被支持的(你需要通过Cookie容器进行处理),这时采用Token认证机制就会简单得多。
- CSRF:因为不再依赖于Cookie,所以你就不需要考虑对CSRF(跨站请求伪造)的防范。
- 性能: 一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算 的Token验证和解析要费时得多.
- 不需要为登录页面做特殊处理: 如果你使用Protractor 做功能测试的时候,不再需要为登录页面做特殊处理.
- 基于标准化:你的API可以采用标准化的 JSON Web Token (JWT). 这个标准已经存在多个后端库(.NET, Ruby, Java,Python, PHP)和多家公司的支持(如:Firebase,Google, Microsoft).
BCrypt密码加密
-
Spring Security提供方法
Spring Security提供了BCryptPasswordEncoder类,实现Spring的PasswordEncoder接口使用BCrypt强哈希方法来加密密码,BCrypt强哈希方法 每次加密的结果都不一样。
-
实操步骤
-
引入坐标依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring‐boot‐starter‐security</artifactId> </dependency>
-
添加配置类
添加了spring security依赖后,所有的地址都被spring security所控制了,我们目前只是需要用到BCrypt密码加密的部分,所以我们要添加一个配置类,配置为所有地址都可以匿名访问
基于JWT的Token认证机制实现
JWT概念
JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。
JWT组成
一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。
-
头部
头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等
在头部指明了签名算法是HS256算法。 我们进行BASE64编码 -
载荷
(1)标准中注册的声明(建议但不强制使用)
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。(2)公共的声明(公共的claim,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息)
(3)私有的声明(自定义的claim)
claim跟JWT标准规定的claim区别在于:JWT规定的claim,JWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证(还不知道是否能够验证);而private claims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。 -
签证(signature)
header (base64后的)
payload (base64后的)
secret
需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分将这三部分用"."连接成一个完整的字符串,构成了最终的jwt
-
注意
secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
token的创建
-
创建类用于生成token(自定义claims保存角色)
public class CreateJwtTest { public static void main(String[] args) { //为了方便测试,我们将过期时间设置为1分钟 long now = System.currentTimeMillis();//当前时间 long exp = now + 1000*60;//过期时间为1分钟 JwtBuilder builder= Jwts.builder().setId("888") .setSubject("小白") .setIssuedAt(new Date()) .signWith(SignatureAlgorithm.HS256,"itcast") .setExpiration(new Date(exp)) .claim("roles","admin") .claim("logo","logo.png"); System.out.println( builder.compact() ); } }
-
常用API介绍
setIssuedAt 用于设置签发时间
signWith 用于设置签名秘钥
setExpiration 方法用于设置过期时间
token的解析
-
创建类用于解析token
public class ParseJwtTest { public static void main(String[] args) { String compactJws="eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiLlsI_nmb0iLCJpYXQiOjE1MjM0MTM0NTh9. gq0J‐cOM_qCNqU_s‐d_IrRytaNenesPmqAIhQpYXHZk"; Claims claims =Jwts.parser().setSigningKey("itcast").parseClaimsJws(compactJws).getBody(); System.out.println("id:"+claims.getId()); System.out.println("subject:"+claims.getSubject()); System.out.println("IssuedAt:"+claims.getIssuedAt()); System.out.println("roles:"+claims.get("roles")); System.out.println("logo:"+claims.get("logo")); SimpleDateFormat sdf=new SimpleDateFormat("yyyy‐MM‐dd hh:mm:ss"); System.out.println("签发时间:"+sdf.format(claims.getIssuedAt())); System.out.println("过期时间:"+sdf.format(claims.getExpiration())); System.out.println("当前时间:"+sdf.format(new Date()) ); } }
-
测试运行
当未过期时可以正常读取,当过期时会引发io.jsonwebtoken.ExpiredJwtException异常。
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTU2NjYyODcxNiwiZXhwIjoxNTY2NjMyMzE2LCJyb2xlcyI6ImFkbWluIn0.59_d07aSZQZU1DTZuvoVKhzaoNPMfaHiSNO8jnMkGZs JWT expired at 2019-08-23T17:50:48+0800. Current time: 2019-08-24T14:52:19+0800
JWT工具类
-
配置私有属性取值
@ConfigurationProperties()
-
Spring提供的拦截器
Spring为我们提供了org.springframework.web.servlet.handler.HandlerInterceptorAdapter这个适配器,继承此类,可以非常方便的实现自己的拦截器。他有三个方法: 分别实现预处理、后处理(调用了Service并返回ModelAndView,但未进行页面渲染)、返回处理(已经渲染了页面) 1. 在preHandle中,可以进行编码、安全控制等处理; 2. 在postHandle中,有机会修改ModelAndView; 3. 在afterCompletion中,可以根据ex是否为null判断是否发生了异常,进行日志记录。
-
拦截器配置类继承类
org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport
其他
-
参考链接
https://blog.csdn.net/xiaoguan_liu/article/details/91492110