E-Mail-Retry, Voucher-Resend, Tageslimit, Docker-Politur
- Mailer::send mit Retry (2 Versuche); SMTP-Test & Templates waren bereits da - admin/vouchers.php: Code per E-Mail (erneut) versenden (ajax_resend) - Tageslimit Voucher pro Nicht-Admin-Benutzer (Setting + Durchsetzung in index) - Docker: HEALTHCHECK (health.php) + curl; GHCR-Publish-Workflow - Setting user_daily_voucher_limit + enforce in integrations.php
This commit is contained in:
parent
2e0f36d6c1
commit
997cda01a8
14 changed files with 350 additions and 8 deletions
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
|
|
@ -32,3 +32,26 @@ jobs:
|
|||
- name: Validate JSON language/migration assets
|
||||
run: |
|
||||
php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";'
|
||||
|
||||
test:
|
||||
name: Unit Tests & Static Analysis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP 8.2
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.2"
|
||||
extensions: pdo, pdo_mysql, curl, mbstring, json
|
||||
tools: composer
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --no-progress
|
||||
|
||||
- name: PHPUnit
|
||||
run: vendor/bin/phpunit
|
||||
|
||||
- name: PHPStan
|
||||
run: vendor/bin/phpstan analyse --no-progress
|
||||
|
|
|
|||
39
.github/workflows/docker-publish.yml
vendored
Normal file
39
.github/workflows/docker-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Docker Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "v*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
11
Dockerfile
11
Dockerfile
|
|
@ -1,8 +1,10 @@
|
|||
# UniFi Voucher Management System – Container-Image
|
||||
FROM php:8.2-apache
|
||||
|
||||
# PHP-Extensions
|
||||
RUN docker-php-ext-install pdo pdo_mysql \
|
||||
# System-Tools (curl für Healthcheck) + PHP-Extensions
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& docker-php-ext-install pdo pdo_mysql \
|
||||
&& a2enmod rewrite headers
|
||||
|
||||
# Empfohlene PHP-Einstellungen
|
||||
|
|
@ -25,5 +27,10 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
|||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Apache-Worker laufen als www-data (Privilege-Drop durch den Master).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://localhost/health.php || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
|
|||
$error = __('error_csrf');
|
||||
} else {
|
||||
$db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0');
|
||||
$db->setSetting('user_daily_voucher_limit', max(0, (int)($_POST['user_daily_voucher_limit'] ?? 0)));
|
||||
$db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? ''));
|
||||
$db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0');
|
||||
$db->setSetting('webhook_url', trim($_POST['webhook_url'] ?? ''));
|
||||
|
|
@ -41,6 +42,7 @@ if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrf
|
|||
}
|
||||
|
||||
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
|
||||
$dailyLimit = (int)$db->getSetting('user_daily_voucher_limit', 0);
|
||||
$trustedProxy = $db->getSetting('trusted_proxy', '');
|
||||
$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1';
|
||||
$webhookUrl = $db->getSetting('webhook_url', '');
|
||||
|
|
@ -86,6 +88,8 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
|
|||
<h2>Sicherheitsrichtlinie</h2>
|
||||
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p>
|
||||
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
|
||||
<label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label>
|
||||
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
|
|
|||
|
|
@ -96,6 +96,25 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
|
|||
exit;
|
||||
}
|
||||
|
||||
// Voucher-Code per E-Mail (erneut) versenden
|
||||
if (isset($_POST['ajax_resend']) && isset($_POST['voucher_id']) && isset($_POST['site_id'])) {
|
||||
header('Content-Type: application/json');
|
||||
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { echo json_encode(['success'=>false,'message'=>__('error_csrf')]); exit; }
|
||||
require_once __DIR__ . '/../includes/Mailer.php';
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['success'=>false,'message'=>'Ungültige E-Mail-Adresse']); exit; }
|
||||
$siteId = (int)$_POST['site_id'];
|
||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [$siteId]);
|
||||
$v = $db->fetchOne("SELECT * FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$_POST['voucher_id'], $siteId]);
|
||||
if (!$site || !$v) { echo json_encode(['success'=>false,'message'=>'Voucher nicht gefunden']); exit; }
|
||||
$mailer = new Mailer();
|
||||
$code = strpos($v['voucher_code'], '-') !== false ? $v['voucher_code'] : implode('-', str_split($v['voucher_code'], 5));
|
||||
$ok = $mailer->sendVoucherEmail($email, $code, $site['name'], (int)$v['max_uses']);
|
||||
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_resend', 'voucher', $v['id'], "Code an $email gesendet");
|
||||
echo json_encode(['success'=>$ok, 'message'=>$ok ? 'E-Mail versendet.' : 'Versand fehlgeschlagen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
|
||||
$siteStats = [];
|
||||
foreach ($sites as $site) {
|
||||
|
|
@ -383,7 +402,10 @@ function renderVouchers() {
|
|||
<td>${statusBadge}</td>
|
||||
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
||||
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td>
|
||||
<td><button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button></td>
|
||||
<td style="white-space:nowrap;">
|
||||
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button>
|
||||
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
|
|
@ -407,6 +429,20 @@ function renderVouchers() {
|
|||
|
||||
function escapeHtml(t) { const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
|
||||
|
||||
async function resendVoucher(voucherId, code) {
|
||||
const email = prompt('Code ' + code + ' senden an (E-Mail):');
|
||||
if (!email) return;
|
||||
const fd = new FormData();
|
||||
fd.append('ajax_resend','1'); fd.append('voucher_id',voucherId);
|
||||
fd.append('site_id', currentSiteId); fd.append('email', email);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const r = await fetch('vouchers.php', {method:'POST', body:fd});
|
||||
const d = await r.json();
|
||||
(window.showToast ? showToast(d.message, d.success?'success':'error') : alert(d.message));
|
||||
} catch(e){ alert('Fehler: '+e.message); }
|
||||
}
|
||||
|
||||
async function deleteVoucher(voucherId) {
|
||||
if (!confirm('Voucher wirklich löschen?')) return;
|
||||
const row = document.getElementById(`voucher-${voucherId}`);
|
||||
|
|
|
|||
21
composer.json
Normal file
21
composer.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "friloo/unifi-voucher-tool",
|
||||
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"phpstan/phpstan": "^1.11"
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit",
|
||||
"stan": "phpstan analyse"
|
||||
}
|
||||
}
|
||||
|
|
@ -31,12 +31,23 @@ class Mailer {
|
|||
}
|
||||
|
||||
public function send($to, $subject, $body, $isHtml = false) {
|
||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||
// Fallback auf PHP mail()
|
||||
return $this->sendWithPhpMail($to, $subject, $body);
|
||||
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
||||
$attempts = 2;
|
||||
for ($i = 1; $i <= $attempts; $i++) {
|
||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
||||
} else {
|
||||
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||
}
|
||||
if ($ok) {
|
||||
return true;
|
||||
}
|
||||
if ($i < $attempts) {
|
||||
usleep(500000); // 0,5s vor erneutem Versuch
|
||||
}
|
||||
}
|
||||
|
||||
return $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private function sendWithPhpMail($to, $subject, $body) {
|
||||
|
|
|
|||
22
index.php
22
index.php
|
|
@ -46,6 +46,26 @@ function isVoucherRateLimited() {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionales Tageslimit pro (Nicht-Admin-)Benutzer (Setting
|
||||
* user_daily_voucher_limit, 0 = aus). Verhindert übermäßige Erstellung.
|
||||
*/
|
||||
function userDailyLimitExceeded($db, $auth, $additional = 1) {
|
||||
if (!$auth->isLoggedIn() || $auth->isAdmin()) {
|
||||
return false;
|
||||
}
|
||||
$limit = (int)$db->getSetting('user_daily_voucher_limit', 0);
|
||||
if ($limit <= 0) {
|
||||
return false;
|
||||
}
|
||||
$uid = $_SESSION['user_id'] ?? 0;
|
||||
$today = (int)($db->fetchOne(
|
||||
"SELECT COUNT(*) c FROM vouchers WHERE user_id=? AND DATE(created_at)=CURDATE()",
|
||||
[$uid]
|
||||
)['c'] ?? 0);
|
||||
return ($today + $additional) > $limit;
|
||||
}
|
||||
|
||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$logoUrl = $db->getSetting('logo_url', '');
|
||||
$instructionHeader = $db->getSetting('instruction_header', '');
|
||||
|
|
@ -146,6 +166,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
|||
if ($sendEmail && !filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
|
||||
|
||||
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
||||
if (userDailyLimitExceeded($db, $auth, 1)) throw new Exception('Tageslimit für Voucher erreicht.');
|
||||
|
||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||||
if (!$site) throw new Exception(__('error_site_not_found'));
|
||||
|
|
@ -194,6 +215,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
|
|||
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
|
||||
if ($siteId <= 0) throw new Exception(__('error_site_req'));
|
||||
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
||||
if (userDailyLimitExceeded($db, $auth, $bulkCount)) throw new Exception('Tageslimit für Voucher erreicht.');
|
||||
|
||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||||
if (!$site) throw new Exception(__('error_site_not_found'));
|
||||
|
|
|
|||
6
phpstan.neon
Normal file
6
phpstan.neon
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
parameters:
|
||||
level: 5
|
||||
paths:
|
||||
- includes/Totp.php
|
||||
- includes/Crypto.php
|
||||
- includes/ApiKey.php
|
||||
11
phpunit.xml.dist
Normal file
11
phpunit.xml.dist
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
35
tests/ApiKeyTest.php
Normal file
35
tests/ApiKeyTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once __DIR__ . '/../includes/ApiKey.php';
|
||||
|
||||
final class ApiKeyTest extends TestCase
|
||||
{
|
||||
public function testGenerateFormat(): void
|
||||
{
|
||||
$k = \ApiKey::generate();
|
||||
$this->assertStringStartsWith('uvt_', $k['plain']);
|
||||
$this->assertSame(44, strlen($k['plain']));
|
||||
$this->assertSame(hash('sha256', $k['plain']), $k['hash']);
|
||||
$this->assertSame(substr($k['plain'], 4, 8), $k['prefix']);
|
||||
}
|
||||
|
||||
public function testScopes(): void
|
||||
{
|
||||
$read = ['scope' => 'read'];
|
||||
$write = ['scope' => 'write'];
|
||||
$this->assertTrue(\ApiKey::hasScope($read, 'read'));
|
||||
$this->assertFalse(\ApiKey::hasScope($read, 'write'));
|
||||
$this->assertTrue(\ApiKey::hasScope($write, 'read'));
|
||||
$this->assertTrue(\ApiKey::hasScope($write, 'write'));
|
||||
}
|
||||
|
||||
public function testFromRequestBearer(): void
|
||||
{
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer uvt_testkey123';
|
||||
$this->assertSame('uvt_testkey123', \ApiKey::fromRequest());
|
||||
unset($_SERVER['HTTP_AUTHORIZATION']);
|
||||
}
|
||||
}
|
||||
39
tests/CryptoTest.php
Normal file
39
tests/CryptoTest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
if (!defined('APP_KEY')) {
|
||||
define('APP_KEY', base64_encode(random_bytes(32)));
|
||||
}
|
||||
require_once __DIR__ . '/../includes/Crypto.php';
|
||||
|
||||
final class CryptoTest extends TestCase
|
||||
{
|
||||
public function testRoundtrip(): void
|
||||
{
|
||||
$plain = 'geheim;mit"sonder@zeichen';
|
||||
$cipher = \Crypto::encrypt($plain);
|
||||
$this->assertNotSame($plain, $cipher);
|
||||
$this->assertTrue(\Crypto::isEncrypted($cipher));
|
||||
$this->assertSame($plain, \Crypto::decrypt($cipher));
|
||||
}
|
||||
|
||||
public function testPlaintextPassthrough(): void
|
||||
{
|
||||
// Legacy-/Klartextwerte werden unverändert zurückgegeben.
|
||||
$this->assertSame('altesKlartextPW', \Crypto::decrypt('altesKlartextPW'));
|
||||
}
|
||||
|
||||
public function testEmptyValues(): void
|
||||
{
|
||||
$this->assertSame('', \Crypto::encrypt(''));
|
||||
$this->assertNull(\Crypto::decrypt(null));
|
||||
}
|
||||
|
||||
public function testGenerateKeyLength(): void
|
||||
{
|
||||
$key = \Crypto::generateKey();
|
||||
$this->assertSame(32, strlen(base64_decode($key)));
|
||||
}
|
||||
}
|
||||
42
tests/MigrationSplitterTest.php
Normal file
42
tests/MigrationSplitterTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once __DIR__ . '/../updater/MigrationRunner.php';
|
||||
|
||||
final class MigrationSplitterTest extends TestCase
|
||||
{
|
||||
private function split(string $sql): array
|
||||
{
|
||||
$rc = new \ReflectionClass(\Updater\MigrationRunner::class);
|
||||
$inst = $rc->newInstanceWithoutConstructor();
|
||||
$m = $rc->getMethod('splitStatements');
|
||||
$m->setAccessible(true);
|
||||
$parts = $m->invoke($inst, $sql);
|
||||
return array_values(array_filter(array_map('trim', $parts), fn($s) => $s !== ''));
|
||||
}
|
||||
|
||||
public function testIgnoresSemicolonsInStringsAndComments(): void
|
||||
{
|
||||
$sql = "INSERT INTO t (a) VALUES (\";semi;colon\"); -- comment; not split\n"
|
||||
. "CREATE TABLE x (id INT); /* block ; comment */ INSERT INTO y VALUES (1);";
|
||||
$parts = $this->split($sql);
|
||||
$this->assertCount(3, $parts);
|
||||
}
|
||||
|
||||
public function testSingleStatement(): void
|
||||
{
|
||||
$parts = $this->split("ALTER TABLE users ADD COLUMN foo INT");
|
||||
$this->assertCount(1, $parts);
|
||||
}
|
||||
|
||||
public function testIgnorableErrorDetection(): void
|
||||
{
|
||||
$rc = new \ReflectionClass(\Updater\MigrationRunner::class);
|
||||
$inst = $rc->newInstanceWithoutConstructor();
|
||||
$this->assertTrue($inst->isIgnorableSqlError('Duplicate column name "x"', 'mysql'));
|
||||
$this->assertTrue($inst->isIgnorableSqlError('Table already exists', 'mysql'));
|
||||
$this->assertFalse($inst->isIgnorableSqlError('Syntax error near FROM', 'mysql'));
|
||||
}
|
||||
}
|
||||
46
tests/TotpTest.php
Normal file
46
tests/TotpTest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once __DIR__ . '/../includes/Totp.php';
|
||||
|
||||
final class TotpTest extends TestCase
|
||||
{
|
||||
/** RFC 6238 Testvektoren (SHA1, 6 Stellen, Seed "12345678901234567890"). */
|
||||
public function testRfc6238Vectors(): void
|
||||
{
|
||||
$secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; // Base32 des RFC-Seeds
|
||||
$this->assertSame('287082', \Totp::code($secret, intdiv(59, 30)));
|
||||
$this->assertSame('081804', \Totp::code($secret, intdiv(1111111109, 30)));
|
||||
$this->assertSame('005924', \Totp::code($secret, intdiv(1234567890, 30)));
|
||||
}
|
||||
|
||||
public function testVerifyAcceptsCurrentCode(): void
|
||||
{
|
||||
$secret = \Totp::generateSecret();
|
||||
$code = \Totp::code($secret);
|
||||
$this->assertTrue(\Totp::verify($secret, $code));
|
||||
}
|
||||
|
||||
public function testVerifyRejectsWrongCode(): void
|
||||
{
|
||||
$secret = \Totp::generateSecret();
|
||||
$wrong = \Totp::code($secret) === '000000' ? '111111' : '000000';
|
||||
$this->assertFalse(\Totp::verify($secret, $wrong));
|
||||
}
|
||||
|
||||
public function testVerifyRejectsMalformed(): void
|
||||
{
|
||||
$secret = \Totp::generateSecret();
|
||||
$this->assertFalse(\Totp::verify($secret, 'abcdef'));
|
||||
$this->assertFalse(\Totp::verify($secret, '12345'));
|
||||
}
|
||||
|
||||
public function testProvisioningUri(): void
|
||||
{
|
||||
$uri = \Totp::provisioningUri('ABC', 'user@example.com', 'My App');
|
||||
$this->assertStringStartsWith('otpauth://totp/', $uri);
|
||||
$this->assertStringContainsString('secret=ABC', $uri);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue