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运行流程
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
注意
- 8.x版本需要php7.4版本,否则会报
AccessToken::__toString() must not throw an exception
也可以使用7.2 安装lcobucci/jwt 3.4.6
详见官网安装配置要求 - 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()
可以用于持久化存储访问令牌,持久化数据库自行选择
参考: