SpringBoot模拟单点登录

SSO: Single Sign On,官方的概念:web系统由单系统发展成多系统组成的应用群,复杂性应该由系统内部承担,而不是用户。无论web系统内部多么复杂,对用户而言,都是一个统一的整体,也就是说,用户访问web系统的整个应用群与访问单个系统一样,登录/注销只要一次就够了。

简言之,系统内部通过某种技术实现用户统一登录和注销,所以单点登录技术一定要包括两部分:登录、注销。

 

1为什么要用单点登录?因为Cookie不能跨域。

Cookie为什么不能跨域请参考《Cookie详解与跨域问题》。

2如何实现单点登录?

建立权限认证中心来处理登录和注销的问题,真正提供服务的应用服务端通过Filter将鉴权任务重定向给认证中心。

原理图:

登录:

SpringBoot模拟单点登录

 

注销:

SpringBoot模拟单点登录

 

首先看得出分为2个角色,一个是应用服务端,也就是认证客户端;另一个是认证服务端。根据上图来分析2个角色应该具备的功能。

 

认证客户端应该具备的能力:

1必须以Filter或者插件等形式提供,方便系统接入SSO。

2未登陆的用户重定向到SSO认证中心

3接收SSO发来的令牌并将该令牌发回给SSO做令牌认证

4处理令牌认证结果并创建局部会话

5拦截用户注销请求并重定向到SSO

6处理SSO发来的注销会话请求

 

认证服务端应该具备的能力:

0独立的web服务

1提供登陆页面,和对用户的校验

2创建全局会话、提供token

3校验token有效性并维护client端地址

4处理注销请求、销毁全局会话、并通知维护的client端地址

5判断用户有没有登录

 

代码模拟:

总共4个项目,核心的是2个SSO-server和SSO-client,其中client不是独立的web应用而是代码插件。SSO-mock-app1和SSO-mock-app2是安装了插件的2个独立应用。

Sso-server:https://github.com/yejingtao/forblog/tree/master/sso-server

Sso-client:https://github.com/yejingtao/forblog/tree/master/sso-client

Sso-mock-app1:https://github.com/yejingtao/forblog/tree/master/sso-mock-app1

Sso-moke-app2:https://github.com/yejingtao/forblog/tree/master/sso-mock-app2

 

Sso-client包括3部分:filter负责判断用户是否登陆和重定向到sso-server;controller负责与sso-server传递令牌等通信;service维护局部会话状态。

SpringBoot模拟单点登录

 

Sso-server包括3部分:controller负责与sso-client传递令盘、校验用户登陆情况等通信;service维护全局用户会话状态;templates为用户登陆提供页面入口

SpringBoot模拟单点登录

 

下面我们按照图中的顺序来对照代码看一下:

1 客户端filter判断如果用户没有登录,重定向到sso-server端:

登陆过放行

 

 
  1. if(userName!=null

  2. && String.valueOf(userName).trim().length()>0

  3. && userAccessService.checkUserStatus(userName.toString())) {

  4. chain.doFilter(req, response);

  5. }

 

没登陆过重定向:

 

 
  1. String originalUrl = req.getRequestURL().toString();

  2. httpResponse.sendRedirect(ssoServerPath+"/index?originalUrl="+originalUrl+"&ssoUser="+userName);

 

 

 

 

 

 

 

2 sso-server端收到请求也需要判断用户是否登陆,用户不存在就给到自己的登陆页面,同时返回请求过来的连接在成功登陆后做3秒自动跳转。

 

[java] view plain copy

  1. <code class="language-java">@RequestMapping("/index")  
  2.     public String firstCheck(HttpServletRequest request) {  
  3.         String originalUrl = request.getParameter("originalUrl");  
  4.         String ssoUser = request.getParameter("ssoUser");  
  5.         String token = null;  
  6.         boolean loginFlag = false;  
  7.         if(ssoUser!=null && ssoUser.trim().length()>0) {  
  8.             //对用户先判断是否已经登陆过  
  9.             token = authSessionService.getUserToken(ssoUser);  
  10.             if(token!=null) {  
  11.                 loginFlag = true;  
  12.             }  
  13.         }  
  14.         if(loginFlag) {  
  15.             //判断如果用户已经在SSO-Server认证过,直接发送token  
  16.             if(tokenTrans(request,originalUrl,ssoUser,token)) {  
  17.                 if(originalUrl!=null) {  
  18.                     if(originalUrl.contains("?")) {  
  19.                         originalUrl = originalUrl + "&ssoUser="+ssoUser;  
  20.                     }else {  
  21.                         originalUrl = originalUrl + "?ssoUser="+ssoUser;  
  22.                     }  
  23.                 }  
  24.             }  
  25.             return "redirect:"+originalUrl;  
  26.         }else {  
  27.             //需要替换成专业点的路径,自己登陆下了  
  28.             return "redirect:/loginPage?originalUrl="+request.getParameter("originalUrl");  
  29.         }  
  30.     }</code>  

 

 

3 用户登陆页面登录后,验证用户名、创建token

 
  1. //登陆逻辑,返回的是令牌

  2. @RequestMapping(value="/doLogin",method=RequestMethod.POST)

  3. public String login(HttpServletRequest request, HttpServletResponse response,

  4. String userName, String password, String originalUrl) {

  5. if(authSessionService.verify(userName,password)) {

  6. String token = authSessionService.cacheSession(userName);

  7. if(tokenTrans(request,originalUrl,userName,token)) {

  8. //跳转到提示成功的页面

  9. request.setAttribute("helloName", userName);

  10. if(originalUrl!=null) {

  11. if(originalUrl.contains("?")) {

  12. originalUrl = originalUrl + "&ssoUser="+userName;

  13. }else {

  14. originalUrl = originalUrl + "?ssoUser="+userName;

  15. }

  16. request.setAttribute("originalUrl", originalUrl);

  17. }

  18. }

  19. return "hello";//TO-DO 三秒跳转

  20. }

  21. //验证不通过,重新来吧

  22. if(originalUrl!=null) {

  23. request.setAttribute("originalUrl", originalUrl);

  24. }

  25. return "loginIndex";

  26. }

重点是token如何传给sso-client端并从clinet端注册上来地址的:

4 server端将token传给client端:

 
  1. private boolean tokenTrans(HttpServletRequest request, String originalUrl,String userName, String token) {

  2. String[] paths = originalUrl.split("/");

  3. String shortAppServerUrl = paths[2];

  4. String returnUrl = "http://"+shortAppServerUrl+"/receiveToken?ssoToken="+token+"&userName="+userName;

  5. //http://peer1:8088/receiveToken?ssoToken=80414bcb-a71d-48c8-bfee-098a303324d4&userName=xixi

  6. return "success".equals(restTemplate.getForObject(returnUrl, String.class));

  7.  
  8. }

5 client端将收到的token和自己的服务地址传递给server端:

 
  1. @RequestMapping("/receiveToken")

  2. @ResponseBody

  3. public String receiveToken(HttpServletRequest request, String ssoToken,String userName) {

  4. if(ssoToken!=null && ssoToken.toString().trim().length()>0) {

  5. String realUrl = request.getRequestURL().toString();

  6. String[] paths = realUrl.split("/");

  7. String realUrlUrls = paths[2];

  8. String returnUrl = ssoServerPath+"/varifyToken?address="+realUrlUrls+"&token="+ssoToken;

  9. //http://peer2:8089/varifyToken?address=peer1:8088&token=c2ce29be-5adb-4aaf-82cc-2ba24330176e

  10. String resultStr = restTemplate.getForObject(returnUrl, String.class);

  11. if("true".equals(resultStr)) {

  12. //创建局部会话,保存用户状态为已登陆

  13. userAccessService.putUserStatus(userName, resultStr);

  14. return "success";

  15. }

  16. }

  17. return "error";

  18. }

6 server端检查token是否是自己发放的令牌并维护客户端的地址

 
  1. //校验token并注册地址

  2. @RequestMapping(value="/varifyToken",method=RequestMethod.GET)

  3. @ResponseBody

  4. public String varifyToken(String token, String address) {

  5. return String.valueOf(authSessionService.checkAndAddAddress(token, address));

  6. }

 

7 令牌交互完毕后,提示用户登陆成功并自动跳转回用户的app服务页面

 
  1. onload = function(){

  2. setInterval(go,1000);

  3. };

  4. var x = 3;

  5. function go(){

  6. x--;

  7. if(x>0){

  8. document.getElementById('sp').innerHTML = x;

  9. }else{

  10. var returnUrl = [[${originalUrl}]];

  11. location.href = returnUrl;

  12. }

  13. }

 

8 sso-client要有根据用户名发起注销的能力

 
  1. @RequestMapping("/ssoLogout")

  2. @ResponseBody

  3. public String ssoLogout(String userName) {

  4. String userToken = userAccessService.getUserToken(userName);

  5. if(userToken!=null) {

  6. String returnUrl = ssoServerPath+"/logoutByToken?ssoToken="+userToken;

  7. return restTemplate.getForObject(returnUrl, String.class);

  8. }

  9. return "None Token";

  10. }

 

 

9 注销在sso-server端提供2种,可以用用户注销,也可以用token注销,代码中都有提供这里主要讲根据token注销,我们假定注销请求都是从客户端发起的

 
  1. @RequestMapping(value="/logoutByToken",method=RequestMethod.GET)

  2. @ResponseBody

  3. public String logoutByToken(String ssoToken) {

  4. List<String> addressList = authSessionService.logoutByToken(ssoToken);

  5. if(addressList!=null) {

  6. addressList.stream().forEach(s -> sendLogout2Client(s,ssoToken));

  7. }

  8. return "logout";

  9. }

  10.  
  11. private void sendLogout2Client(String address,String ssoToken) {

  12. String returnUrl = "http://"+address+"/ssoDeleteToken?ssoToken="+ssoToken;

  13. try {

  14. restTemplate.getForObject(returnUrl, String.class);

  15. }catch(Exception e) {

  16. //Log and do nothing

  17. }

  18. }


10  对应的客户端要有个删除token的接收动作

 
  1. @RequestMapping("/ssoDeleteToken")

  2. @ResponseBody

  3. public String ssoDeleteToken(String ssoToken) {

  4. userAccessService.deleteToken(ssoToken);

  5. return "success";

  6. }

 

11 细节需要注意下:

a 客户端filter过滤范围要控制好,不要将sso-server发来的请求进行过滤,否则会死循环

b client端和server端的Redis要维护好user和token

 

启动测试:

 Sso-mock-app1,植入sso-client插件,域名使用peer1,服务地址peer1:9001

 Sso-mock-app2,植入sso-client插件,域名使用peer2,服务地址peer2:9002

 Sso-Server,独立部署,域名使用peer3,服务地址peer3:9003

 

 都启动之后,开始测试:

 步骤一:无论我浏览器访问app1被重定向到http://peer3:9003/loginPage?originalUrl=http://peer1:9001/hello地址进行登录

步骤二:在peer3,也就是SSO-Server的登陆页面上用yejingtao账户登陆,登陆成功后3秒钟后页面自动跳转到peer1的步骤一中的请求页面。

步骤三:在app2服务尝试yejingtao账号直接访问,成功。

步骤四:在SSO-Server端注销该账号

步骤五:注销后app1和app2又要重新登录了。

整个过程中可以配合redisclient观察redis数据帮助自己理解。

 

由于只是验证和示例代码,好多可以优化的地方,例如

1 token已经预留了失效时间,可以在逻辑中增加对token时效的判断,并定时清理

2 sso-server与sso-client的通信可以改成post请求,虽然不会经过浏览器,从使用效果和安全级别上没有差别,但是从http协议设计来考虑还是post更好

3 Redis序列化部分可以换成Jackson2JsonRedisSerializer节省内存空间

4 sso-client拦截的权限范围可以放在application.yml中让用户自己设置

 

如果自己产品中用SSO的话当然不用自己写,成型有CAS等,原理当然差别不大。

还有一种解决系统间统一认证的思路,OAuth2.0,虽然也是解决多系统登录的,但是跟这里的单点登录思路完全不一样,等后面有机会再博文介绍。