Cas 5.2.x版本使用 —— Restful API 方式实现SSO(十八)

假设有6个单独的子项目A、B、C、D、E、F,都有各自的客户端登录界面(6个),现在要实现SSO效果,所以加上了一个CAS-Server服务

我想实现的效果是:登陆界面还是在客户端(不是在Server端增加主题登录界面)实现【同域名、不同域名】之间的SSO

举例:

  1、当我访问子项目 A 的受保护资源时,跳转到 A 的登录界面。(其他子项目同理)

  2、子项目 A 登录界面输入用户名,密码实现 CAS登录成功。

  3、当我访问任意子项目(B、C、D、E、F)的受保护资源时,用于 A 已经登录过了,所以可以直接访问

  4、当我在任意子项目(B、C、D、E、F)登出时,全局实现登出效果。

一、系统架构

统一使用 SpringBoot+Meven,app1 和 app2 内容一致、client1 和 client2 内容一致

文件名 域名 功能
cas-app1 http://app1.com:8181/fire 客户端1(浏览器访问)[cas-client在此]
cas-app2 http://app2.com:8282/water 客户端2(浏览器访问)[cas-client在此]
cas-client1 http://client1.com:8888/ 后端接口1
cas-client2 http://client2.com:8889/ 后端接口2
sso-server http://sso.server.com:8889/sso 自定义SSO服务,管理单点登录的用户
cas-server-rest http://cas.server.com:8484/cas CAS-Server

本地hosts文件配置如下

127.0.0.1    cas.server.com
127.0.0.1    sso.server.com
127.0.0.1    app1.com
127.0.0.1    app2.com
127.0.0.1    client1.com
127.0.0.1    client2.com

架构图

其实我是将cas-server中的TGT管理,单独使用自定义的sso-server进行管理了。

这种方式,个人感觉,比之前写的iframe方式要好很多。与不同建议和方案,请在底部留言 谢谢!。

WX20180423-185833@2x.png

操作流程

1、用户访问受限资源 http://app1.com:8181/fire/books.html 

2、由于未登录,跳转自定义的登录界面 http://app1.com:8181/fire/login.html?service=http%3A%2F%2Fapp1.com%3A8181%2Ffire%2Fbooks.html

3、登录页面首先向 sso-server 发起 jsonp 的登录验证请求(提交sso-server域名下的cookie),根据 Cookie 判断是否登录过

    未登录返回:jQuery33109023589333109241_1524470965614({"status":0,"data":"nothing"})

    登录过返回:jQuery33109023589333109241_1524470965614({"status":1,"data":"http://app1.com:8181/fire/books.html?ticket=ST值"})

(1)未登录

4、未登录,渲染登录表单,用户进行username、password、service 登录,登录地址提交到 sso-serverlogin 接口

5、login 接口通过账号密码调用 cas-server 服务的 v1/tickets 接口,获取 TGT,然后根据用户名规则,将 TGT 保存到 CookieRedis 缓存中

6、login 接口通过 TGT 获取 ST,拼接成 http://app1.com:8181/fire/books.html?ticket=ST值,进行redirect 该字符串作为响应

7、http://app1.com:8181/fire/books.html?ticket=ST值  app 中的 cas-client 过滤器进行验证 ST 的正确性

8、验证通过,建立成功的SessionId

(2)已登录

4、已登录,接收 jsonp 响应的值 http://app1.com:8181/fire/books.html?ticket=ST值,并进行 JS 的重定向

5、http://app1.com:8181/fire/books.html?ticket=ST值  app 中的 cas-client 过滤器进行验证 ST 的正确性

6、验证通过,建立成功的SessionId

二、实战

1、cas-server 配置

pom.xml 配置

如果需要使用rest的请求方式,就需要添加下面的依赖。

<!--开启cas server的rest支持-->
<dependency>
    <groupId>org.apereo.cas</groupId>
    <artifactId>cas-server-support-rest</artifactId>
    <version>${cas.version}</version>
</dependency>

2、sso-server 配置

pom.xml配置

一些常规依赖,主要是redis

<!--HttpClient-->
<dependency>
   <groupId>org.apache.httpcomponents</groupId>
   <artifactId>httpclient</artifactId>
   <version>4.5.3</version>
</dependency>

<!--Gson-->
<dependency>
   <groupId>com.google.code.gson</groupId>
   <artifactId>gson</artifactId>
   <version>2.8.2</version>
</dependency>

<!--redis-->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

CasConfig.java

package com.tingfeng.config;

public class CasConfig {

    /**
     * CAS登录地址的token
     */
    public static String GET_TOKEN_URL = "https://cas.server.com:8443/cas/v1/tickets";

    /**
     * 设置Cookie的有效时长(1小时)
     */
    public static int COOKIE_VALID_TIME = 1 * 60 * 60;

    /*
     * 设置Cookie的有效时长(1小时)
     */
    public static String COOKIE_NAME = "UToken";


}

UserController.java

package com.tingfeng.controller;

import com.google.gson.Gson;
import com.tingfeng.config.CasConfig;
import com.tingfeng.server.TgtServer;
import com.tingfeng.utils.CasServerUtil;
import com.tingfeng.viewmodel.res.UserCheckResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Controller
@RequestMapping("/user")
public class UserController {

    @Autowired
    private TgtServer tgtServer;

    /**
     * CAS 登录授权
     */
    @PostMapping("/login")
    public Object login(HttpServletRequest request, HttpServletResponse response) throws Exception {

        String username = request.getParameter("username");
        String password = request.getParameter("password");
        String service = request.getParameter("service");

        System.out.println("username:" + username + ",password:" + password + ",service:" + service);

        // 1、获取 TGT
        String tgt = CasServerUtil.getTGT(username, password);
        System.out.println("TGT:" + tgt);

        // 2、获取 ST
        String st = CasServerUtil.getST(tgt, service);
        System.out.println("ST:" + st);

        if (tgt == null || st==null){
            return new ResponseEntity("用户名或密码错误。", HttpStatus.BAD_REQUEST);
        }

        // 3、设置cookie(1小时)
        Cookie cookie = new Cookie(CasConfig.COOKIE_NAME, username + "@" + tgt);
        cookie.setMaxAge(CasConfig.COOKIE_VALID_TIME);             // Cookie有效时间
        cookie.setPath("/");                       // Cookie有效路径
        cookie.setHttpOnly(true);                  // 只允许服务器获取cookie
        response.addCookie(cookie);

        // 4、将当前用户的TGT信息存储在Redis上
        tgtServer.setTGT(username, tgt, CasConfig.COOKIE_VALID_TIME);

        // 5、302重定向最后授权
        String redirectUrl = service + "?ticket=" + st;
        System.out.println("redirectUrl:" + redirectUrl);

        return "redirect:" + redirectUrl;
    }

    /**
     * 检查用户是否登录过
     */
    @RequestMapping("/check")
    @ResponseBody
    public String checkLoginUser(HttpServletRequest request) throws Exception {

        String service = request.getParameter("service");
        String callback = request.getParameter("callback");
        Cookie[] cookies = request.getCookies();
        String username = null;
        String tgt = null;

        UserCheckResponse result = new UserCheckResponse();

        if (cookies != null) {
            System.out.println(new Gson().toJson(cookies));

            for (Cookie cookie : request.getCookies()) {
                if (cookie.getName().equals(CasConfig.COOKIE_NAME)) {
                    username = cookie.getValue().split("@")[0];
                    tgt = cookie.getValue().split("@")[1];
                    break;
                }
            }

            if (username != null) {
                // 获取Redis值
                String value = tgtServer.getTGT(username);
                System.out.println("Redis value:" + value);

                // 匹配Redis中的TGT与Cookie中的TGT是否相等
                if (tgt.equals(value)) {

                    // 获取 ST
                    String st = CasServerUtil.getST(tgt, service);
                    System.out.println("ST:" + st);

                    result.setStatus(1);
                    result.setData(service + "?ticket=" + st);
                }
            }
        }

        System.out.println("callback:" + callback);
        String tmp = callback + "(" + new Gson().toJson(result) + ")";
        System.out.println("result:" + tmp);

        return tmp;
    }

    /**
     * 因为TGT在SSO服务端维护,并不在CAS-Server,所以只需要想办法把redis中匹配的tgt信息删除即可。
     */
    @GetMapping("/logout")
    @ResponseBody
    public String logout(HttpServletRequest request) {
        String callback = request.getParameter("callback");
        Cookie[] cookies = request.getCookies();
        String username = null;
        String tgt = null;

        if (cookies != null) {
            System.out.println(new Gson().toJson(cookies));

            for (Cookie cookie : request.getCookies()) {
                if (cookie.getName().equals(CasConfig.COOKIE_NAME)) {
                    username = cookie.getValue().split("@")[0];
                    tgt = cookie.getValue().split("@")[1];
                    break;
                }
            }

            if (username != null) {
                // 获取Redis值
                String value = tgtServer.getTGT(username);
                System.out.println("Redis value:" + value);

                // 匹配Redis中的TGT与Cookie中的TGT是否相等
                if (tgt.equals(value)) {
                    // 删除TGT
                    tgtServer.delTGT(username);
                }
            }
        }

        System.out.println("callback:" + callback);
        String tmp = callback + "({'code':'0','msg':'登出成功'})";
        System.out.println("result:" + tmp);

        return null;
    }

}

TgtServer.java

package com.tingfeng.server;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * 通过 Redis 存储/获取/删除 TGT 数据
 */
@Component
public class TgtServer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 设置用户TGT到Redis
     *
     * @param username
     * @param tgt
     * @param time
     * @return
     */
    public void setTGT(String username, String tgt, long time) {
        ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
        String value = operations.get(username);
        if (StringUtils.isNotBlank(value)) {
            System.out.println("用户:" + username + " 缓存中旧值:" + value + " 替换为新值:" + tgt);
        }
        operations.set(username, tgt, time, TimeUnit.SECONDS);
    }

    /**
     * 获取 TGT
     *
     * @param username
     * @return
     */
    public String getTGT(String username) {
        ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
        String value = operations.get(username);
        if (StringUtils.isNotBlank(value)) {
            return value;
        }
        return null;
    }

    /**
     * 删除 TGT
     *
     * @param username
     * @return
     */
    public void delTGT(String username) {
        stringRedisTemplate.delete(username);
    }

}

CasServerUtil.java

更详细,见github源码

package com.tingfeng.utils;

import com.tingfeng.config.CasConfig;
import org.apache.http.Header;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.CookieStore;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.socket.LayeredConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicNameValuePair;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;

/**
 * CAS - Server通信服务
 */
public class CasServerUtil {

    public static void main(String[] args) {
        try {
//            String tgt = getTGT("tingfeng", "tingfeng");
//            System.out.println("TGT:" + tgt);
//
//            String service = "http://app1.com:8181/fire/users.html";
//            String st = getST(tgt, service);
//            System.out.println("ST:" + st);
//
//            System.out.println(service + "?ticket=" + st);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取TGT
     */
    public static String getTGT(String username, String password) {
        try{
            CookieStore httpCookieStore = new BasicCookieStore();
            CloseableHttpClient client = HttpClients.createDefault();

            HttpPost httpPost = new HttpPost(CasConfig.GET_TOKEN_URL);
            List<NameValuePair> params = new ArrayList<NameValuePair>();
            params.add(new BasicNameValuePair("username", username));
            params.add(new BasicNameValuePair("password", password));
            httpPost.setEntity(new UrlEncodedFormEntity(params));
            HttpResponse response = client.execute(httpPost);

            Header headerLocation = response.getFirstHeader("Location");
            String location = headerLocation == null ? null : headerLocation.getValue();

            System.out.println("Location:" + location);

            if (location != null) {
                return location.substring(location.lastIndexOf("/") + 1);
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 获取ST
     */
    public static String getST(String TGT, String service){
        try {
            CloseableHttpClient client = HttpClients.createDefault();

            HttpPost httpPost = new HttpPost(CasConfig.GET_TOKEN_URL + "/" + TGT);
            List<NameValuePair> params = new ArrayList<NameValuePair>();
            params.add(new BasicNameValuePair("service", service));
            httpPost.setEntity(new UrlEncodedFormEntity(params));
            HttpResponse response = client.execute(httpPost);

            String st = readResponse(response);
            return st == null ? null : (st == "" ? null : st);
        }catch (Exception e){
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 读取 response body 内容为字符串
     *
     * @param response
     * @return
     * @throws IOException
     */
    private static String readResponse(HttpResponse response) throws IOException {
        BufferedReader in = new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
        String result = new String();
        String line;
        while ((line = in.readLine()) != null) {
            result += line;
        }
        return result;
    }

}

3、cas-app 和 cas-client 配置

cas-app与cas-client配置与之前写的文章大致相同,

cas-app 主要负责前端页面

cas-client 主要提供接口api。

主要改动了一下login.html登录页面。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>App1 登录界面</title>
</head>

<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script src="assets/js/common.js"></script>
<script>
$(document).ready(function() {
    var service = GetQueryString("service");
    // 如果为空,表示直接进入登录页面
    if (service == null) {
        service = "http://app1.com:8181/fire/index.html";
    }
    console.info("service:" + service);
    $("#service").val(service); // 受访受限url的地址
    // 新进行判断用户是否登录过
    $.ajax({
        method: "GET",
        url: "http://sso.server.com:9000/sso/user/check",
        data: {
            'service': service
        },
        xhrFields: {
            withCredentials: true
        },
        crossDomain: true,
        dataType: "jsonp",
        jsonp: "callback",
        // cache: false,
        success: function(result) {
            console.info("请求成功");
            console.info(result);
            if (result.status == 1) {
                // 设置 302 重定向跳转
                window.location.href = result.data;
            } else {
                // 显示登录页面
                $("#loginDiv").show("slow");
            }
        },
        error: function(data) {
            console.info("请求失败");
            $("#loginDiv").show("slow");
        }
    });
});
</script>
<body>

<h2>App1 用户登录</h2>

<div id="loginDiv" style="display: none">
    <form action="http://sso.server.com:9000/sso/user/login" method="post">
        <table>
            <tr>
                <td>用户名:</td>
                <td><input id="username" name="username" type="text" ></td>
            </tr>
            <tr>
                <td>密 码:</td>
                <td><input id="password" name="password" type="password"></td>
            </tr>
            <tr>
                <td>
                    <input type="hidden" name="service" id="service" value="">
                    <input type="submit" value="登录">
                </td>
                <td><input type="reset"></td>
            </tr>
        </table>
    </form>
</div>

</body>
</html>

三、测试效果

访问,跳转自定义登录页面。check首先进行验证。

WX20180423-162406@2x.png

登录成功,302进行st验证。

WX20180423-162607@2x.png

验证成功,生成sessionid

WX20180423-162648@2x.png

并且生成sso-server域名下的cookie缓存

WX20180423-170428@2x.png

观察redis中是否有存储TGT值

WX20180423-170511@2x.png

app2登录时,发送jsonp请求,自动携带sso-server域下的cooke,完整校验和重定向操作。

WX20180423-164324@2x.png

特别说明

比较推荐在生产环境,将存储Redis的规则进行修改,比如根据用户请求的浏览器信息、ip地址、uuid、账号信息等,进行md5加密,生成短密码值当作key,而不是我这里直接使用的username作为key。

从cookie中获取之后再进行解密完成流程即可。

我的源码

https://github.com/X-rapido/CAS_SSO_Record

视频效果演示

视频地址:https://v.qq.com/x/page/w0614c07580.html


未经允许请勿转载:程序喵 » Cas 5.2.x版本使用 —— Restful API 方式实现SSO(十八)

点  赞 (13) 打  赏
分享到: