410 lines
12 KiB
PHP
410 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXM\AuthenticationProviderTotp\Services;
|
|
|
|
use KTXC\SessionTenant;
|
|
use KTXM\AuthenticationProviderTotp\Stores\EnrollmentStore;
|
|
|
|
/**
|
|
* TOTP Enrollment Service
|
|
*
|
|
* Handles TOTP enrollment lifecycle:
|
|
* - Checking enrollment status
|
|
* - Beginning enrollment (generating secrets and QR codes)
|
|
* - Completing enrollment (verifying initial code)
|
|
* - Removing enrollment
|
|
* - Managing recovery codes
|
|
*/
|
|
class EnrollmentService
|
|
{
|
|
// TOTP Configuration defaults
|
|
private const DEFAULT_DIGITS = 6;
|
|
private const DEFAULT_PERIOD = 30;
|
|
private const DEFAULT_ALGORITHM = 'sha1';
|
|
private const SECRET_LENGTH = 20; // 160 bits as recommended by RFC 4226
|
|
|
|
public function __construct(
|
|
private readonly SessionTenant $tenant,
|
|
private readonly EnrollmentStore $enrollmentStore,
|
|
) {}
|
|
|
|
/**
|
|
* Check if user has a verified TOTP enrollment
|
|
*/
|
|
public function isEnrolled(string $userId): bool
|
|
{
|
|
$tenantId = $this->tenant->identifier();
|
|
if (!$tenantId) {
|
|
return false;
|
|
}
|
|
|
|
$enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId);
|
|
return $enrollment !== null && ($enrollment['verified'] ?? false) === true;
|
|
}
|
|
|
|
/**
|
|
* Begin TOTP enrollment - generates secret, provisioning URI, and recovery codes
|
|
*
|
|
* @param string $userId User identifier
|
|
* @param array $config Configuration options (issuer, account_name)
|
|
* @return array{success: bool, error?: string, error_code?: string, enrollment?: array}
|
|
*/
|
|
public function beginEnrollment(string $userId, array $config = []): array
|
|
{
|
|
$tenantId = $this->tenant->identifier();
|
|
if (!$tenantId) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Invalid tenant context',
|
|
'error_code' => 'internal_error',
|
|
];
|
|
}
|
|
|
|
// Check if already enrolled
|
|
if ($this->isEnrolled($userId)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'TOTP is already enrolled for this user',
|
|
'error_code' => 'already_enrolled',
|
|
];
|
|
}
|
|
|
|
// Generate new secret
|
|
$secret = $this->generateSecret();
|
|
|
|
// Generate recovery codes
|
|
$recoveryCodes = $this->generateRecoveryCodes();
|
|
|
|
// Store pending enrollment (not yet verified)
|
|
$this->enrollmentStore->storePendingEnrollment($tenantId, $userId, $secret, $recoveryCodes);
|
|
|
|
// Generate provisioning URI for QR code
|
|
$issuer = $config['issuer'] ?? 'Ktrix';
|
|
$accountName = $config['account_name'] ?? $userId;
|
|
$provisioningUri = $this->generateProvisioningUri($secret, $issuer, $accountName);
|
|
|
|
// Generate QR code data URL (empty for client-side generation)
|
|
$qrCodeDataUrl = $this->generateQrCodeDataUrl($provisioningUri);
|
|
|
|
return [
|
|
'success' => true,
|
|
'enrollment' => [
|
|
'secret' => $secret,
|
|
'provisioning_uri' => $provisioningUri,
|
|
'qr_code' => $qrCodeDataUrl,
|
|
'recovery_codes' => $recoveryCodes,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Complete TOTP enrollment by verifying the initial code
|
|
*
|
|
* @param string $userId User identifier
|
|
* @param string $code Verification code from authenticator app
|
|
* @return array{success: bool, error?: string, error_code?: string}
|
|
*/
|
|
public function completeEnrollment(string $userId, string $code): array
|
|
{
|
|
$tenantId = $this->tenant->identifier();
|
|
if (!$tenantId) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Invalid tenant context',
|
|
'error_code' => 'internal_error',
|
|
];
|
|
}
|
|
|
|
// Get pending enrollment
|
|
$enrollment = $this->enrollmentStore->getPendingEnrollment($tenantId, $userId);
|
|
if (!$enrollment) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'No pending TOTP enrollment found',
|
|
'error_code' => 'no_pending_enrollment',
|
|
];
|
|
}
|
|
|
|
// Verify the code
|
|
if (!$this->verifyCode($enrollment['secret'], $code)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Invalid verification code',
|
|
'error_code' => 'invalid_code',
|
|
];
|
|
}
|
|
|
|
// Mark enrollment as verified
|
|
$this->enrollmentStore->confirmEnrollment($tenantId, $userId);
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
/**
|
|
* Remove TOTP enrollment for a user
|
|
*
|
|
* @param string $userId User identifier
|
|
* @return array{success: bool, error?: string, error_code?: string}
|
|
*/
|
|
public function removeEnrollment(string $userId): array
|
|
{
|
|
$tenantId = $this->tenant->identifier();
|
|
if (!$tenantId) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Invalid tenant context',
|
|
'error_code' => 'internal_error',
|
|
];
|
|
}
|
|
|
|
if (!$this->isEnrolled($userId)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'TOTP is not enrolled for this user',
|
|
'error_code' => 'not_enrolled',
|
|
];
|
|
}
|
|
|
|
$this->enrollmentStore->removeEnrollment($tenantId, $userId);
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
/**
|
|
* Verify a TOTP code for an enrolled user
|
|
*
|
|
* @param string $userId User identifier
|
|
* @param string $code Verification code
|
|
* @return array{success: bool, error?: string, error_code?: string, used_recovery_code?: bool}
|
|
*/
|
|
public function verifyCode(string $userId, string $code): array
|
|
{
|
|
$tenantId = $this->tenant->identifier();
|
|
if (!$tenantId) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Invalid tenant context',
|
|
'error_code' => 'internal_error',
|
|
];
|
|
}
|
|
|
|
// Get enrollment
|
|
$enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId);
|
|
if (!$enrollment) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'TOTP is not enrolled for this user',
|
|
'error_code' => 'not_enrolled',
|
|
];
|
|
}
|
|
|
|
// Check if it's a recovery code
|
|
if ($this->verifyRecoveryCode($tenantId, $userId, $code)) {
|
|
return [
|
|
'success' => true,
|
|
'used_recovery_code' => true,
|
|
];
|
|
}
|
|
|
|
// Verify TOTP code
|
|
if (!$this->verifyTotpCode($enrollment['secret'], $code)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Invalid verification code',
|
|
'error_code' => 'invalid_code',
|
|
];
|
|
}
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
/**
|
|
* Get the user's enrollment secret (for provider verification)
|
|
*/
|
|
public function getEnrollmentSecret(string $userId): ?string
|
|
{
|
|
$tenantId = $this->tenant->identifier();
|
|
if (!$tenantId) {
|
|
return null;
|
|
}
|
|
|
|
$enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId);
|
|
return $enrollment['secret'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Verify a TOTP code against a secret (for provider use)
|
|
*/
|
|
public function verifyTotpCode(string $secret, string $code, int $window = 1): bool
|
|
{
|
|
$code = preg_replace('/\s+/', '', $code);
|
|
|
|
if (strlen($code) !== self::DEFAULT_DIGITS) {
|
|
return false;
|
|
}
|
|
|
|
$timestamp = time();
|
|
$counter = (int) floor($timestamp / self::DEFAULT_PERIOD);
|
|
|
|
for ($i = -$window; $i <= $window; $i++) {
|
|
$expectedCode = $this->generateTotp($secret, $counter + $i);
|
|
if (hash_equals($expectedCode, $code)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Verify a recovery code
|
|
*/
|
|
public function verifyRecoveryCode(string $tenantId, string $userId, string $code): bool
|
|
{
|
|
$code = strtoupper(preg_replace('/\s+/', '', $code));
|
|
$validCodes = $this->enrollmentStore->getRecoveryCodes($tenantId, $userId);
|
|
|
|
foreach ($validCodes as $index => $storedCode) {
|
|
if (hash_equals($storedCode, $code)) {
|
|
$this->enrollmentStore->markRecoveryCodeUsed($tenantId, $userId, $index);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private Helper Methods
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Generate a cryptographically secure secret
|
|
*/
|
|
private function generateSecret(): string
|
|
{
|
|
$bytes = random_bytes(self::SECRET_LENGTH);
|
|
return $this->base32Encode($bytes);
|
|
}
|
|
|
|
/**
|
|
* Generate a provisioning URI for QR codes (otpauth:// format)
|
|
*/
|
|
private function generateProvisioningUri(string $secret, string $issuer, string $accountName): string
|
|
{
|
|
$params = http_build_query([
|
|
'secret' => $secret,
|
|
'issuer' => $issuer,
|
|
'algorithm' => strtoupper(self::DEFAULT_ALGORITHM),
|
|
'digits' => self::DEFAULT_DIGITS,
|
|
'period' => self::DEFAULT_PERIOD,
|
|
]);
|
|
|
|
$label = rawurlencode($issuer) . ':' . rawurlencode($accountName);
|
|
|
|
return "otpauth://totp/{$label}?{$params}";
|
|
}
|
|
|
|
/**
|
|
* Generate QR code as data URL
|
|
*/
|
|
private function generateQrCodeDataUrl(string $uri): string
|
|
{
|
|
// Return empty for client-side QR generation
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Generate recovery codes
|
|
*/
|
|
private function generateRecoveryCodes(int $count = 10): array
|
|
{
|
|
$codes = [];
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$part1 = strtoupper(bin2hex(random_bytes(2)));
|
|
$part2 = strtoupper(bin2hex(random_bytes(2)));
|
|
$codes[] = "{$part1}-{$part2}";
|
|
}
|
|
return $codes;
|
|
}
|
|
|
|
/**
|
|
* Generate TOTP code for a given counter
|
|
*/
|
|
private function generateTotp(string $secret, int $counter): string
|
|
{
|
|
$secretBytes = $this->base32Decode($secret);
|
|
$counterBytes = pack('J', $counter);
|
|
$hash = hash_hmac(self::DEFAULT_ALGORITHM, $counterBytes, $secretBytes, true);
|
|
|
|
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
|
|
$binary = (ord($hash[$offset]) & 0x7F) << 24
|
|
| ord($hash[$offset + 1]) << 16
|
|
| ord($hash[$offset + 2]) << 8
|
|
| ord($hash[$offset + 3]);
|
|
|
|
$otp = $binary % (10 ** self::DEFAULT_DIGITS);
|
|
|
|
return str_pad((string) $otp, self::DEFAULT_DIGITS, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
/**
|
|
* Base32 encode (RFC 4648)
|
|
*/
|
|
private function base32Encode(string $data): string
|
|
{
|
|
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
$encoded = '';
|
|
$bitLen = 0;
|
|
$val = 0;
|
|
$len = strlen($data);
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$val = ($val << 8) | ord($data[$i]);
|
|
$bitLen += 8;
|
|
|
|
while ($bitLen >= 5) {
|
|
$bitLen -= 5;
|
|
$encoded .= $alphabet[($val >> $bitLen) & 0x1F];
|
|
}
|
|
}
|
|
|
|
if ($bitLen > 0) {
|
|
$encoded .= $alphabet[($val << (5 - $bitLen)) & 0x1F];
|
|
}
|
|
|
|
return $encoded;
|
|
}
|
|
|
|
/**
|
|
* Base32 decode (RFC 4648)
|
|
*/
|
|
private function base32Decode(string $data): string
|
|
{
|
|
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
$data = strtoupper(str_replace('=', '', $data));
|
|
|
|
$decoded = '';
|
|
$val = 0;
|
|
$bitLen = 0;
|
|
$len = strlen($data);
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$pos = strpos($alphabet, $data[$i]);
|
|
if ($pos === false) continue;
|
|
|
|
$val = ($val << 5) | $pos;
|
|
$bitLen += 5;
|
|
|
|
if ($bitLen >= 8) {
|
|
$bitLen -= 8;
|
|
$decoded .= chr(($val >> $bitLen) & 0xFF);
|
|
}
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
}
|