oauth2.0及oauth2-server 库在thinkphp中的使用

1.OAuth2.0

OAuth2.0 定义了四个角色

Client:客户端,第三方应用程序。
Resource Owner:资源所有者,授权 Client 访问其帐户的用户。
Authorization server:授权服务器,服务商专用于处理用户授权认证的服务器。
Resource server:资源服务器,服务商用于存放用户受保护资源的服务器,它可以与授权服务器是同一台服务器,也可以是不同的服务器。

oauth2-server

Access token:用于访问受保护资源的令牌。
Authorization code:发放给应用程序的中间令牌,客户端应用使用此令牌交换 access token。
Scope:授予应用程序的权限范围。
JWT:Json Web Token 是一种用于安全传输的数据传输格式。

2.四种模式

1)授权码模式(authorization code)

这种模式是最安全的OAuth2的授权模式。设计了auth code,通过这个code再获取token,最后通过token获取资源。支持refresh token

2)简单模式(implicit)

和授权码模式类似,只不过少了获取code的步骤,是直接获取令牌token的,适用于公开的浏览器单页应用,令牌直接从授权服务器返回,不支持刷新令牌,且没有code安全保证,令牌容易因为被拦截窃听而泄露。不支持refresh token

3)密码模式(resource owner password credentials)

这种模式是最不推荐的,因为client可能存了用户密码
这种模式主要用来做遗留项目升级为oauth2的适配方案
当然如果client是自家的应用,也是可以。支持refresh token

4)客户端模式(client credentials)

这种模式直接根据client的id和密钥即可获取token,无需用户参与
这种模式比较合适消费api的后端服务,比如拉取一组用户信息等
不支持refresh token,主要是没有必要

个别参数说明

  • scope:权限范围,可选项,以空格分隔
  • state:CSRF令牌,可选项,但强烈建议使用,应将该值存储于用户会话中,以便在返回时验证

3.oauth2运行流程

oauth2.0及oauth2-server 库在thinkphp中的使用

4.jwt

jwt使用一种特殊格式的token,token是有特定含义的,分为三部分:

头部Header
在header中通常包含了两部分:token类型和采用的加密算法。如下:

{
"alg": "HS256",   #加密方式
"typ": "JWT"        #token的类型为jwt
}

载荷Payload,可以添加自定义参数

{
    "aud": "myapp",                            #接收该JWT的一方
    "jti": "9cf3dde8d0dd28fe5c678acb3161354128d627b12638c78c40f32bafe3e6fc",
    "iat": 1629360641.99316,            #在什么时候签发的
    "nbf": 1629360641.993166,
    "exp": 1629364241.990516,        #什么时候过期,这里是一个Unix时间戳
    "sub": "300000",                         #该JWT所面向的用户
    "scopes": ["basic"]                        #权限范围
}

签名Signature
将Header和Payload通过'.'拼接起来,再根据header中的加密方式加密,然后再base64即可得到signature
最后这三部分均用base64进行编码,并使用.进行分隔。一个典型的jwt格式的token类似xxxx.yyy.zzz。

$accessToken = $request->header('authorization');
$arr = explode('.', $accessToken);
$uid = json_decode(base64_decode($arr[1]))->sub;

5.安装

composer require league/oauth2-server

注意

  1. 8.x版本需要php7.4版本,否则会报 AccessToken::__toString() must not throw an exception 也可以使用7.2 安装lcobucci/jwt 3.4.6 详见官网安装配置要求
  2. 7.x的版本需要将jwt库降级为3.3.3,否则会报 Replicating claims as headers is deprecated and will removed from v4.0. Please manually set the header if you need it replicated

composer require lcobucci/jwt 3.3.3

6.授权码模式

6.1代码实例

生成私钥公钥

openssl genrsa -out private.key 2048 #生成私钥(无密码)
openssl rsa -in private.key -pubout -out public.key #私钥换取公钥(无密码)

openssl genrsa -aes128 -passout pass:_passphrase_ -out private.key 2048 #生成私钥(有密码)
openssl rsa -in private.key -passin pass:_passphrase_ -pubout -out public.key #私钥换取公钥(有密码)

初始化服务

public function codeGrantInit(){
    $clientRepository = new ClientRepository();
    $scopeRepository = new ScopeRepository();
    $accessTokenRepository = new AccessTokenRepository();
    $authCodeRepository = new AuthCodeRepository();
    $refreshTokenRepository = new RefreshTokenRepository();
 
    $privateKeyPath = root_path() . 'private.key';
 
    $server = new AuthorizationServer(
        $clientRepository,
        $accessTokenRepository,
        $scopeRepository,
        $privateKeyPath, '');
 
    //设置模式
    try {
        $grant = new AuthCodeGrant(
            $authCodeRepository,
            $refreshTokenRepository,
            new DateInterval('PT10M') // authorization codes will expire after 10 minutes
        );
 
        //设置RefreshToken的生命周期
        $grant->setRefreshTokenTTL(new DateInterval('P1M'));
 
        $server->enableGrantType(
            $grant,
            new DateInterval('PT1H') // access tokens will expire after 1 hour
        );
 
        return $server;
    } catch (\Exception $e) {
        throw new BaseException($e->getMessage());
    }
}

第一步:
用户访问客户端,客户端将用户导向授权服务器。【获取code,用户可见】【用户在客户端】
请求方式:get
请求参数 :response_type=code、client_id、redirect_uri、scope、state

public function authorize(){
    $oauth = new Oauth2Service();
    $server = $oauth->codeGrantInit();
    $request = ServerRequest::fromGlobals();
    $response = new Response();
    try {
        #1.验证HTTP请求并返回AuthorizationRequest对象
        //这里是三方应用请求给授权服务器的。授权服务器验证
        //生成code时,只是验证了client_id必须,以及redirect_uri不为空时,是否与client_id对应的redirect_uri匹配,
        $authRequest = $server->validateAuthorizationRequest($request);
        // 此时应将 authRequest 对象序列化后存在当前会话(session)中
        session('authRequest', serialize($authRequest));
        #2.用户没有登录,则跳转到授权服务的登录【qq】  是pc端;11
        // 然后将用户重定向至登录入口或在当前地址直接响应登录页面
        return View::fetch('index/auth_code');
    } catch (OAuthServerException $exception) {
        throw new BaseException($exception->getMessage());
    } catch (\Exception $exception) {
        throw new BaseException($exception->getMessage());
    }
}

第二步:
要求用户登录授权服务器并批准客户端 【用户在授权服务器端】
这里登录和授权实际是两步这里只做了一步。数据库中存储的是code的唯一标识,code标识加上其他参数转json后再通过encrypt加密既是返回值。

public function agree(){
    $oauth = new Oauth2Service();
    $server = $oauth->codeGrantInit();
    $response = new Response();
    try {
        // 在会话(session)中取出 authRequest 对象
        $authRequest = unserialize(session('authRequest'));
        //1、登录验证成功,设置用户实体(userEntity)【jwt中的sub】
        $user = new UserEntity();
        $authRequest->setUser($user->login());
 
        //2、设置权限范围【jwt中的scope】
        $scope = new ScopeEntity();
        $scope->setIdentifier('basic');
        $scopes[] = $scope;
        $authRequest->setScopes($scopes);
 
        #批准客户端
        $authRequest->setAuthorizationApproved(true);
        //完成后重定向至客户端请求重定向地址
        $response = $server->completeAuthorizationRequest($authRequest, $response);
        $redirectUrl = $response->getHeaders()['Location'][0];
 
        #将auth_code返回给重定向地址
        return redirect($redirectUrl);
    } catch (OAuthServerException $exception) {
        throw new BaseException($exception->getMessage());
    } catch (Exception $exception) {
        throw new BaseException($exception->getMessage());
    }
}

返回值auth_code的组成

$payload = [
    'client_id'             => $authCode->getClient()->getIdentifier(),
    'redirect_uri'          => $authCode->getRedirectUri(),
    'auth_code_id'          => $authCode->getIdentifier(),
    'scopes'                => $authCode->getScopes(),
    'user_id'               => $authCode->getUserIdentifier(),
    'expire_time'           => (new DateTimeImmutable())->add($this->authCodeTTL)->getTimestamp(),
    'code_challenge'        => $authorizationRequest->getCodeChallenge(),//null
    'code_challenge_method' => $authorizationRequest->getCodeChallengeMethod(),//null
];
$jsonPayload = \json_encode($payload);
//无关代码省略
$this->encrypt($jsonPayload);

第三步:
获取access_token。【用户不可见,由客户后端执行】
请求方式:post
需要的参数:grant_type=authorization_code、client_id、client_secret、redirect_uri、code

public function accessToken(){
    $oauth = new Oauth2Service();
    $server = $oauth->codeGrantInit();
    $request = ServerRequest::fromGlobals();
    $response = new Response();
    try {
        // 这里只需要这一行就可以,具体的判断在 Repositories 中
        $response = $server->respondToAccessTokenRequest($request, $response);
        return response(json_decode($response->getBody()), 200, [], 'json');
    } catch (OAuthServerException $exception) {
        throw new BaseException($exception->getMessage());
    } catch (Exception $exception) {
        throw new BaseException($exception->getMessage());
    }
}

access_token返回值方法

1.(string) $this->accessToken; //BearerTokenResponse类中,此时$this->AccessTokenEntity
2.触发AccessTokenTrait的__toString();即return $this->convertToJWT()->toString();
3.生成jwt格式的acess_token

返回实例:access_token是jwt格式。

{"token_type":"Bearer","expires_in":3600,"access_token":"xxxxxx","refresh_token":"xxxx"}

access_token解密后的结果:

{ "typ": "JWT","alg": "RS256"}   #HEADER
{"aud": "test", "jti": "2cb...", "iat": 1629771757.03695,"nbf": 1629771757.036958,"exp": 1629775357.028548,"sub": "300000","scopes": ["oauth2"]} #PAYLOAD

rerfresh_token的组成

$refreshTokenPayload = \json_encode([
    'client_id'        => $this->accessToken->getClient()->getIdentifier(),
    'refresh_token_id' => $this->refreshToken->getIdentifier(),
    'access_token_id'  => $this->accessToken->getIdentifier(),
    'scopes'           => $this->accessToken->getScopes(),
    'user_id'          => $this->accessToken->getUserIdentifier(),
    'expire_time'      => $this->refreshToken->getExpiryDateTime()->getTimestamp(),
]);
$responseParams['refresh_token'] = $this->encrypt($refreshTokenPayload);

6.2相关Repository和Entity执行流程(接口实现见 官方文档)

oauth2-server 的新版本并没有指定某种数据库,所以它可以使用任何形式的数据存储方式。代价就是必须开发者自己实现...

/authorize路由中:validateAuthorizationRequest()

ClientRepository->getClientEntity ()
通过查询数据库,验证client_id,并返回cient对象
ScopeRepository->getScopeEntityByIdentifier()
通过查询数据库,验证授权服务器是否支持该权限

/agree路由中:completeAuthorizationRequest()

AuthCodeRepository->getNewAuthCode()
创建新授权码时调用方法,需要返回 AuthCodeEntityInterface 对象
AuthCodeRepository->persistNewAuthCode()
用于持久化存储授权码,持久化数据库自行选择

/access_token路由中:respondToAccessTokenRequest()

ClientRepository->getClientEntity()
通过查询数据库,验证client_id,并返回cient对象
ClientRepository->validateClient()
验证客户端,包括(client_secret,redirect_uri,is_confidential,name)
ClientRepository->getClientEntity()
验证code前,也需要验证client_id;
AuthCodeRepository->isAuthCodeRevoked()
当【使用授权码获取访问令牌时】调用此方法,用于验证授权码是否已被使用
ScopeRepository->getScopeEntityByIdentifier()
通过查询数据库,验证授权服务器是否支持该权限
ScopeRepository->finalizeScopes()
用于验证权限范围、授权类型、客户端、用户是否匹配 ,必须返回 ScopeEntityInterface对象
AccessTokenRepository->getNewToken()
返回AccessTokenEntityInterface对象,在返回前向 AccessTokenEntity 传入参数中对应属性.
AccessTokenRepository->persistNewAccessToken()
可以用于持久化存储访问令牌,持久化数据库自行选择
RefreshTokenRepository->getNewRefreshToken()
要返回 RefreshTokenEntityInterface 对象
RefreshTokenRepository->persistNewRefreshToken()
用于持久化存储授刷新令牌
AuthCodeRepository->revokeAuthCode()
可以在此时将授权码从持久化数据库中删除

6.3.刷新令牌

创建服务

public function refreshTokenGrantInit(){
    $clientRepository = new ClientRepository();
    $scopeRepository = new ScopeRepository();
    $accessTokenRepository = new AccessTokenRepository();
    $refreshTokenRepository = new RefreshTokenRepository();
 
    $privateKeyPath = root_path() . 'private.key';
 
    $server = new AuthorizationServer(
        $clientRepository,
        $accessTokenRepository,
        $scopeRepository,
        $privateKeyPath, '');
    //设置模式
    try {
        $grant = new RefreshTokenGrant($refreshTokenRepository);
        //设置RefreshToken的生命周期
        $grant->setRefreshTokenTTL(new DateInterval('P1M'));
        $server->enableGrantType(
            $grant,
            new DateInterval('PT1H') // access tokens will expire after 1 hour
        );
        return $server;
    } catch (\Exception $e) {
        throw new BaseException($e->getMessage());
    }
}

相关Repository执行流程
/refresh_token路由中:respondToAccessTokenRequest()

ClientRepository -> validateClient()
验证客户端,包括(client_secret,redirect_uri,is_confidential,name)
ClientRepository -> getClientEntity()
验证code前,也需要验证client_id;
RefreshTokenRepository -> isRefreshTokenRevoked()
当【刷新令牌获取访问令牌时】调用此方法,用于验证刷新令牌是否已被删除
ScopeRepository -> getScopeEntityByIdentifier()
通过查询数据库,验证授权服务器是否支持该权限
AccessTokenRepository -> revokeAccessToken()
使用刷新令牌创建新的访问令牌时调用此方法,可将token在持久化存储中过期
ScopeRepository -> revokeRefreshToken()
使用刷新令牌获取访问令牌时调用此方法,原刷新令牌将删除,创建新的刷新令牌
AccessTokenRepository -> getNewToken()
返回AccessTokenEntityInterface对象,在返回前向 AccessTokenEntity 传入参数中对应属性.
AccessTokenRepository -> persistNewAccessToken()
可以用于持久化存储访问令牌,持久化数据库自行选择
RefreshTokenRepository -> getNewRefreshToken()
要返回 RefreshTokenEntityInterface 对象
RefreshTokenRepository -> persistNewRefreshToken()
用于持久化存储授刷新令牌
AuthCodeRepository -> revokeAuthCode()
可以在此时将授权码从持久化数据库中删除

6.4验证令牌(中间件)

class AccessTokenMiddleware{
    public function handle($request, \Closure $next)
    {
        $publicKeyPath = root_path() . 'public.key'; // 授权服务器分发的公钥
        $accessTokenRepository = new AccessTokenRepository();
        $bearerTokenValidator = new BearerTokenValidator($accessTokenRepository);
        $bearerTokenValidator->setPublicKey(new CryptKey($publicKeyPath, null, false));
        try {
            $bearerTokenValidator->validateAuthorization(ServerRequest::fromGlobals());
            return $next($request); 
        }catch (OAuthServerException $exception) {
            throw new BaseException($exception->getHint());
        }catch (\Exception $exception) {
            throw new BaseException($exception->getMessage());
        }
    }
}

相关Repository

AccessTokenRepository -> isAccessTokenRevoked()
用于验证访问令牌是否已被删除,正常情况下,一个token会多次使用,这里可以使用缓存

7.隐式授权

整个流程与授权码模式的第一部分类似,只是授权服务器直接响应了访问令牌,跳过了授权码的步骤。它适用于没有服务器,完全运行在前端的应用程序。

此模式下没有刷新令牌(refresh token)的返回。

创建服务

public function implicitGrantInit(){
    $clientRepository = new ClientRepository();
    $scopeRepository = new ScopeRepository();
    $accessTokenRepository = new AccessTokenRepository();
    $privateKeyPath = root_path() . 'private.key';
    
    $server = new AuthorizationServer(
        $clientRepository,
        $accessTokenRepository,
        $scopeRepository,
        $privateKeyPath, '');  //encryptionKey,用于auth_code 的加密
    //设置模式
    try {
        $server->enableGrantType(
            new ImplicitGrant(new \DateInterval('PT1H')),
            new \DateInterval('PT1H') // 设置访问令牌过期时间1小时
        );
        return $server;
    } catch (\Exception $e) {
        throw new BaseException($e->getMessage());
    }
}

请求实例:实际可能是两步(1、用户访问客户端,客户端将用户导向授权服务器。2、用户登录并授权),这里用一步完成。
请求方式:post
参数:response_type=token、client_id、redirect_uri、scope、state
注意:这里授权服务器会重定向到redirect_uri,在redirect_uri后会带上 #access_token=xxx

public function authorize(){
    $oauth = new Oauth2Service();
    $request = ServerRequest::fromGlobals();
    $response = new Response();
    try {
        $server = $oauth->implicitGrantInit();
        $authRequest = $server->validateAuthorizationRequest($request);
        //1、登录验证成功,设置用户实体(userEntity)【jwt中的sub】
        $user = new UserEntity();
        $authRequest->setUser($user->login());
 
        //2、设置权限范围【假如多个可选,列表展示客户端的权限】【jwt中的scope】
        $scope = new ScopeEntity();
        $scope->setIdentifier('oauth2');
        $scopes[] = $scope;
        $authRequest->setScopes($scopes);
 
        #批准客户端
        $authRequest->setAuthorizationApproved(true);
        //完成后重定向至客户端请求重定向地址
        $response = $server->completeAuthorizationRequest($authRequest, $response);
        $redirectUrl = $response->getHeaders()['Location'][0];
        return redirect($redirectUrl);
    }catch (OAuthServerException $exception) {
        throw new BaseException($exception->getHint());
    }  catch (Exception $exception) {
        throw new BaseException($exception->getMessage());
    }
}

返回值:token_type、expires_in、access_token、state
返回实例:redirect_uri#access_token=eyJ0...&token_type=Bearer&expires_in=3600&state=1

8.密码模式

由用户提供给客户端账号密码来获取访问令牌,这属于危险行为,所以此模式只适用于高度信任的客户端(例如第一方应用程序)。客户端不应存储用户的账号密码。

OAuth2 协议规定此模式不需要传 client_id & client_secret,但 oauth-server 库需要

创建服务

public function pwdGrantInit(){
    $clientRepository = new ClientRepository();
    $scopeRepository = new ScopeRepository();
    $accessTokenRepository = new AccessTokenRepository();
    $privateKeyPath = root_path() . 'private.key';
 
    $server = new AuthorizationServer(
        $clientRepository,
        $accessTokenRepository,
        $scopeRepository,
        $privateKeyPath, '');  //encryptionKey,用于auth_code 的加密
 
    $userRepository = new UserRepository();
    $refreshTokenRepository = new RefreshTokenRepository();
    try {
        $grant = new PasswordGrant(
            $userRepository,
            $refreshTokenRepository
        );
 
        $grant->setRefreshTokenTTL(new \DateInterval('P1M')); // refresh tokens will expire after 1 month
 
        $server->enableGrantType(
            $grant,
            new \DateInterval('PT1H') // access tokens will expire after 1 hour
        );
        return $server;
    } catch (\Exception $e) {
        throw new BaseException($e->getMessage());
    }
}

请求方式:post
参数:grant_type=password、client_id、client_secret、scope、username、password

public function accessToken(){
    //先验证必传参数
    $oauth = new Oauth2Service();
    $request = ServerRequest::fromGlobals();
    $response = new Response();
    try {
        $server = $oauth->pwdGrantInit();
        // 这里只需要这一行就可以,具体的判断在 Repositories 中
        $response = $server->respondToAccessTokenRequest($request, $response);
        return response(json_decode($response->getBody()), 200, [], 'json');
    } catch (OAuthServerException $exception) {
        throw new BaseException($exception->getHint());
    } catch (Exception $exception) {
        throw new BaseException($exception->getMessage());
    }
}

返回值:token_type、expires_in、access_token、refresh_token

相关Repository执行流程

ClientRepository->validateClient()
验证客户端,包括(client_secret,redirect_uri,is_confidential,name)
ClientRepository->getClientEntity()
通过查询数据库,验证client_id,并返回cient对象
ScopeRepository->getScopeEntityByIdentifier()
通过查询数据库,验证授权服务器是否支持该权限
ScopeRepository->finalizeScopes()
用于验证权限范围、授权类型、客户端用户是否匹配 ,必须返回 ScopeEntityInterface对象
AccessTokenRepository->getNewToken()
返回AccessTokenEntityInterface对象,在返回前向 AccessTokenEntity 传入参数中对应属性.
AccessTokenRepository->persistNewAccessToken()
可以用于持久化存储访问令牌,持久化数据库自行选择
RefreshTokenRepository->getNewRefreshToken()
要返回 RefreshTokenEntityInterface 对象
RefreshTokenRepository->persistNewRefreshToken()
用于持久化存储授刷新令牌

9.客户端模式

客户端模式是指以客户端的名义,而不是用户的名义,向授权服务器获取认证。在这个模式下,用户与授权服务器不产生关系,用户只能感知到的客户端所产生的资源【公共资源和单个用户无关】也都由客户端处理。

创建服务

public function clientGrantInit(){
    $clientRepository = new ClientRepository();
    $scopeRepository = new ScopeRepository();
    $accessTokenRepository = new AccessTokenRepository();
    $privateKeyPath = root_path() . 'private.key';
 
    $server = new AuthorizationServer(
        $clientRepository,
        $accessTokenRepository,
        $scopeRepository,
        $privateKeyPath, '');  //encryptionKey,用于auth_code 的加密
 
    try {
        $server->enableGrantType(
            new ImplicitGrant(new \DateInterval('PT1H')),
            new \DateInterval('PT1H') // 设置访问令牌过期时间1小时
        );
        return $server;
    } catch (\Exception $e) {
        throw new BaseException($e->getMessage());
    }
}

请求方式:post
参数:grant_type=client_credentials、client_id、client_secret、scope

public function accessToken(){
    //先验证必传参数
    $oauth = new Oauth2Service();
    $request = ServerRequest::fromGlobals();
    $response = new Response();
    try {
        $server = $oauth->clientGrantInit(); //只是服务不同
        // 这里只需要这一行就可以,具体的判断在 Repositories 中
        $response = $server->respondToAccessTokenRequest($request, $response);
        return response(json_decode($response->getBody()), 200, [], 'json');
    } catch (OAuthServerException $exception) {
        throw new BaseException($exception->getHint());
    } catch (Exception $exception) {
        throw new BaseException($exception->getMessage());
    }
}

返回值:token_type、expires_in、access_token

相关Repository执行流程

ClientRepository->getClientEntity()
通过查询数据库,验证client_id,并返回cient对象
ClientRepository->validateClient()
验证客户端,包括(client_secret,redirect_uri,is_confidential,name)
ClientRepository->getClientEntity()
验证code前,也需要验证client_id;
ScopeRepository->getScopeEntityByIdentifier()
通过查询数据库,验证授权服务器是否支持该权限
ScopeRepository->finalizeScopes()
用于验证权限范围、授权类型、客户端、用户是否匹配 ,必须返回 ScopeEntityInterface对象
AccessTokenRepository->getNewToken()
返回AccessTokenEntityInterface对象,在返回前向 AccessTokenEntity 传入参数中对应属性.
AccessTokenRepository->persistNewAccessToken()
可以用于持久化存储访问令牌,持久化数据库自行选择

参考:

Tags: oAuth

添加新评论