258 lines
8.4 KiB
PHP
258 lines
8.4 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Lightweight OAuth 2.0 data/access service for DSP -> JupyterHub integration.
|
|
*/
|
|
class OAuthService
|
|
{
|
|
private const AUTH_CODE_TTL = 600; // 10 minutes
|
|
private const ACCESS_TOKEN_TTL = 3600; // 1 hour
|
|
private const REFRESH_TOKEN_TTL = 2592000; // 30 days
|
|
|
|
private PDO $pdo;
|
|
|
|
public function __construct(PDO $pdo)
|
|
{
|
|
$this->pdo = $pdo;
|
|
}
|
|
|
|
public function getClient(string $clientId): ?array
|
|
{
|
|
$sql = "SELECT client_id, client_name, client_secret_hash, redirect_uris, allowed_scopes, is_confidential
|
|
FROM dsp_oauth_clients
|
|
WHERE client_id = :client_id AND is_revoked = 0";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([':client_id' => $clientId]);
|
|
$client = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
return $client ?: null;
|
|
}
|
|
|
|
public function verifyClientSecret(array $client, string $candidate): bool
|
|
{
|
|
if (empty($client['client_secret_hash'])) {
|
|
return $candidate === '';
|
|
}
|
|
|
|
return password_verify($candidate, $client['client_secret_hash']);
|
|
}
|
|
|
|
public function isRedirectUriAllowed(array $client, string $redirectUri): bool
|
|
{
|
|
$allowed = array_filter(array_map('trim', preg_split('/[\s,]+/', (string) ($client['redirect_uris'] ?? ''))));
|
|
if (empty($allowed)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($allowed as $prefix) {
|
|
if (stripos($redirectUri, $prefix) === 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function isScopeAllowed(array $client, ?string $requestedScope): bool
|
|
{
|
|
$requestedScope = trim((string) $requestedScope);
|
|
if ($requestedScope === '') {
|
|
return true;
|
|
}
|
|
|
|
$allowedScopes = array_filter(array_map('trim', explode(' ', (string) ($client['allowed_scopes'] ?? ''))));
|
|
|
|
if (empty($allowedScopes)) {
|
|
return true;
|
|
}
|
|
|
|
foreach (explode(' ', $requestedScope) as $scope) {
|
|
if (!in_array($scope, $allowedScopes, true)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function issueAuthorizationCode(string $clientId, int $personId, string $redirectUri, ?string $scope = null): array
|
|
{
|
|
$code = $this->generateToken(32);
|
|
$codeHash = $this->hashToken($code);
|
|
$expiresAt = time() + self::AUTH_CODE_TTL;
|
|
|
|
$sql = "INSERT INTO dsp_oauth_auth_codes
|
|
(code_hash, client_id, person_id, scope, redirect_uri, expires_at, created_at)
|
|
VALUES (:code_hash, :client_id, :person_id, :scope, :redirect_uri, FROM_UNIXTIME(:expires_at), NOW())";
|
|
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([
|
|
':code_hash' => $codeHash,
|
|
':client_id' => $clientId,
|
|
':person_id' => $personId,
|
|
':scope' => $scope,
|
|
':redirect_uri' => $redirectUri,
|
|
':expires_at' => $expiresAt,
|
|
]);
|
|
|
|
return [
|
|
'code' => $code,
|
|
'expires_at' => $expiresAt,
|
|
];
|
|
}
|
|
|
|
public function consumeAuthorizationCode(string $code, string $clientId): ?array
|
|
{
|
|
$codeHash = $this->hashToken($code);
|
|
|
|
$sql = "SELECT code_hash, client_id, person_id, scope, redirect_uri, UNIX_TIMESTAMP(expires_at) AS expires_at
|
|
FROM dsp_oauth_auth_codes
|
|
WHERE code_hash = :code_hash";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([':code_hash' => $codeHash]);
|
|
$record = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$record) {
|
|
return null;
|
|
}
|
|
|
|
// Delete regardless of outcome
|
|
$deleteStmt = $this->pdo->prepare("DELETE FROM dsp_oauth_auth_codes WHERE code_hash = :code_hash");
|
|
$deleteStmt->execute([':code_hash' => $codeHash]);
|
|
|
|
if ((int) $record['expires_at'] < time()) {
|
|
return null;
|
|
}
|
|
|
|
if ($record['client_id'] !== $clientId) {
|
|
return null;
|
|
}
|
|
|
|
return $record;
|
|
}
|
|
|
|
public function issueTokens(string $clientId, int $personId, ?string $scope = null, bool $includeRefresh = true): array
|
|
{
|
|
$accessToken = $this->generateToken(43);
|
|
$accessHash = $this->hashToken($accessToken);
|
|
$accessExpiresAt = time() + self::ACCESS_TOKEN_TTL;
|
|
|
|
$refreshToken = null;
|
|
$refreshHash = null;
|
|
$refreshExpiresAt = null;
|
|
|
|
if ($includeRefresh) {
|
|
$refreshToken = $this->generateToken(43);
|
|
$refreshHash = $this->hashToken($refreshToken);
|
|
$refreshExpiresAt = time() + self::REFRESH_TOKEN_TTL;
|
|
}
|
|
|
|
$sql = "INSERT INTO dsp_oauth_access_tokens
|
|
(token_hash, client_id, person_id, scope, expires_at, refresh_token_hash, refresh_expires_at, created_at)
|
|
VALUES (:token_hash, :client_id, :person_id, :scope, FROM_UNIXTIME(:expires_at),
|
|
:refresh_hash, " . ($refreshExpiresAt ? "FROM_UNIXTIME(:refresh_expires_at)" : "NULL") . ", NOW())";
|
|
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([
|
|
':token_hash' => $accessHash,
|
|
':client_id' => $clientId,
|
|
':person_id' => $personId,
|
|
':scope' => $scope,
|
|
':expires_at' => $accessExpiresAt,
|
|
':refresh_hash' => $refreshHash,
|
|
':refresh_expires_at' => $refreshExpiresAt,
|
|
]);
|
|
|
|
return [
|
|
'access_token' => $accessToken,
|
|
'access_expires_at' => $accessExpiresAt,
|
|
'refresh_token' => $refreshToken,
|
|
'refresh_expires_at' => $refreshExpiresAt,
|
|
'token_type' => 'Bearer',
|
|
'scope' => $scope,
|
|
];
|
|
}
|
|
|
|
public function exchangeRefreshToken(string $clientId, string $refreshToken): ?array
|
|
{
|
|
$refreshHash = $this->hashToken($refreshToken);
|
|
|
|
$sql = "SELECT token_hash, client_id, person_id, scope, UNIX_TIMESTAMP(refresh_expires_at) AS refresh_expires_at
|
|
FROM dsp_oauth_access_tokens
|
|
WHERE refresh_token_hash = :refresh_hash AND is_revoked = 0";
|
|
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([':refresh_hash' => $refreshHash]);
|
|
$record = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$record) {
|
|
return null;
|
|
}
|
|
|
|
if ($record['client_id'] !== $clientId) {
|
|
return null;
|
|
}
|
|
|
|
if (!empty($record['refresh_expires_at']) && (int) $record['refresh_expires_at'] < time()) {
|
|
$this->revokeTokenByHash($record['token_hash']);
|
|
return null;
|
|
}
|
|
|
|
// Revoke old access token
|
|
$this->revokeTokenByHash($record['token_hash']);
|
|
|
|
// Issue new pair
|
|
return $this->issueTokens($clientId, (int) $record['person_id'], $record['scope'], true);
|
|
}
|
|
|
|
public function getAccessToken(string $token): ?array
|
|
{
|
|
$hash = $this->hashToken($token);
|
|
$sql = "SELECT token_hash, client_id, person_id, scope,
|
|
UNIX_TIMESTAMP(expires_at) AS expires_at,
|
|
refresh_token_hash,
|
|
UNIX_TIMESTAMP(refresh_expires_at) AS refresh_expires_at
|
|
FROM dsp_oauth_access_tokens
|
|
WHERE token_hash = :hash AND is_revoked = 0";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([':hash' => $hash]);
|
|
$record = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$record) {
|
|
return null;
|
|
}
|
|
|
|
if ((int) $record['expires_at'] < time()) {
|
|
$this->revokeTokenByHash($record['token_hash']);
|
|
return null;
|
|
}
|
|
|
|
return $record;
|
|
}
|
|
|
|
public function revokeTokenByHash(string $hash): void
|
|
{
|
|
$sql = "UPDATE dsp_oauth_access_tokens SET is_revoked = 1, revoked_at = NOW()
|
|
WHERE token_hash = :hash";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([':hash' => $hash]);
|
|
}
|
|
|
|
public function recordTokenUsage(string $hash): void
|
|
{
|
|
$sql = "UPDATE dsp_oauth_access_tokens SET last_used_at = NOW() WHERE token_hash = :hash";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([':hash' => $hash]);
|
|
}
|
|
|
|
private function generateToken(int $length): string
|
|
{
|
|
return rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '=');
|
|
}
|
|
|
|
private function hashToken(string $token): string
|
|
{
|
|
return hash('sha256', $token);
|
|
}
|
|
}
|