单点登录是目前比较流行的企业业务整合的解决方案之一。单点登录是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。例如:百度旗下有很多的产品,比如百度贴吧、百度知道、百度文库等,只要登录百度账号,在任何一个地方都是已登录状态,不需要重新登录。
单点登录是互联网应用和企业级平台中的基础组件服务。接下来就介绍单点登录的原理,并基于SpringBoot +JWT实现单点登录解决方案。
一、什么是单点登录?
单点登录(Single Sign On 简称 SSO)是一种统一认证和授权机制,指在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统,不再需要重新登录验证。
单点登录一般是用于互相授信的系统,实现单一位置登录,其他信任的应用直接免登录的方式。在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。
单点登录是互联网应用和企业级平台中的基础组件服务。比如百度贴吧、百度知道、百度文库等,只要登录百度账号,在任何一个地方都是已登录状态,不需要重新登录。此外,第三方授权登录,如在京东中使用微信登录。解决信息孤岛和用户不对等的实现方案。
随着时代的演进,大型web系统早已从单体应用架构发展为如今的多系统分布式应用群。但无论系统内部多么复杂,对用户而言,都是一个统一的整体,访问web系统的整个应用群要和访问单个系统一样,登录/注销只要一次就够了,不可能让一个用户在每个业务系统上都进行一次登录验证操作,这时就需要独立出一个单独的认证系统,它就是单点登录系统。
二、单点登录的优点和不足
单点登录解决了用户只需要登录一次就可以访问所有相互信任的应用系统,而不用重复登录。除此之外,还有以下优点:
1)提高用户的效率。用户不再被多次登录困扰,也不需要记住多个 ID 和密码。另外,用户忘记密码并求助于支持人员的情况也会减少。
2)提高开发人员的效率。SSO 为开发人员提供了一个通用的身份验证框架。实际上,如果 SSO 机制是独立的,那么开发人员就完全不需要为身份验证操心。他们可以假设,只要对应用程序的请求附带一个用户名,身份验证就已经完成了。
3)简化管理。如果应用程序加入了单点登录协议,管理用户帐号的负担就会减轻。简化的程度取决于应用程序,因为 SSO 只处理身份验证。所以,应用程序可能仍然需要设置用户的属性(比如访问特权)。
三、单点登录实现机制
单点登录听起来很复杂,实际上架构非常简单,具体如下图所示:
如上图所示,当用户第一次访问应用系统A时,因为还没有登录,会被跳转到认证系统进行登录;认证系统根据用户提供的登录信息进行身份效验,如果通过效验,则返回给用户一个认证凭据(ticket);用户访问其他应用系统时,将会带上此ticket作为自己认证的凭据,应用系统B接受到请求之后会把ticket送到认证系统进行效验,检查ticket的合法性。如果通过,则成功登录应用系统B。
四、单点登录常见的解决方案
实现单点登录的方式有很多种,常见的有基于Cookie、Session共享、Token机制、JWT机制等方式。
4.1 基于Cookie
基于Cookie是最简单的单点登录实现方式是使用cookie作为媒介存放用户凭证。用户登录成功后,返回一个加密的cookie,当用户访问其他应用时,携带此cookie,授权应用解密cookie并进行校验,校验通过则登录当前用户。
当然,我们不难发现以上方式将信任数据存储在客户端的Cookie中,这种方式存在两个问题:
1、Cookie不安全,CSRF(跨站请求伪造);
2、Cookie不能跨域,无法解决跨域系统的问题。
对于第一个问题,通过加密Cookie可以保证安全性,当然这是在源代码不泄露的前提下。如果Cookie的加密算法泄露,攻击者通过伪造Cookie则可以伪造特定用户身份,这是很危险的。
对于第二个问题,不能跨域是Cookie的硬伤。可以将生成ticket作为参数传递到其他应用系统。这样可以避免跨域问题。
4.2 基于Session共享
所谓基于Session共享,主要是将Session会话信息保存到公共的平台,如Redis,数据库等,各应用系统共用一个会话状态sessionId,实现登录信息的共享,从而实现单点登录。
基于Session解决了Cookie不能跨域的问题,但也存在其他问题。早期的单体应用使用Session实现单点登录,但现在大部分情况下都需要集群,由于存在多台服务器,Session在多台服务器之间是不共享的,因此,还需解决Session共享的问题
解决系统之间的 Session 不共享问题有以下几种方案:
1)Tomcat集群Session全局复制【会影响集群的性能呢,不建议】
2)分布式Session,即把Session数据放在Redis中(使用Redis模拟Session)【建议】
4.3 Token机制
其实,Token就是一串加密(使用MD5,等不可逆加密算法)的字符串。具体流程如下:
1.客户端使用用户名跟密码请求登录,
2.服务端收到请求,去验证用户名与密码,
3.验证成功后,服务端会签发一个加密的字符串(Token)保存到(Session,Redis,Mysql)中,并把这个Token发送给客户端,
4.客户端收到Token后存储在本地,如:Cookie 或 Local Storage 中,
5.客户端每次向服务端请求资源的时候需要带着服务端签发的 Token,
6.服务端收到请求,验证密客户端请求里面带着的 Token和服务器中保存的Token进行对比效验, 如果验证成功,就向客户端返回请求的数据。
使用Token验证的优势:
- 无状态、可扩展;
- 在客户端存储的Token是无状态的,并且可扩展。基于这种无状态和不存储Session信息,负载负载均衡器能够将用户信息从一个服务传到其他服务器上;
- 安全性,请求资源时发送token而不再是发送cookie能够防止CSRF(跨站请求伪造)
即使在客户端使用cookie存储token,cookie也仅仅是一个存储机制而不是用于认证。不将信息存储在Session中,让我们少了对session操作。
4.4 JWT 机制
JWT(JSON Web Token的缩写)它将用户信息加密到token里,服务器不保存任何用户信息。服务器通过使用保存的密钥验证JWTToken的正确性,只要正确即通过验证。
4.4.1 JWT的特点
- 紧凑:数据小,可以通过URL,POST参数,请求头发送。且数据小代表传输速度快。
- 自包含:使用payload数据块记录用户必要且不隐私的数据,可以有效的减少数据库访问次数,提高代码性能。
- JWT一般用于处理用户身份验证或数据信息交换。
- 用户身份验证:一旦用户登录,每个后续请求都将包含JWT,允许用户访问该令牌允许的路由,服务和资源。单点登录是当今广泛使用JWT的一项功能,因为它的开销很小,并且能够轻松地跨不同域使用。
- 数据信息交换:JWT是一种非常方便的多方传递数据的载体,因为其可以使用数据前面来保证数据的有效性和安全性。
4.4.2 JWT数据结构
JWT的结构包含三个部分: Header头部,Payload负载和Signature签名。三部分之间用“.”号做分割。 校验也是JWT内部自己实现的 ,并且可以将你存储时候的信息从token中取出来无须查库。
数据结构: {“alg”: “加密算法名称”, “typ” : “JWT”}
alg是加密算法定义内容,如:HMAC SHA256 或 RSA
typ是token类型,这里固定为JWT。
在payload数据块中一般用于记录实体(通常为用户信息)或其他数据的。主要分为三个部分,分别是:已注册信息(registered claims),公开数据(public claims),私有数据(private claims)。
payload中常用信息有:iss(发行者),exp(到期时间),sub(主题),aud(受众)等。前面列举的都是已注册信息。
公开数据部分一般都会在JWT注册表中增加定义。避免和已注册信息冲突。
公开数据和私有数据可以由程序员任意定义。
注意:即使JWT有签名加密机制,但是payload内容都是明文记录,除非记录的是加密数据,否则不排除泄露隐私数据的可能。不推荐在payload中记录任何敏感数据。
签名信息。这是一个由开发者提供的信息。是服务器验证的传递的数据是否有效安全的标准。在生成JWT最终数据的之前。先使用header中定义的加密算法,将header和payload进行加密,并使用点进行连接。如:加密后的head.加密后的payload。再使用相同的加密算法,对加密后的数据和签名信息进行加密。得到最终结果。
4.4.3 JWT执行流程
JWT的请求流程也特别简单,首先使用账号登录获取Token,然后后面的各种请求,都带上这个Token即可。具体流程如下:
1. 客户端发起登录请求,传入账号密码;
2. 服务端使用私钥创建一个Token;
3. 服务器返回Token给客户端;
4. 客户端向服务端发送请求,在请求头中该Token;
5. 服务器验证该Token;
6. 返回结果。
可能有些小伙伴会觉得,Token 和JWT有什么区别呢?其实Token和JWT确实比较类似,只不过,Token需要查库验证token 是否有效,而JWT不用查库,直接在服务端进行校验,因为用户的信息及加密信息,和过期时间,都在JWT里,只要在服务端进行校验就行,并且校验也是JWT自己实现的。
五、基于JWT机制的单点登录
JWT提供了基于Java组件:java-jwt帮助我们在Spring Boot项目中快速集成JWT,接下来进行SpringBoot和JWT的集成。接下来我们通过项目示例,演示如何基于SpringBoot+JWT实现单点登录。
5.1 项目结构
项目结构如下图所示:
如上图所示,weiz-sso为认证系统,weiz-app-a和weiz-app-b为两个独立的应用系统。
5.2 创建认证系统
5.2.1.创建项目并引入JWT等依赖
首先,创建普通的Spring Boot项目weiz-sso,修改项目中的pom.xml文件,引入JWT等依赖。示例代码如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> </exclusion> </exclusions> </dependency> <!-- SpringBoot集成thymeleaf模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.10.3</version> </dependency>
5.2.2.创建&验证JWT工具类
创建通用的处理类TokenUtil,负责创建和验证Token。示例代码如下:
@/** * 类功能简述: * 类功能详述: * * @author weiz */ public class JwtUtil { /** * Description: 生成一个jwt字符串 * * @param name 用户名 * @param secret 秘钥 * @param timeOut 超时时间(单位s) * @return java.lang.String * @author weiz */ public static String encode(String name, String secret, long timeOut) { Algorithm algorithm = Algorithm.HMAC256(secret); String token = JWT.create() //设置过期时间为一个小时 .withExpiresAt(new Date(System.currentTimeMillis() + timeOut)) //设置负载 .withClaim("name", name) .sign(algorithm); return token; } /** * Description: 解密jwt * * @param token token * @param secret secret * @return java.util.Map<java.lang.String , com.auth0.jwt.interfaces.Claim> * @author weiz */ public static Map<String, Claim> decode(String token, String secret) { if (token == null || token.length() == 0) { throw new CustomException("token为空:" + token); } Algorithm algorithm = Algorithm.HMAC256(secret); JWTVerifier jwtVerifier = JWT.require(algorithm).build(); DecodedJWT decodedJWT = jwtVerifier.verify(token); return decodedJWT.getClaims(); } }
5.2.3 登录功能
接下来创建AuthController控制器,实现登录,退出等请求接口,示例代码如下:
/** * 类功能简述: * 类功能详述: * * @author weiz */ @Controller @RequestMapping("/sso") public class AuthController { private JwtService service; @Autowired public AuthController(JwtService service) { this.service = service; } @RequestMapping({"/","/index"}) public String index(){ return "index"; } @RequestMapping("/login") public String login(){ return "login"; } @RequestMapping(value = "/login",method = RequestMethod.POST) @ResponseBody public ReturnResult login(@RequestBody User user) { String token = service.login(user); return ReturnResult.successResult(token); } @RequestMapping("/checkJwt") @ResponseBody public ReturnResult checkJwt(String token) { return ReturnResult.successResult(service.checkJwt(token)); } @RequestMapping("/refreshJwt") @ResponseBody public ReturnResult refreshJwt(String token){ return ReturnResult.successResult(service.refreshJwt(token)); } @RequestMapping("/inValid") @ResponseBody public ReturnResult inValid(String token) { service.inValid(token); return ReturnResult.successResult(null); } }
5.3 创建应用系统
5.3.1创建应用系统weiz-app-a和weiz-app-b
首先,分别创建两个Spring Boot项目weiz-app-a和weiz-app-b。并引入相关依赖,示例代码如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- SpringBoot集成thymeleaf模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>3.14.9</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency>
5.3.2 登录验证拦截器
在两个项目中分别添加登录拦截器LoginFilter,负责拦截所有Http请求,验证Token是否有效。示例代码如下:
/** * 类功能简述: * 类功能详述: * * @author weiz */ @Component @WebFilter(urlPatterns = "/**", filterName = "loginFilter") public class LoginFilter implements Filter { private Logger logger = LoggerFactory.getLogger(this.getClass()); @Value("${sso_server}") private String serverHost; @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void destroy() { } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; String token = httpServletRequest.getParameter("token"); if (this.check(token)) { filterChain.doFilter(servletRequest, servletResponse); } else { HttpServletResponse response = (HttpServletResponse) servletResponse; String redirect = serverHost + "/login?redirect=" + httpServletRequest.getRequestURL(); //response.setContentType("application/json;charset=utf-8"); //response.setCharacterEncoding("utf-8"); //response.getWriter().write(JSON.toJSONString(new ReturnEntity(-1, "未登录", null))); response.sendRedirect(redirect); } } private boolean check(String jwt) { try { if (jwt == null || jwt.trim().length() == 0) { return false; } JSONObject object = HttpClient.get(serverHost + "/checkJwt?token=" + jwt); return object.getBoolean("data"); } catch (Exception e) { logger.error("向认证中心请求失败", e); return false; } } }
5.3.3.创建控制器
在两个项目中分别创建IndexController控制器,处理HTTP请求。示例代码如下:
/** * 类功能简述: * 类功能详述: * * @author weiz */ @Controller public class IndexController { @Value("${sso_server}") private String serverHost; @RequestMapping({"/","/index"}) public String index() { return "index"; } @RequestMapping("/test") @ResponseBody public ReturnEntity test() { return new ReturnEntity(1, "通过验证", null); } @RequestMapping("/logout") public void logout(HttpServletRequest request, HttpServletResponse response,String token) throws Exception { HttpClient.get(serverHost + "/inValid?token=" + token); String requestHost = request.getScheme() +"://"+ request.getServerName() + ":"+request.getServerPort() +"/"; String redirect = serverHost + "/login?redirect=" + requestHost; System.out.println(redirect); response.sendRedirect(redirect); } }
5.4 测试验证
集成JWT成功之后,接下来验证单点登录系统是否成功,分别启动wei-sso、weiz-app-a和weiz-app-b。验证功能是否正常。
首先,在浏览器中访问应用系统A:http://localhost:8081/
如上图所示,由于没有登录,自动跳转到了单点登录系统进行登录验证。输入用户名、密码(admin\admin)登录成功并返回到应用系统A。
接下来,使用此token 访问应用系统B,在浏览器中输入:http://localhost:8082/?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiYWRtaW4iLCJleHAiOjE2NTc4MDA4MTB9.7NLXFJwGKxgnEwBsE25OredrpLIaanAoqeHXSZjO6QA
如上图所示,通过之前的token,无需登录即可成功进入了应用系统B。说明我们的单点登录系统搭建成功。
总结
以上,我们就把单点登录(SSO)的所有流程都介绍完了,原理大家都清楚了。单点登录是互联网应用和企业级平台中的基础组件服务,希望大家能明白其中的原理,熟练掌握。