128 lines
3.6 KiB
PHP
128 lines
3.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXM\AuthenticationProviderTotp;
|
|
|
|
use KTXF\Security\Authentication\AuthenticationProviderAbstract;
|
|
use KTXF\Security\Authentication\ProviderContext;
|
|
use KTXF\Security\Authentication\ProviderResult;
|
|
use KTXM\AuthenticationProviderTotp\Services\EnrollmentService;
|
|
|
|
/**
|
|
* TOTP Authentication Provider
|
|
*
|
|
* Implements Time-based One-Time Password (TOTP) according to RFC 6238.
|
|
* Compatible with FreeOTP, Google Authenticator, Authy, Microsoft Authenticator, etc.
|
|
*/
|
|
class Provider extends AuthenticationProviderAbstract
|
|
{
|
|
private const DEFAULT_DIGITS = 6;
|
|
|
|
public function __construct(
|
|
private readonly EnrollmentService $enrollmentService,
|
|
) {}
|
|
|
|
// =========================================================================
|
|
// Provider Implementation
|
|
// =========================================================================
|
|
|
|
public function type(): string
|
|
{
|
|
return 'authentication';
|
|
}
|
|
|
|
public function identifier(): string
|
|
{
|
|
return 'totp';
|
|
}
|
|
|
|
public function method(): string
|
|
{
|
|
return self::METHOD_CREDENTIAL;
|
|
}
|
|
|
|
public function label(): string
|
|
{
|
|
return 'TOTP Code';
|
|
}
|
|
|
|
public function description(): string
|
|
{
|
|
return 'Authenticate using Time-based One-Time Passwords (TOTP) from an authenticator app.';
|
|
}
|
|
|
|
public function icon(): string
|
|
{
|
|
return 'clock';
|
|
}
|
|
|
|
public function beginChallenge(ProviderContext $context): ProviderResult
|
|
{
|
|
$userIdentifier = $context->userIdentifier;
|
|
|
|
if (empty($userIdentifier)) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INVALID_FACTOR,
|
|
'User identifier is required'
|
|
);
|
|
}
|
|
|
|
// Verify user is enrolled
|
|
if (!$this->enrollmentService->isEnrolled($userIdentifier)) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_NOT_ENROLLED,
|
|
'TOTP is not enrolled for this user'
|
|
);
|
|
}
|
|
|
|
// TOTP doesn't require sending anything to begin challenge
|
|
// The user generates the code on their device
|
|
return ProviderResult::challenge([
|
|
'type' => 'totp',
|
|
'message' => 'Enter the code from your authenticator app',
|
|
'digits' => self::DEFAULT_DIGITS,
|
|
]);
|
|
}
|
|
|
|
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
|
|
{
|
|
$userIdentifier = $context->userIdentifier;
|
|
|
|
if (empty($userIdentifier)) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INVALID_FACTOR,
|
|
'User identifier is required'
|
|
);
|
|
}
|
|
|
|
$result = $this->enrollmentService->verifyCode($userIdentifier, $code);
|
|
|
|
if (!$result['success']) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_FACTOR_FAILED,
|
|
$result['error'] ?? 'Invalid verification code'
|
|
);
|
|
}
|
|
|
|
$identity = [
|
|
'user_id' => $userIdentifier,
|
|
'provider' => $this->identifier(),
|
|
];
|
|
|
|
if ($result['used_recovery_code'] ?? false) {
|
|
$identity['used_recovery_code'] = true;
|
|
}
|
|
|
|
return ProviderResult::success($identity);
|
|
}
|
|
|
|
/**
|
|
* TOTP can also be used as a credential method (verify directly without challenge)
|
|
*/
|
|
public function verify(ProviderContext $context, string $secret): ProviderResult
|
|
{
|
|
return $this->verifyChallenge($context, $secret);
|
|
}
|
|
}
|