You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

575 lines
15 KiB
PHP

<?php
declare(strict_types=1);
/**
* This file is part of CodeIgniter Shield.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Shield\Models;
use CodeIgniter\I18n\Time;
use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens;
use CodeIgniter\Shield\Authentication\Authenticators\HmacSha256;
use CodeIgniter\Shield\Authentication\Authenticators\Session;
use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter;
use CodeIgniter\Shield\Authentication\Passwords;
use CodeIgniter\Shield\Entities\AccessToken;
use CodeIgniter\Shield\Entities\User;
use CodeIgniter\Shield\Entities\UserIdentity;
use CodeIgniter\Shield\Exceptions\LogicException;
use CodeIgniter\Shield\Exceptions\ValidationException;
use Exception;
use Faker\Generator;
use ReflectionException;
class UserIdentityModel extends BaseModel
{
protected $primaryKey = 'id';
protected $returnType = UserIdentity::class;
protected $useSoftDeletes = false;
protected $allowedFields = [
'user_id',
'type',
'name',
'secret',
'secret2',
'expires',
'extra',
'force_reset',
'last_used_at',
];
protected $useTimestamps = true;
protected function initialize(): void
{
parent::initialize();
$this->table = $this->tables['identities'];
}
/**
* Inserts a record
*
* @param array|object $data
*
* @throws DatabaseException
*/
public function create($data): void
{
$this->disableDBDebug();
$return = $this->insert($data);
$this->checkQueryReturn($return);
}
/**
* Creates a new identity for this user with an email/password
* combination.
*
* @phpstan-param array{email: string, password: string} $credentials
*/
public function createEmailIdentity(User $user, array $credentials): void
{
$this->checkUserId($user);
/** @var Passwords $passwords */
$passwords = service('passwords');
$return = $this->insert([
'user_id' => $user->id,
'type' => Session::ID_TYPE_EMAIL_PASSWORD,
'secret' => $credentials['email'],
'secret2' => $passwords->hash($credentials['password']),
]);
$this->checkQueryReturn($return);
}
private function checkUserId(User $user): void
{
if ($user->id === null) {
throw new LogicException(
'"$user->id" is null. You should not use the incomplete User object.'
);
}
}
/**
* Create an identity with 6 digits code for auth action
*
* @phpstan-param array{type: string, name: string, extra: string} $data
* @param callable $codeGenerator generate secret code
*
* @return string secret
*/
public function createCodeIdentity(
User $user,
array $data,
callable $codeGenerator
): string {
$this->checkUserId($user);
helper('text');
// Create an identity for the action
$maxTry = 5;
$data['user_id'] = $user->id;
while (true) {
$data['secret'] = $codeGenerator();
try {
$this->create($data);
break;
} catch (DatabaseException $e) {
$maxTry--;
if ($maxTry === 0) {
throw $e;
}
}
}
return $data['secret'];
}
/**
* Generates a new personal access token for the user.
*
* @param string $name Token name
* @param list<string> $scopes Permissions the token grants
*/
public function generateAccessToken(User $user, string $name, array $scopes = ['*']): AccessToken
{
$this->checkUserId($user);
helper('text');
$return = $this->insert([
'type' => AccessTokens::ID_TYPE_ACCESS_TOKEN,
'user_id' => $user->id,
'name' => $name,
'secret' => hash('sha256', $rawToken = random_string('crypto', 64)),
'extra' => serialize($scopes),
]);
$this->checkQueryReturn($return);
/** @var AccessToken $token */
$token = $this
->asObject(AccessToken::class)
->find($this->getInsertID());
$token->raw_token = $rawToken;
return $token;
}
public function getAccessTokenByRawToken(string $rawToken): ?AccessToken
{
return $this
->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
->where('secret', hash('sha256', $rawToken))
->asObject(AccessToken::class)
->first();
}
public function getAccessToken(User $user, string $rawToken): ?AccessToken
{
$this->checkUserId($user);
return $this->where('user_id', $user->id)
->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
->where('secret', hash('sha256', $rawToken))
->asObject(AccessToken::class)
->first();
}
/**
* Given the ID, returns the given access token.
*
* @param int|string $id
*/
public function getAccessTokenById($id, User $user): ?AccessToken
{
$this->checkUserId($user);
return $this->where('user_id', $user->id)
->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
->where('id', $id)
->asObject(AccessToken::class)
->first();
}
/**
* @return list<AccessToken>
*/
public function getAllAccessTokens(User $user): array
{
$this->checkUserId($user);
return $this
->where('user_id', $user->id)
->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
->orderBy($this->primaryKey)
->asObject(AccessToken::class)
->findAll();
}
// HMAC
/**
* Find and Retrieve the HMAC AccessToken based on Token alone
*
* @return ?AccessToken Full HMAC Access Token object
*/
public function getHmacTokenByKey(string $key): ?AccessToken
{
return $this
->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
->where('secret', $key)
->asObject(AccessToken::class)
->first();
}
/**
* Generates a new personal access token for the user.
*
* @param string $name Token name
* @param list<string> $scopes Permissions the token grants
*
* @throws Exception
* @throws ReflectionException
*/
public function generateHmacToken(User $user, string $name, array $scopes = ['*']): AccessToken
{
$this->checkUserId($user);
$encrypter = new HmacEncrypter();
$rawSecretKey = $encrypter->generateSecretKey();
$secretKey = $encrypter->encrypt($rawSecretKey);
$return = $this->insert([
'type' => HmacSha256::ID_TYPE_HMAC_TOKEN,
'user_id' => $user->id,
'name' => $name,
'secret' => bin2hex(random_bytes(16)), // Key
'secret2' => $secretKey,
'extra' => serialize($scopes),
]);
$this->checkQueryReturn($return);
/** @var AccessToken $token */
$token = $this
->asObject(AccessToken::class)
->find($this->getInsertID());
$token->rawSecretKey = $rawSecretKey;
return $token;
}
/**
* Retrieve Token object for selected HMAC Token.
* Note: These tokens are not hashed as they are considered shared secrets.
*
* @param User $user User Object
* @param string $key HMAC Key String
*
* @return ?AccessToken Full HMAC Access Token
*/
public function getHmacToken(User $user, string $key): ?AccessToken
{
$this->checkUserId($user);
return $this->where('user_id', $user->id)
->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
->where('secret', $key)
->asObject(AccessToken::class)
->first();
}
/**
* Given the ID, returns the given access token.
*
* @param int|string $id
* @param User $user User Object
*
* @return ?AccessToken Full HMAC Access Token
*/
public function getHmacTokenById($id, User $user): ?AccessToken
{
$this->checkUserId($user);
return $this->where('user_id', $user->id)
->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
->where('id', $id)
->asObject(AccessToken::class)
->first();
}
/**
* Retrieve all HMAC tokes for users
*
* @param User $user User object
*
* @return list<AccessToken>
*/
public function getAllHmacTokens(User $user): array
{
$this->checkUserId($user);
return $this
->where('user_id', $user->id)
->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
->orderBy($this->primaryKey)
->asObject(AccessToken::class)
->findAll();
}
/**
* Delete any HMAC tokens for the given key.
*
* @param User $user User object
* @param string $key HMAC Key
*/
public function revokeHmacToken(User $user, string $key): void
{
$this->checkUserId($user);
$return = $this->where('user_id', $user->id)
->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
->where('secret', $key)
->delete();
$this->checkQueryReturn($return);
}
/**
* Revokes all access tokens for this user.
*/
public function revokeAllHmacTokens(User $user): void
{
$this->checkUserId($user);
$return = $this->where('user_id', $user->id)
->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
->delete();
$this->checkQueryReturn($return);
}
/**
* Used by 'magic-link'.
*/
public function getIdentityBySecret(string $type, ?string $secret): ?UserIdentity
{
if ($secret === null) {
return null;
}
return $this->where('type', $type)
->where('secret', $secret)
->first();
}
/**
* Returns all identities.
*
* @return list<UserIdentity>
*/
public function getIdentities(User $user): array
{
$this->checkUserId($user);
return $this->where('user_id', $user->id)->orderBy($this->primaryKey)->findAll();
}
/**
* @param list<int>|list<string> $userIds
*
* @return list<UserIdentity>
*/
public function getIdentitiesByUserIds(array $userIds): array
{
return $this->whereIn('user_id', $userIds)->orderBy($this->primaryKey)->findAll();
}
/**
* Returns the first identity of the type.
*/
public function getIdentityByType(User $user, string $type): ?UserIdentity
{
$this->checkUserId($user);
return $this->where('user_id', $user->id)
->where('type', $type)
->orderBy($this->primaryKey)
->first();
}
/**
* Returns all identities for the specific types.
*
* @param list<string> $types
*
* @return list<UserIdentity>
*/
public function getIdentitiesByTypes(User $user, array $types): array
{
$this->checkUserId($user);
if ($types === []) {
return [];
}
return $this->where('user_id', $user->id)
->whereIn('type', $types)
->orderBy($this->primaryKey)
->findAll();
}
/**
* Update the last used at date for an identity record.
*/
public function touchIdentity(UserIdentity $identity): void
{
$identity->last_used_at = Time::now();
$return = $this->save($identity);
$this->checkQueryReturn($return);
}
public function deleteIdentitiesByType(User $user, string $type): void
{
$this->checkUserId($user);
$return = $this->where('user_id', $user->id)
->where('type', $type)
->delete();
$this->checkQueryReturn($return);
}
/**
* Delete any access tokens for the given raw token.
*/
public function revokeAccessToken(User $user, string $rawToken): void
{
$this->checkUserId($user);
$return = $this->where('user_id', $user->id)
->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
->where('secret', hash('sha256', $rawToken))
->delete();
$this->checkQueryReturn($return);
}
/**
* Delete any access tokens for the given secret token.
*/
public function revokeAccessTokenBySecret(User $user, string $secretToken): void
{
$this->checkUserId($user);
$return = $this->where('user_id', $user->id)
->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
->where('secret', $secretToken)
->delete();
$this->checkQueryReturn($return);
}
/**
* Revokes all access tokens for this user.
*/
public function revokeAllAccessTokens(User $user): void
{
$this->checkUserId($user);
$return = $this->where('user_id', $user->id)
->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
->delete();
$this->checkQueryReturn($return);
}
/**
* Force password reset for multiple users.
*
* @param list<int>|list<string> $userIds
*/
public function forceMultiplePasswordReset(array $userIds): void
{
$this->where(['type' => Session::ID_TYPE_EMAIL_PASSWORD, 'force_reset' => 0]);
$this->whereIn('user_id', $userIds);
$this->set('force_reset', 1);
$return = $this->update();
$this->checkQueryReturn($return);
}
/**
* Force global password reset.
* This is useful for enforcing a password reset
* for ALL users in case of a security breach.
*/
public function forceGlobalPasswordReset(): void
{
$whereFilter = [
'type' => Session::ID_TYPE_EMAIL_PASSWORD,
'force_reset' => 0,
];
$this->where($whereFilter);
$this->set('force_reset', 1);
$return = $this->update();
$this->checkQueryReturn($return);
}
/**
* Override the Model's `update()` method.
* Throws an Exception when it fails.
*
* @param array|int|string|null $id
* @param array|object|null $row
*
* @return true if the update is successful
*
* @throws ValidationException
*/
public function update($id = null, $row = null): bool
{
$result = parent::update($id, $row);
$this->checkQueryReturn($result);
return true;
}
public function fake(Generator &$faker): UserIdentity
{
return new UserIdentity([
'user_id' => fake(UserModel::class)->id,
'type' => Session::ID_TYPE_EMAIL_PASSWORD,
'name' => null,
'secret' => $faker->unique()->email(),
'secret2' => password_hash('secret', PASSWORD_DEFAULT),
'expires' => null,
'extra' => null,
'force_reset' => false,
'last_used_at' => null,
]);
}
}