单点登录
登录
-
实现原理
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-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-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
Cookie Auth
适用场景
-
方案一
使用nginx对ip进行hash取模,使得每次该用户登录系统时,都访问到同一台服务器
存在问题
存在单点故障问题,若该服务器系统宕机,将会丢失该服务器中存储的用户信息,可以考虑搭建数据库集群,数据库和应用分别部署在不同的服务器
-
方案二
使用tomcat服务器进行session同步
存在问题
只能用于同一集群下的项目,不能在不同服务进行session同步,例如淘宝登录后,天猫还得进行登录;而且session复制会导致单个tomcat中存储压力过大
-
方案三
借助redis等第三方存储引擎存储session
存在问题
以Map<String,Object>形式存储登录信息(不同服务器(域名)之间SessionID不一致),但需要保证key唯一,可以采用UUID类似工具类生成,但是跨服务后,另外的服务也无法直接获取key,需要在登录的服务将key传给客户端,由客户端保存在本地缓存(cookie)中,再访问其他域服务时,如果没有设置跨域访问,将不会接收cookie信息
OAuth
Token Auth
- 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名与密码
- 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
- 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里
- 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
- 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据
- 支持跨域访问: 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密码加密
-
引入坐标依赖
<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
其他