934 lines
26 KiB
PHP
934 lines
26 KiB
PHP
<?php
|
|
/**
|
|
* OAuth 2.0 Token model for ActivityPub C2S.
|
|
*
|
|
* @package Activitypub
|
|
*/
|
|
|
|
namespace Activitypub\OAuth;
|
|
|
|
use Activitypub\Collection\Actors;
|
|
|
|
/**
|
|
* Token class for managing OAuth 2.0 access and refresh tokens.
|
|
*
|
|
* Tokens are stored as user metadata with hashed values for security.
|
|
* This follows the IndieAuth pattern for efficient token management.
|
|
*/
|
|
class Token {
|
|
/**
|
|
* User meta key prefix for OAuth tokens.
|
|
*/
|
|
const META_PREFIX = '_activitypub_oauth_token_';
|
|
|
|
/**
|
|
* User meta key prefix for refresh token index (maps refresh hash to access hash).
|
|
*/
|
|
const REFRESH_INDEX_PREFIX = '_activitypub_oauth_refresh_';
|
|
|
|
/**
|
|
* Post meta key on OAuth client posts to track users with tokens.
|
|
*
|
|
* Stored as non-unique post meta (one row per user) on ap_oauth_client posts,
|
|
* following the same pattern as _activitypub_following on ap_actor posts.
|
|
*/
|
|
const USER_META_KEY = '_activitypub_user_id';
|
|
|
|
/**
|
|
* Maximum number of active tokens per user.
|
|
*
|
|
* When exceeded, the oldest tokens are revoked automatically.
|
|
*
|
|
* @since 8.1.0
|
|
*/
|
|
const MAX_TOKENS_PER_USER = 50;
|
|
|
|
/**
|
|
* Default access token expiration in seconds (1 hour).
|
|
*/
|
|
const DEFAULT_EXPIRATION = 3600;
|
|
|
|
/**
|
|
* Refresh token expiration in seconds (30 days).
|
|
*/
|
|
const REFRESH_EXPIRATION = 2592000;
|
|
|
|
/**
|
|
* The token data array.
|
|
*
|
|
* @var array
|
|
*/
|
|
private $data;
|
|
|
|
/**
|
|
* The user ID this token belongs to.
|
|
*
|
|
* @var int
|
|
*/
|
|
private $user_id;
|
|
|
|
/**
|
|
* The token key (hash) used for storage.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $token_key;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param int $user_id The user ID.
|
|
* @param string $token_key The token key (hash).
|
|
* @param array $data The token data.
|
|
*/
|
|
public function __construct( $user_id, $token_key, $data ) {
|
|
$this->user_id = $user_id;
|
|
$this->token_key = $token_key;
|
|
$this->data = $data;
|
|
}
|
|
|
|
/**
|
|
* Create a new access token.
|
|
*
|
|
* @param int $user_id WordPress user ID.
|
|
* @param string $client_id OAuth client ID.
|
|
* @param array $scopes Granted scopes.
|
|
* @param int $expires Expiration time in seconds.
|
|
* @return array|\WP_Error Token data or error.
|
|
*/
|
|
public static function create( $user_id, $client_id, $scopes, $expires = self::DEFAULT_EXPIRATION ) {
|
|
// Generate tokens.
|
|
$access_token = self::generate_token();
|
|
$refresh_token = self::generate_token();
|
|
|
|
// Calculate expirations.
|
|
$access_expires_at = time() + $expires;
|
|
$refresh_expires_at = time() + self::REFRESH_EXPIRATION;
|
|
|
|
// Create token data.
|
|
$token_data = array(
|
|
'access_token_hash' => self::hash_token( $access_token ),
|
|
'refresh_token_hash' => self::hash_token( $refresh_token ),
|
|
'client_id' => $client_id,
|
|
'scopes' => Scope::validate( $scopes ),
|
|
'expires_at' => $access_expires_at,
|
|
'refresh_expires_at' => $refresh_expires_at,
|
|
'created_at' => time(),
|
|
'last_used_at' => null,
|
|
);
|
|
|
|
// Store in user meta with access token hash as key.
|
|
$access_hash = self::hash_token( $access_token );
|
|
$meta_key = self::META_PREFIX . $access_hash;
|
|
$result = \update_user_meta( $user_id, $meta_key, $token_data );
|
|
|
|
if ( false === $result ) {
|
|
return new \WP_Error(
|
|
'activitypub_token_storage_failed',
|
|
\__( 'Failed to store access token.', 'activitypub' ),
|
|
array( 'status' => 500 )
|
|
);
|
|
}
|
|
|
|
// Store refresh token index for O(1) lookup during refresh.
|
|
$refresh_index_key = self::REFRESH_INDEX_PREFIX . self::hash_token( $refresh_token );
|
|
\update_user_meta( $user_id, $refresh_index_key, $access_hash );
|
|
|
|
// Track user on the client post for cleanup.
|
|
self::track_user( $user_id, $client_id );
|
|
|
|
// Enforce per-user token limit by revoking the oldest tokens.
|
|
self::enforce_token_limit( $user_id );
|
|
|
|
/*
|
|
* Get the actor URI for the 'me' parameter (IndieAuth convention).
|
|
* Fall back to blog actor when user actors are disabled.
|
|
*/
|
|
$actor = Actors::get_by_id( $user_id );
|
|
if ( \is_wp_error( $actor ) ) {
|
|
$actor = Actors::get_by_id( Actors::BLOG_USER_ID );
|
|
}
|
|
$me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
|
|
|
|
return array(
|
|
'access_token' => $access_token,
|
|
'token_type' => 'Bearer',
|
|
'expires_in' => $expires,
|
|
'refresh_token' => $refresh_token,
|
|
'scope' => Scope::to_string( $token_data['scopes'] ),
|
|
'me' => $me,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validate an access token.
|
|
*
|
|
* @param string $token The access token to validate.
|
|
* @return Token|\WP_Error The token object or error.
|
|
*/
|
|
public static function validate( $token ) {
|
|
global $wpdb;
|
|
|
|
$token_hash = self::hash_token( $token );
|
|
$meta_key = self::META_PREFIX . $token_hash;
|
|
|
|
// Direct DB lookup by meta_key - O(1) instead of O(n) users.
|
|
$user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$wpdb->prepare(
|
|
"SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
|
|
$meta_key
|
|
)
|
|
);
|
|
|
|
if ( empty( $user_id ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_token',
|
|
\__( 'Invalid access token.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
$token_data = \get_user_meta( (int) $user_id, $meta_key, true );
|
|
|
|
if ( empty( $token_data ) || ! is_array( $token_data ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_token',
|
|
\__( 'Invalid access token.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
// Verify hash matches.
|
|
if ( ! isset( $token_data['access_token_hash'] ) ||
|
|
! hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_token',
|
|
\__( 'Invalid access token.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
// Check expiration.
|
|
if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < time() ) {
|
|
return new \WP_Error(
|
|
'activitypub_token_expired',
|
|
\__( 'Access token has expired.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
// Throttle last_used_at writes to avoid a DB write on every request.
|
|
$last_used = $token_data['last_used_at'] ?? 0;
|
|
if ( empty( $last_used ) || ( time() - $last_used ) > 5 * MINUTE_IN_SECONDS ) {
|
|
$token_data['last_used_at'] = time();
|
|
\update_user_meta( (int) $user_id, $meta_key, $token_data );
|
|
}
|
|
|
|
return new self( (int) $user_id, $token_hash, $token_data );
|
|
}
|
|
|
|
/**
|
|
* Refresh an access token using a refresh token.
|
|
*
|
|
* @param string $refresh_token The refresh token.
|
|
* @param string $client_id The client ID (must match original).
|
|
* @return array|\WP_Error New token data or error.
|
|
*/
|
|
public static function refresh( $refresh_token, $client_id ) {
|
|
global $wpdb;
|
|
|
|
$refresh_hash = self::hash_token( $refresh_token );
|
|
$refresh_index_key = self::REFRESH_INDEX_PREFIX . $refresh_hash;
|
|
|
|
// Direct DB lookup by refresh token index - O(1) instead of O(n) users.
|
|
$user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$wpdb->prepare(
|
|
"SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
|
|
$refresh_index_key
|
|
)
|
|
);
|
|
|
|
if ( empty( $user_id ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_refresh_token',
|
|
\__( 'Invalid refresh token.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
$user_id = (int) $user_id;
|
|
|
|
// Get the access token hash from the index.
|
|
$access_hash = \get_user_meta( $user_id, $refresh_index_key, true );
|
|
if ( empty( $access_hash ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_refresh_token',
|
|
\__( 'Invalid refresh token.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
// Get the full token data.
|
|
$meta_key = self::META_PREFIX . $access_hash;
|
|
$token_data = \get_user_meta( $user_id, $meta_key, true );
|
|
|
|
if ( empty( $token_data ) || ! is_array( $token_data ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_refresh_token',
|
|
\__( 'Invalid refresh token.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
// Verify refresh token hash matches.
|
|
if ( ! isset( $token_data['refresh_token_hash'] ) ||
|
|
! hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_refresh_token',
|
|
\__( 'Invalid refresh token.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
// Verify client ID matches.
|
|
if ( $token_data['client_id'] !== $client_id ) {
|
|
return new \WP_Error(
|
|
'activitypub_client_mismatch',
|
|
\__( 'Client ID does not match.', 'activitypub' ),
|
|
array( 'status' => 400 )
|
|
);
|
|
}
|
|
|
|
// Check refresh token expiration.
|
|
if ( isset( $token_data['refresh_expires_at'] ) &&
|
|
$token_data['refresh_expires_at'] < time() ) {
|
|
// Delete the expired token and index.
|
|
\delete_user_meta( $user_id, $meta_key );
|
|
\delete_user_meta( $user_id, $refresh_index_key );
|
|
|
|
return new \WP_Error(
|
|
'activitypub_refresh_token_expired',
|
|
\__( 'Refresh token has expired.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
// Delete the old token and index.
|
|
\delete_user_meta( $user_id, $meta_key );
|
|
\delete_user_meta( $user_id, $refresh_index_key );
|
|
|
|
// Create a new token.
|
|
return self::create( $user_id, $client_id, $token_data['scopes'] );
|
|
}
|
|
|
|
/**
|
|
* Revoke a token.
|
|
*
|
|
* When `$caller_user_id` or `$caller_client_id` is provided, the token
|
|
* is only deleted if it was issued to that user or that client, per
|
|
* RFC 7009 Section 2.1. A mismatch is treated as a successful no-op so
|
|
* the caller cannot probe for token existence belonging to others.
|
|
*
|
|
* @since 8.2.0 The `$caller_user_id` and `$caller_client_id` parameters were added.
|
|
*
|
|
* @param string $token The token to revoke (access or refresh).
|
|
* @param int|null $caller_user_id Optional. User ID of the caller. Null disables the user check.
|
|
* @param string|null $caller_client_id Optional. OAuth client ID of the caller. Null disables the client check.
|
|
* @return bool True on success (always returns true per RFC 7009).
|
|
*/
|
|
public static function revoke( $token, $caller_user_id = null, $caller_client_id = null ) {
|
|
global $wpdb;
|
|
|
|
$token_hash = self::hash_token( $token );
|
|
|
|
// Try as access token first (O(1) lookup).
|
|
$access_meta_key = self::META_PREFIX . $token_hash;
|
|
$user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$wpdb->prepare(
|
|
"SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
|
|
$access_meta_key
|
|
)
|
|
);
|
|
|
|
if ( $user_id ) {
|
|
$user_id = (int) $user_id;
|
|
$token_data = \get_user_meta( $user_id, $access_meta_key, true );
|
|
$client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
|
|
|
|
if ( ! self::caller_owns_token( $user_id, $client_id, $caller_user_id, $caller_client_id ) ) {
|
|
return true;
|
|
}
|
|
|
|
// Delete the token.
|
|
\delete_user_meta( $user_id, $access_meta_key );
|
|
|
|
// Also delete the refresh token index if it exists.
|
|
if ( is_array( $token_data ) && isset( $token_data['refresh_token_hash'] ) ) {
|
|
$refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'];
|
|
\delete_user_meta( $user_id, $refresh_index_key );
|
|
}
|
|
|
|
self::maybe_untrack_user( $user_id, $client_id );
|
|
return true;
|
|
}
|
|
|
|
// Try as refresh token (O(1) lookup via index).
|
|
$refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_hash;
|
|
$user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$wpdb->prepare(
|
|
"SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
|
|
$refresh_index_key
|
|
)
|
|
);
|
|
|
|
if ( $user_id ) {
|
|
$user_id = (int) $user_id;
|
|
$access_hash = \get_user_meta( $user_id, $refresh_index_key, true );
|
|
$client_id = '';
|
|
|
|
if ( $access_hash ) {
|
|
$token_data = \get_user_meta( $user_id, self::META_PREFIX . $access_hash, true );
|
|
$client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
|
|
}
|
|
|
|
if ( ! self::caller_owns_token( $user_id, $client_id, $caller_user_id, $caller_client_id ) ) {
|
|
return true;
|
|
}
|
|
|
|
if ( $access_hash ) {
|
|
\delete_user_meta( $user_id, self::META_PREFIX . $access_hash );
|
|
}
|
|
\delete_user_meta( $user_id, $refresh_index_key );
|
|
|
|
self::maybe_untrack_user( $user_id, $client_id );
|
|
return true;
|
|
}
|
|
|
|
// Token doesn't exist or already revoked - that's fine per RFC 7009.
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Decide whether a caller is permitted to revoke a specific token.
|
|
*
|
|
* A null caller user and null caller client disable the check entirely,
|
|
* preserving the pre-RFC-7009-enforcement behavior for internal callers
|
|
* that already know they have authority (admin unlink, uninstall, etc.).
|
|
*
|
|
* When either caller parameter is provided, the token is considered
|
|
* owned if it matches the caller user OR the caller client. Matching
|
|
* client alone is enough to let an OAuth client clean up any token it
|
|
* issued, regardless of which user granted consent.
|
|
*
|
|
* @param int $token_user_id User ID the token was issued to.
|
|
* @param string $token_client_id OAuth client ID the token was issued to.
|
|
* @param int|null $caller_user_id Caller user ID, or null to skip the user check.
|
|
* @param string|null $caller_client_id Caller client ID, or null to skip the client check.
|
|
* @return bool True if the caller may revoke, false otherwise.
|
|
*/
|
|
private static function caller_owns_token( $token_user_id, $token_client_id, $caller_user_id, $caller_client_id ) {
|
|
if ( null === $caller_user_id && null === $caller_client_id ) {
|
|
return true;
|
|
}
|
|
|
|
if ( null !== $caller_user_id && $token_user_id === $caller_user_id ) {
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
* Require a real client_id on the token. An empty string on both
|
|
* sides would otherwise match and let an un-attributed token be
|
|
* revoked by any caller presenting an empty client claim.
|
|
*/
|
|
if ( null !== $caller_client_id && '' !== $token_client_id && $token_client_id === $caller_client_id ) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Untrack user from a client if they have no remaining tokens for that client.
|
|
*
|
|
* @param int $user_id The user ID.
|
|
* @param string $client_id The OAuth client ID.
|
|
*/
|
|
private static function maybe_untrack_user( $user_id, $client_id ) {
|
|
if ( empty( $client_id ) ) {
|
|
return;
|
|
}
|
|
|
|
// Check if user has any remaining tokens for this client.
|
|
$tokens = self::get_all_for_user( $user_id );
|
|
foreach ( $tokens as $token_data ) {
|
|
if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
|
|
return; // Still has tokens for this client.
|
|
}
|
|
}
|
|
|
|
self::untrack_user( $user_id, $client_id );
|
|
}
|
|
|
|
/**
|
|
* Revoke all tokens for a user.
|
|
*
|
|
* @param int $user_id WordPress user ID.
|
|
* @return int Number of tokens revoked.
|
|
*/
|
|
public static function revoke_all_for_user( $user_id ) {
|
|
$all_meta = \get_user_meta( $user_id );
|
|
$count = 0;
|
|
$client_ids = array();
|
|
|
|
foreach ( $all_meta as $meta_key => $meta_values ) {
|
|
// Delete token entries and collect client IDs.
|
|
if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) {
|
|
$token_data = \maybe_unserialize( $meta_values[0] );
|
|
if ( is_array( $token_data ) && ! empty( $token_data['client_id'] ) ) {
|
|
$client_ids[] = $token_data['client_id'];
|
|
}
|
|
\delete_user_meta( $user_id, $meta_key );
|
|
++$count;
|
|
}
|
|
// Delete refresh token indices.
|
|
if ( 0 === strpos( $meta_key, self::REFRESH_INDEX_PREFIX ) ) {
|
|
\delete_user_meta( $user_id, $meta_key );
|
|
}
|
|
}
|
|
|
|
// Remove user from all client tracking.
|
|
foreach ( array_unique( $client_ids ) as $client_id ) {
|
|
self::untrack_user( $user_id, $client_id );
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Revoke all tokens for all users.
|
|
*
|
|
* Used during plugin uninstall to clean up all OAuth token data.
|
|
*
|
|
* @return int Number of tokens revoked.
|
|
*/
|
|
public static function revoke_all() {
|
|
$user_ids = self::get_all_tracked_users();
|
|
$count = 0;
|
|
|
|
foreach ( $user_ids as $user_id ) {
|
|
$count += self::revoke_all_for_user( $user_id );
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Revoke all tokens for a specific client.
|
|
*
|
|
* @param string $client_id OAuth client ID.
|
|
* @return int Number of tokens revoked.
|
|
*/
|
|
public static function revoke_for_client( $client_id ) {
|
|
$user_ids = self::get_tracked_users( $client_id );
|
|
$count = 0;
|
|
|
|
foreach ( $user_ids as $user_id ) {
|
|
$all_meta = \get_user_meta( $user_id );
|
|
|
|
foreach ( $all_meta as $meta_key => $meta_values ) {
|
|
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
|
continue;
|
|
}
|
|
|
|
$token_data = \maybe_unserialize( $meta_values[0] );
|
|
|
|
if ( ! is_array( $token_data ) ) {
|
|
continue;
|
|
}
|
|
|
|
// Only revoke tokens belonging to this client.
|
|
if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
|
|
\delete_user_meta( $user_id, $meta_key );
|
|
// Also delete refresh token index.
|
|
if ( isset( $token_data['refresh_token_hash'] ) ) {
|
|
\delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
|
|
}
|
|
++$count;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove all user tracking for this client.
|
|
self::untrack_all_users( $client_id );
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Get all tokens for a user.
|
|
*
|
|
* @param int $user_id WordPress user ID.
|
|
* @return array Array of token data.
|
|
*/
|
|
public static function get_all_for_user( $user_id ) {
|
|
$all_meta = \get_user_meta( $user_id );
|
|
$tokens = array();
|
|
|
|
foreach ( $all_meta as $meta_key => $meta_values ) {
|
|
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
|
continue;
|
|
}
|
|
|
|
$token_data = \maybe_unserialize( $meta_values[0] );
|
|
|
|
if ( is_array( $token_data ) ) {
|
|
// Don't expose hashes.
|
|
unset( $token_data['access_token_hash'], $token_data['refresh_token_hash'] );
|
|
$token_data['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query, just array key.
|
|
$tokens[] = $token_data;
|
|
}
|
|
}
|
|
|
|
return $tokens;
|
|
}
|
|
|
|
/**
|
|
* Check if token has a specific scope.
|
|
*
|
|
* @param string $scope The scope to check.
|
|
* @return bool True if token has scope.
|
|
*/
|
|
public function has_scope( $scope ) {
|
|
$scopes = $this->get_scopes();
|
|
return Scope::contains( $scopes, $scope );
|
|
}
|
|
|
|
/**
|
|
* Get the user ID associated with this token.
|
|
*
|
|
* @return int The WordPress user ID.
|
|
*/
|
|
public function get_user_id() {
|
|
return $this->user_id;
|
|
}
|
|
|
|
/**
|
|
* Get the client ID associated with this token.
|
|
*
|
|
* @return string The OAuth client ID.
|
|
*/
|
|
public function get_client_id() {
|
|
return $this->data['client_id'] ?? '';
|
|
}
|
|
|
|
/**
|
|
* Get the scopes for this token.
|
|
*
|
|
* @return array The granted scopes.
|
|
*/
|
|
public function get_scopes() {
|
|
return $this->data['scopes'] ?? array();
|
|
}
|
|
|
|
/**
|
|
* Get the expiration timestamp.
|
|
*
|
|
* @return int Unix timestamp.
|
|
*/
|
|
public function get_expires_at() {
|
|
return $this->data['expires_at'] ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Check if the token is expired.
|
|
*
|
|
* @return bool True if expired.
|
|
*/
|
|
public function is_expired() {
|
|
return $this->get_expires_at() < time();
|
|
}
|
|
|
|
/**
|
|
* Get the creation timestamp.
|
|
*
|
|
* @return int Unix timestamp.
|
|
*/
|
|
public function get_created_at() {
|
|
return $this->data['created_at'] ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Get the last used timestamp.
|
|
*
|
|
* @return int|null Unix timestamp or null if never used.
|
|
*/
|
|
public function get_last_used_at() {
|
|
return $this->data['last_used_at'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Generate a cryptographically secure random token.
|
|
*
|
|
* @param int $length The length of the token in bytes (default 32 = 64 hex chars).
|
|
* @return string The random token as a hex string.
|
|
*/
|
|
public static function generate_token( $length = 32 ) {
|
|
return bin2hex( random_bytes( $length ) );
|
|
}
|
|
|
|
/**
|
|
* Hash a token for secure storage.
|
|
*
|
|
* @param string $token The token to hash.
|
|
* @return string The SHA-256 hash.
|
|
*/
|
|
public static function hash_token( $token ) {
|
|
return hash( 'sha256', $token );
|
|
}
|
|
|
|
/**
|
|
* Track a user as having tokens for a client.
|
|
*
|
|
* Stores user ID as non-unique post meta on the client post,
|
|
* following the same pattern as _activitypub_following on ap_actor posts.
|
|
*
|
|
* @param int $user_id The user ID.
|
|
* @param string $client_id The OAuth client ID.
|
|
*/
|
|
private static function track_user( $user_id, $client_id ) {
|
|
$client = Client::get( $client_id );
|
|
|
|
if ( \is_wp_error( $client ) ) {
|
|
return;
|
|
}
|
|
|
|
$post_id = $client->get_post_id();
|
|
$existing = \get_post_meta( $post_id, self::USER_META_KEY, false );
|
|
|
|
if ( ! in_array( $user_id, array_map( 'intval', $existing ), true ) ) {
|
|
\add_post_meta( $post_id, self::USER_META_KEY, $user_id );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enforce per-user token limit by revoking oldest tokens.
|
|
*
|
|
* @since 8.1.0
|
|
*
|
|
* @param int $user_id The user ID.
|
|
*/
|
|
private static function enforce_token_limit( $user_id ) {
|
|
$all_meta = \get_user_meta( $user_id );
|
|
$tokens = array();
|
|
|
|
foreach ( $all_meta as $meta_key => $meta_values ) {
|
|
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
|
continue;
|
|
}
|
|
|
|
$token_data = \maybe_unserialize( $meta_values[0] );
|
|
|
|
if ( is_array( $token_data ) ) {
|
|
$tokens[ $meta_key ] = $token_data;
|
|
}
|
|
}
|
|
|
|
if ( count( $tokens ) <= self::MAX_TOKENS_PER_USER ) {
|
|
return;
|
|
}
|
|
|
|
// Sort by created_at ascending (oldest first).
|
|
uasort(
|
|
$tokens,
|
|
function ( $a, $b ) {
|
|
return ( $a['created_at'] ?? 0 ) - ( $b['created_at'] ?? 0 );
|
|
}
|
|
);
|
|
|
|
$to_remove = count( $tokens ) - self::MAX_TOKENS_PER_USER;
|
|
|
|
foreach ( $tokens as $meta_key => $token_data ) {
|
|
if ( $to_remove <= 0 ) {
|
|
break;
|
|
}
|
|
|
|
\delete_user_meta( $user_id, $meta_key );
|
|
|
|
// Also delete the refresh token index.
|
|
if ( isset( $token_data['refresh_token_hash'] ) ) {
|
|
\delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
|
|
}
|
|
|
|
--$to_remove;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Untrack a user from a specific client.
|
|
*
|
|
* @param int $user_id The user ID.
|
|
* @param string $client_id The OAuth client ID.
|
|
*/
|
|
private static function untrack_user( $user_id, $client_id ) {
|
|
$client = Client::get( $client_id );
|
|
|
|
if ( \is_wp_error( $client ) ) {
|
|
return;
|
|
}
|
|
|
|
\delete_post_meta( $client->get_post_id(), self::USER_META_KEY, $user_id );
|
|
}
|
|
|
|
/**
|
|
* Untrack all users from a specific client.
|
|
*
|
|
* @param string $client_id The OAuth client ID.
|
|
*/
|
|
private static function untrack_all_users( $client_id ) {
|
|
$client = Client::get( $client_id );
|
|
|
|
if ( \is_wp_error( $client ) ) {
|
|
return;
|
|
}
|
|
|
|
\delete_post_meta( $client->get_post_id(), self::USER_META_KEY );
|
|
}
|
|
|
|
/**
|
|
* Get tracked users for a specific client.
|
|
*
|
|
* @param string $client_id The OAuth client ID.
|
|
* @return array User IDs.
|
|
*/
|
|
private static function get_tracked_users( $client_id ) {
|
|
$client = Client::get( $client_id );
|
|
|
|
if ( \is_wp_error( $client ) ) {
|
|
return array();
|
|
}
|
|
|
|
$user_ids = \get_post_meta( $client->get_post_id(), self::USER_META_KEY, false );
|
|
|
|
return array_map( 'intval', $user_ids );
|
|
}
|
|
|
|
/**
|
|
* Get all user IDs with tokens across all clients.
|
|
*
|
|
* @return array Unique user IDs.
|
|
*/
|
|
private static function get_all_tracked_users() {
|
|
global $wpdb;
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$user_ids = $wpdb->get_col(
|
|
$wpdb->prepare(
|
|
"SELECT DISTINCT pm.meta_value FROM $wpdb->postmeta pm
|
|
INNER JOIN $wpdb->posts p ON pm.post_id = p.ID
|
|
WHERE p.post_type = %s AND pm.meta_key = %s",
|
|
Client::POST_TYPE,
|
|
self::USER_META_KEY
|
|
)
|
|
);
|
|
|
|
return array_map( 'intval', $user_ids );
|
|
}
|
|
|
|
/**
|
|
* Clean up expired tokens.
|
|
*
|
|
* Should be called periodically via cron.
|
|
*
|
|
* @return int Number of tokens deleted.
|
|
*/
|
|
public static function cleanup_expired() {
|
|
$user_ids = self::get_all_tracked_users();
|
|
$count = 0;
|
|
|
|
foreach ( $user_ids as $user_id ) {
|
|
$all_meta = \get_user_meta( $user_id );
|
|
$client_ids = array();
|
|
|
|
foreach ( $all_meta as $meta_key => $meta_values ) {
|
|
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
|
continue;
|
|
}
|
|
|
|
$token_data = \maybe_unserialize( $meta_values[0] );
|
|
|
|
if ( ! is_array( $token_data ) ) {
|
|
\delete_user_meta( $user_id, $meta_key );
|
|
++$count;
|
|
continue;
|
|
}
|
|
|
|
// Check if both access and refresh tokens are expired.
|
|
$access_expired = isset( $token_data['expires_at'] ) &&
|
|
$token_data['expires_at'] < time() - DAY_IN_SECONDS;
|
|
$refresh_expired = isset( $token_data['refresh_expires_at'] ) &&
|
|
$token_data['refresh_expires_at'] < time();
|
|
|
|
if ( $access_expired && $refresh_expired ) {
|
|
\delete_user_meta( $user_id, $meta_key );
|
|
// Also delete refresh token index.
|
|
if ( isset( $token_data['refresh_token_hash'] ) ) {
|
|
\delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
|
|
}
|
|
++$count;
|
|
|
|
if ( ! empty( $token_data['client_id'] ) ) {
|
|
$client_ids[] = $token_data['client_id'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Untrack user from clients where all tokens were removed.
|
|
foreach ( array_unique( $client_ids ) as $client_id ) {
|
|
self::maybe_untrack_user( $user_id, $client_id );
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Introspect a token (RFC 7662).
|
|
*
|
|
* @param string $token The token to introspect.
|
|
* @return array Token introspection response.
|
|
*/
|
|
public static function introspect( $token ) {
|
|
$validated = self::validate( $token );
|
|
|
|
if ( \is_wp_error( $validated ) ) {
|
|
// Return inactive for invalid/expired tokens.
|
|
return array( 'active' => false );
|
|
}
|
|
|
|
$user_id = $validated->get_user_id();
|
|
$user = \get_userdata( $user_id );
|
|
|
|
/*
|
|
* Get the actor URI for the 'me' parameter (IndieAuth convention).
|
|
* Fall back to blog actor when user actors are disabled.
|
|
*/
|
|
$actor = Actors::get_by_id( $user_id );
|
|
if ( \is_wp_error( $actor ) ) {
|
|
$actor = Actors::get_by_id( Actors::BLOG_USER_ID );
|
|
}
|
|
$me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
|
|
|
|
return array(
|
|
'active' => true,
|
|
'scope' => Scope::to_string( $validated->get_scopes() ),
|
|
'client_id' => $validated->get_client_id(),
|
|
'username' => $user ? $user->user_login : null,
|
|
'token_type' => 'Bearer',
|
|
'exp' => $validated->get_expires_at(),
|
|
'iat' => $validated->get_created_at(),
|
|
'sub' => (string) $user_id,
|
|
'me' => $me,
|
|
);
|
|
}
|
|
}
|