多系统场景的登录授权方案

登录和授权是后台管理系统开发中经常遇到的功能。本文将会从登录和授权两个方面介绍多系统场景下的登录授权方案。

定义

什么是登录

我们先看一下百度百科对于登录的解释:

供多人使用的网站或应用系统为每位用户配置了一套独特的用户名和密码,用户可以使用各自的这套用户名和密码来使用系统,以便系统能识别该用户的身份,从而保持该用户的使用习惯或使用数据。用户使用这套用户名和密码来进入系统的过程,以及系统验证进入是成功或失败的过程,称为“登录”。

概念太复杂,简单来说,登录就是通过你提供的信息让应用程序认识你的过程。其中,你提供的信息,可以是用户名密码、生物特征如指纹等或者其他第三方授权后的信息,然后应用程序通过跟数据库中的信息比对,定位到唯一一个合法用户,并颁发令牌。比对信息、定位用户、颁发令牌的过程也叫做认证。

什么是授权

我们知道,登录本身并不是目的,而通过登录我们可以访问应用程序的资源才是目的。所以,登录之后,主体获得可以访问应用程序资源权限的过程就叫做授权,也称为访问控制。

访问控制决定了谁能够访问系统、能访问系统的哪些种资源和如何使用这些资源,是控制对计算机系统或网络的访问的一种方法,目的是防止对信息系统资源的非授权访问和使用。

所以,为什么应用软件或者web系统要进行登录和授权,就是为了防止非法用户对资源的访问和使用。

多系统的要求

当只有一个系统需要进行登录与授权时,功能的设计和实现都相对简单。当系统数量不是一个,而是多个时,如果每个系统、后台都按照自己的需求开发登录授权功能,不仅在工作量上是一个巨大的负担和浪费,而且在安全性上也无法做到统一。所以对于多系统场景,登录与授权往往需要达到以下要求:

  • 统一

    单点登录,任何系统只要登录完成,其他系统均能共享登录状态

  • 灵活

    授权方案要能精确控制用户对资源的访问,实现可配置化

  • 实时

    权限是赋予和回收要尽量快速

调研

目前市面上的登录授权的方案比较多,下面介绍几种常见的方案

cookie和session是为了解决http协议的无状态特性而设计的,在一次http会话中,可以通过cookie和session记录用户的相关信息,便于服务器识别访问用户。

cookie:网站为了辨别用户身份,进行跟踪而储存在用户本地终端上的数据(通常经过加密),由用户客户端计算机暂时或永久保存的信息,存于客户端。

session:在网络应用中,称为“会话控制”,存储特定用户会话所需的属性及配置信息,存于服务器端。

使用cookie和session设计登录功能是目前最基本也是最常见的方案,尤其在B/S架构中。用户登录服务器后会在服务器生成session并将用户信息存在session中,同时将sessionId发送到客户端保存在cookie中。当用户再次访问服务器时,会携带cookie,那么服务器就可以从cookie中读取sessionId并找到session获取到用户信息,从而达到识别用户的目的。

JWT

JWT是JSON Web Token的缩写,官网的定义比较复杂,简单来说,JWT是一个基于json的跨域认证解决方案。

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,之后当用户与服务器通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。

JWT常应用于以下两个场景:

授权

这是使用 JWT 最常见的场景,尤其是在单点登录应用中。用户登录后,每个后续请求都将包含 JWT,从而允许用户访问该令牌允许的路由、服务和资源。

信息交换

JWT可以在不同服务器之间传递信息,因为可以对JWT进行签名和加密,所以不用担心信息被修改。

JWT的结构通常包含以下内容:

  • 标题(Header)

    标头通常由两部分组成:令牌的类型,即 JWT,以及正在使用的签名算法,例如 HMAC SHA256 或 RSA。

  • 有效负载(Payload)

    JSON 对象,用来存放实际需要传递的数据,包含7个官方字段和自定义字段

  • 签名(Signature)

    对前两部分的签名,防止数据篡改。

显示出来就是如下形式:

xxxxx.yyyyy.zzzzz

对比一下Cookie+Session与JWT方案的优劣

OAuth2.0

OAuth协议是一个开发标准,在这种标准下允许用户让第三方应用访问该用户在某一网站上存储的私密的资源数据,无须将用户名和密码提供给第三方应用。OAuth2.0是该标准的第二个版本,其侧重于简化客户端开发,同时为web应用程序、桌面应用程序、移动应用和客厅设备提供特定的授权流。

OAuth2.0为定义了四种角色:

  • 资源拥有者(Resource Owner)

    能够授予对受保护资源的访问权限的实体。当资源所有者是个人时,它被称为最终用户。

  • 资源服务器(Resource Server)

    托管受保护资源的服务器,能够接受以及使用访问令牌响应受保护的资源请求。

  • 客户端应用(Client第三方应用)

    资源拥有者在获取授权之后请求受保护资源的应用。

  • 授权服务器(Authorization Server)

    验证资源拥有者并获得授权成功后,服务器向客户端发出访问令牌。

四种角色之间的交互是按照以下流程进行的:

(A)用户打开客户端以后,客户端要求用户给予授权。

(B)用户同意给予客户端授权。

(C)客户端使用上一步获得的授权,向认证服务器申请令牌。

(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。

(E)客户端使用令牌,向资源服务器申请获取资源。

(F)资源服务器确认令牌无误,同意向客户端开放资源。

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。

OAuth 2.0一共分成四种授权类型:

  • 授权码模式(authorization code)

    第三方应用先申请一个授权码,然后再用该码获取令牌。

  • 简化模式(implicit)

    允许直接向前端颁发令牌,这种方式没有授权码这个中间步骤。

  • 密码模式(resource owner password credentials)

    对于高度信任某个应用,也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌。

  • 客户端模式(client credentials)

    客户端以自己的名义,而不是以用户的名义,向"服务提供商"进行授权。适用于没有前端的命令行应用,即在命令行下请求令牌。一般用来提供给我们完全信任的服务器端服务。

方案设计

登录方案的选择要根据具体的场景,对于用户量较少,访问压力小的场景,优先选择cookie+session方案,反之则优先选择JWT方案,授权方案选择OAuth2.0。下面以cookie+session+OAuth2.0为例介绍具体方案设计。

解耦

对于多系统场景,每个系统都需要支持登录与授权,也都需要能够共享别的系统的登录状态。这时,与其每个系统都开发登录功能,不如考虑将登录与授权功能从每个系统中剥离出来,单独作为一个系统维护,形成一个登录授权中心。那么其他每个系统需要进行登录的时候就将用户转移到登录授权中心,需要授权的时候也是跟登录授权中心通信来获取用户权限。

登录

上文已经大致介绍了以cookie+session为依据的登录方案,下面将会以php为例,详细介绍一下。

登录操作

php的会话机制:

会话通常被用来在多个页面请求之间保存及共享信息。 一般来说,会话 ID 通过 cookie 的方式发送到浏览器,并且在服务器端也是通过会话 ID 来取回会话中的数据。 如果请求中不包含会话 ID 信息,那么 PHP 就会创建一个新的会话,并为新创建的会话分配新的 ID。

会话的工作流程很简单。当开始一个会话时,PHP 会尝试从请求中查找会话 ID (通常通过会话 cookie), 如果请求中不包含会话 ID 信息,PHP 就会创建一个新的会话。 会话开始之后,PHP 就会将会话中的数据设置到 $_SESSION 变量中。 当 PHP 停止的时候,它会自动读取 $_SESSION 中的内容,并将其进行序列化, 然后发送给会话保存管理器来进行保存。

所以,我们需要做的就是

  1. 开启会话

    在php中可以通过两种方式开启会话:第一种是在php.ini中将session.auto_start 设置为1,此时表示自动开启会话;第二种方式是调用函数 session_start() 来手动开始一个会话。

  2. 保存用户信息
    验证完用户信息之后,需要将必要的用户信息存在session中,以便后续用户访问时能够认证用户。具体的做法就是将用户信息保存到$_SESSION变量中。

// 获取用户信息
$user = [
    'id' => 1,
    'username' => '张三',
];

// 执行登录
@session_start(); // 手动开启会话

// 保存用户信息
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['lifetime'] = time() + 3600;

这里,我们需要注意的问题是:

默认情况下,session在服务器是有有效期的

// php.ini
; After this number of seconds, stored data will be seen as 'garbage' and
; cleaned up by the garbage collection process.
; http://php.net/session.gc-maxlifetime
session.gc_maxlifetime = 1440

在php.ini中,可以为session设置有效期,默认是1440s。但是,这并不表示1440s之后session就会失效。php的session是通过gc(garbage collection 垃圾回收)进程处理的,这个进程的触发会在每个会话初始化时按照一定概率触发的。这个概率通过php.ini中的两个参数session.gc_probability 与 session.gc_divisor来指定,通过 gc_probability/gc_divisor 计算得来。例如 1/100 意味着在每个请求中有 1% 的概率启动 gc 进程。

因此,当用户登录成功之后,如果session.gc_maxlifetime定义的时间内没有后续操作,用户的登录状态就有可能失效。这种失效是按照概率触发的,显然无法精确控制用户的登录时间,我们可以通过调用session_set_save_handler()函数来自定义保存session到数据库中,同时有效精确控制session的有效期。

// 自定义保存session
$save = new SessionCallback();
session_set_save_handler(
    [$save, 'openSession'],
    [$save, 'closeSession'],
    [$save, 'readSession'],
    [$save, 'writeSession'],
    [$save, 'destroySession'],
    [$save, 'gcSession']
);

// 执行登录
session_start(); // 手动开启会话

// 保存用户信息
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['lifetime'] = time() + 3600;


// session类--保存在redis中
class SessionCallback{

    public function openSession($savePath, $sessionName)
    {
        return true;
    }

    public function closeSession()
    {
        return true;
    }

    public function readSession($id)
    {
        return $this->getRedis()->get(md5($id));
    }

    public function writeSession($id, $data)
    {
        return (bool)$this->getRedis()->set(md5($id),serialize($data));
    }

    public function destroySession($id)
    {
        return true;
    }

    public function gcSession($maxLifetime)
    {
        return true;
    }
}
认证

认证就是当用户在登录有效期内再次访问服务器时,服务器能够根据cookie中的sessionId找到session,同时读取到用户信息并验证通过的过程。而从cookie中读取sessionId并找到session这一步,php已经帮我们完成了。

php支持两种方式传输sessionId:Cookies、URL 参数

会话模块支持这两种方式。 Cookie 方式相对好一些,但是用户可能在浏览器中关闭 Cookie,所以 第二种方案就是把会话 ID 直接并入到 URL 中,以保证会话 ID 的传送。

无需开发人员干预,PHP 就可以自动处理 URL 传送会话 ID 的场景。 如果启用了 session.use_trans_sid 选项, PHP 将会自动在相对 URI 中包含会话 ID。

// 自定义保存session
$save = new SessionCallback();
session_set_save_handler(
    [$save, 'openSession'],
    [$save, 'closeSession'],
    [$save, 'readSession'],
    [$save, 'writeSession'],
    [$save, 'destroySession'],
    [$save, 'gcSession']
);

session_start(); // 手动开启会话

// 用户再次访问
print_r($_SESSION);

return ;
自动登录

登录之后,在session未过期之前,用户可以一直保持登录状态,不需要再次登录。当session过期之后,用户就必须再次提交用户名密码等信息重新登录了。那如果我们希望用户可以长时间保持登录状态,就算session过期之后仍然不需要输入用户名密码也能继续保持登录状态,需要怎么做呢?

有的同学可能会说,那直接把session的有效期设置久一些不就可以了?

这种做法不仅不安全,而且无法满足不同用户设置不同长度登录有效期的需求。php文档也明确指出不应该这么做:

开发者不应该通过使用长生命周期的会话 ID 来实现自动登录功能, 因为这种方式提高了会话被窃取的风险。 开发者应该自己实现自动登录的机制。

如果要解决这个问题,我们应该充分利用cookie。如果将用户信息加密之后保存在cookie中,然后给cookie设置有效期,当session过期之后,通过cookie携带的用户信息依然可以认证用户,从而达到自动登录的目的。

首次登录

$user = [
    'id' => 1,
    'username' => '张三',
];
// 自定义保存session
$save = new SessionCallback();
session_set_save_handler(
    [$save, 'openSession'],
    [$save, 'closeSession'],
    [$save, 'readSession'],
    [$save, 'writeSession'],
    [$save, 'destroySession'],
    [$save, 'gcSession']
);

session_start(); // 手动开启会话

// 保存用户信息
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['lifetime'] = time() + 3600;

// 同时保存cookie
$salt = '123456';
$userInfo = [
    'id' => $user['id'],
    'lifetime' => $_SESSION['lifetime'],
    'sign' => md5($user['id'].$_SESSION['lifetime'].$salt) // 签名,防止用户信息被修改
];
setcookie('user',json_encode($userInfo));
echo '登录成功';

session失效(删除服务器session或者cookie中的PHPSESSID)后再次访问

// 用户再次访问
// 判断用户是否登录
if (empty($_SESSION['user_id'])){ // 登录失效
    // 读取cookie中的用户信息进行自动登录
    if (!empty($_COOKIE['user'])){
        $userInfo = json_decode($_COOKIE['user'],true);
        $salt = '123456';
        if ($userInfo['sign'] != md5($userInfo['id'].$userInfo['lifetime'].$salt)){
            // 用户信息无效,重新登录
            echo '请重新登录';
            return ;
        }else{
            // 用户验证成功,自动登录
            // 保存用户信息并延长登录
            $_SESSION['user_id'] = $userInfo['id'];
            $_SESSION['lifetime'] = time() + 3600;
          	$userInfo = [
              'id' => $userInfo['id'],
              'lifetime' => $_SESSION['lifetime'],
              'sign' => md5($user['id'].$_SESSION['lifetime'].$salt) // 签名,防止用户信息被修改
						];
						setcookie('user',json_encode($userInfo));
            echo '登录成功';
        }
    }else{
        // 无用户信息,重新登录
        echo '请重新登录';
        return ;
    }
}

在自动登录的场景中,由于将用户信息保存到了cookie中,那么为了安全起见,cookie要进行专门属性设置。

Domain属性:指定了可以访问该 Cookie 的 Web 站点或域。

Secure属性:指定是否使用HTTPS安全协议发送Cookie。

HTTPOnly 属性 :用于防止客户端脚本通过document.cookie属性访问Cookie,有助于保护Cookie不被跨站脚本攻击窃取或篡改。

SameSite属性:用来防止 CSRF 攻击 和用户追踪(第三方恶意获取cookie),限制第三方 Cookie。

授权

登录功能完成之后,每个业务后台都需要接入登录授权中心,接入之后就可以实现单点登录的效果。

下面介绍几种常见的授权方式:

  • 授权码模式(authorization code)

    大多数业务后台的授权方式都采用该模式,能够精准控制每个用户的权限内容。

  • 客户端模式(client credentials)

    例如后台任务或者脚本与登录授权中心的通信

  • 刷新token

    对于用户可能会多次与登录授权中心通信,需要保持token始终有效

组件选择

bshaffer/oauth2-server-php,文档见https://bshaffer.github.io/oauth2-server-php-docs/

该组件为OAuth2.0官方提供的php组件,安全稳定

搭建OAuth2.0服务

业务系统授权

下面展示授权码模式的授权过程

业务系统之间的通信

把登录授权功能从每个业务系统中剥离出来之后,每个业务系统都需要经过登录中心授权才能访问。上述部分解决了业务系统与登录授权中心之间的授权通信问题,那么当两个业务系统之间需要通信时,又该如何处理呢?

为了解决两个业务系统之间的通信,我们需要充分利用OAuth2.0授权之后下发的token,以此作为凭据进行通信。

具体方案如下:

A、用户去登录授权中心获取进行授权

B、登录授权中心验证之后下发token

C、携带token访问B系统api接口

D、B系统获取到token后,把token发送到登录授权中心验证

E、登录授权中心验证token合法性,并返回用户信息

F、B系统拿到用户信息后执行api接口逻辑并返回结果

总结

本文概括总结了多系统登录与授权功能的设计方案、内部逻辑和实现方法,当前的技术方案能够满足多业务后台的登录授权需求以及用户使用的便利性,但是仍然具有较大的优化空间:

  • 登录信息的加密方式可以继续优化,不仅在客户端和服务端要加密存储,传输过程也需要加密或者签名
  • 登录信息(session)的保存只适用于目前用户量较少的场景,无法应对未来分布式集群的场景
  • 前端微服务场景下,多项目同时授权的支持不够

登录与授权功能是大多数web系统和应用程序都需要集成的功能,而这些大家每天都在使用而又无法明确感知的功能,如果设计不当,往往会给系统带来较大的安全隐患,希望本文提供的技术方案能够为大家提供一些有用的方向和思路。

展示评论