SSO单点登录的实现

1、为什么要做SSO?

在猎豹移动游戏开放平台刚开始的时候,我们的首要需求是实现OAuth2协议来为CP提供接入功能。
但随着我们的项目在发展,论坛、客服、用户中心也进行开发以及展望,不同的系统之间,帐号需要互通,实现单点登录,因此SSO应运而生。
也许有人说OAuth2也能实现单点登录,为什么不直接所有的系统都通过OAuth协议来实现统一登录。
对于大型的平台,SSO单点登录是必须的。OAuth给用户资源的授权提供了一个安全的、开放而又简易的标准,能够更安全、更方便的给第三方提供某些用户授权的信息。但和OAuth不同的是,对于我们自己的系统来说,不需要进行授权就能让用户进行使用。
一个很好的例子就是腾讯的QQ登录功能,对于第三方例如京东,就是使用OAuth协议进行授权,而对于腾讯微博、QQ空间,则是通过SSO来实现单点登录。

 

2、如何实现SSO?

SSO有以下几种方式实现:

  • 共享Cookie,这种是我们最先采取的方式。当我们的子系统都在一个父级域名下时,我们可以将Cookie种在父域下,这样浏览器同域名下的Cookie则可以共享,这样可以通过Cookie加解密的算法获取用户SessionID,从而实现SSO。
    但是,后面我们发现这种方式有几种弊端:
    a. 所有同域名的系统都能获取SessionID,易被修改且不安全;
    b. 跨域无法使用。
    所以到后面抛弃这种做法。
  • Ticket验证,我们目前采取的是这种方式。这种实现的SSO有以下几个步骤:
    a. 用户访问某个子系统,发现如果未登录,则引导用户跳转到SSO登录页面;
    b. 判断SSO是否已经登录;
    c. 如果已经登录,直接跳转到回调地址,并返回认证ticket;
    d. 如果未登录,用户正确输入用户名/密码,认证通过跳转到回调地址,并返回认证ticket;
    e. 子系统获取ticket,调用SSO获取用户uid等信息,成功后让用户登录。

3、SSO实现机制

以下是我们当前SSO的时序图

1

SSO系统生成ticket并跳转

$ticket = $this->generate_ticket($appid, 60, $redis);
if(strpos($ret['data'],"?"))
{
$ret['data'] .= "&ticket=$ticket";
}
else
{
$ret['data'] .= "?ticket=$ticket";
}
$ret['data'] .= ($request->get('state')) ? "&state=".$request->get('state') : "";

function generate_ticket($appid, $timeout, $redis)
{
    $uuid = \J20\Uuid\Uuid::v4(false);;
    $ticket = md5($uuid.self::SALT);
    $data = array('sid' => session_id(), 'appid' => $appid);
    $redis->setex($ticket, 60, json_encode($data));
    return $ticket;
}

子系统换票

    $ip = $config->redis->ip;
    $port = $config->redis->port;

    $redis = new \Redis();
    $redis->connect($ip, $port);
    $ticket = $request->get('ticket');
    $resp = array('code' => \ecode\Ecode::OK);
    if($redis->exists($ticket))
    {
        $data = json_decode($redis->get($ticket), TRUE);
        $redis->delete($ticket);
        session_destroy();
        session_id($data['sid']);
        session_start();
        $resp['uid'] = $_SESSION['uid'];
        if(isset($_SESSION['ptoken']))
        {
            $resp['ptoken'] = $_SESSION['ptoken'];
        }
        else
        {
            $resp['ptoken'] = $_SESSION['ptoken'] = \account\Tools::str_random()
        }
        $ptlogout = \account\Tools::str_random();
        $resp['ptlogout'] = $ptlogout;
        $info = array('ptlogout' => $ptlogout, 'sid' => $request->get('sid'));
        $_SESSION['ptlogin'][$data['appid']] = $info;
    }
    else
    {
        $resp['code'] = \ecode\Ecode::SSOTicketInvalid;
    }
    $resp['msg'] = constant("L::ecode_".$resp['code']);
    return \account\Tools::json_ret($resp);

4、如何实现统一退出

当子系统换票拿ticket去SSO获取用户信息的时候,会获取到2个参数:ptoken/ptlogout。
那么这2个参数有什么用呢?
1. 当A子系统退出的时候,如果需要通知SSO退出,就需要用到ptoken。 2. A子系统引导用户访问SSO退出的接口,并带上ptoken作为参数。 3. 当SSO验证参数ptoken与本地存储一致时,从而信任调用它的子系统,主动退出。
4. 这时候已登录过的B子系统要怎么退出呢?ptlogout这时候就发挥它的作用了。SSO拿ptlogout去调用B子系统的退出接口,ptlogout双方一致时,B子系统退出。

    $ptoken = $request->get('ptoken');
    $appid = $request->get('appid');
    $map = explode(',', $config->apps->map);
    $apps = array();
    foreach($map as $item)
    {
        $i = explode('-', $item);
        $apps[$i[0]] = $i[1];
    }
    if(array_key_exists($appid, $apps) && $ptoken == $_SESSION['ptoken'])
    {
        // 已登录过的子系统
        $ptlogin = json_decode($_SESSION['ptlogin'], TRUE);
        foreach($ptlogin as $k => $v)
        {
            // 判断是否是当前调用系统,如果是则跳过本次循环,如果不是则调用退出接口
            if($appid == $k)
                continue;
            $base_url = $config->$apps[$k]->logout;
            $base_url .= (strpos($base_url, '?')) ? '&' : '?';
            $url = $base_url.'?ticket='.$v['ptlogout'].'&sid='.$v['sid'];
            $ret = file_get_contents($url);
        }
    }
    session_destroy();

5、SSO的表现形式 — iframe

使用iframe的好处就是对于自己内部接入SSO的系统来说,不用关心用户是如何登录的。
1. 当需要使用SSO登录时,只需在页面嵌入iframe。
2. 当iframe中SSO已存在登录状态的时候,可以直接实现无缝的跳转,若没有登录状态,则显示登录框提供用户登录的功能,认证通过后再跳转。
3. 这样对于子系统,不需要写太多的代码就能实现单点登录。
4. 为了适应不同子系统的需求,我们还能通过参数配置,来显示不同的样式,来控制不同的跳转返回。

效果图:2

3