mein-sterntours/app/Models/OfferAccessToken.php

146 lines
4.8 KiB
PHP

<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* Einmal-Token für den kundenseitigen Angebots-Freigabe-Link
* (`/angebot/{token}` — siehe Phase D).
*
* In der Datenbank liegt nur der **SHA-256-Hash** des Tokens
* (`token_hash`), nicht der Klartext — das minimiert das Risiko, wenn
* die DB einmal abgegriffen wird. Der Klartext-Token wird ausschließlich
* einmal von {@see self::generate()} zurückgegeben (transient in der
* Property `plain_token`) und dann in der versendeten Mail an den
* Kunden eingebettet.
*
* @property int $id
* @property int $offer_id
* @property int $offer_version_id
* @property string $token_hash
* @property Carbon|null $expires_at
* @property Carbon|null $first_opened_at
* @property Carbon|null $revoked_at
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Offer $offer
* @property-read OfferVersion $offerVersion
*
* Transient (nicht persistent, nur direkt nach generate()):
* @property-read string|null $plain_token
*/
class OfferAccessToken extends Model
{
use HasFactory;
/** Anzahl der zufälligen Klartext-Zeichen (ergibt ≥ 48 bit Entropie deutlich). */
public const TOKEN_LENGTH = 64;
protected $connection = 'mysql';
protected $table = 'offer_access_tokens';
protected $fillable = [
'offer_id',
'offer_version_id',
'token_hash',
'expires_at',
'first_opened_at',
'revoked_at',
];
protected $casts = [
'offer_id' => 'int',
'offer_version_id' => 'int',
'expires_at' => 'datetime',
'first_opened_at' => 'datetime',
'revoked_at' => 'datetime',
];
/**
* Transient — der ungehashte Token. Nur unmittelbar nach {@see generate()}
* gesetzt, wird weder gespeichert noch serialisiert.
*/
public ?string $plain_token = null;
// ── Beziehungen ──────────────────────────────────────────────────────────
public function offer(): BelongsTo
{
return $this->belongsTo(Offer::class, 'offer_id');
}
public function offerVersion(): BelongsTo
{
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
}
// ── Scopes ───────────────────────────────────────────────────────────────
/**
* Nur Tokens, die aktuell wirklich verwendet werden können:
* nicht widerrufen, nicht abgelaufen.
*/
public function scopeActive(Builder $query): Builder
{
return $query
->whereNull('revoked_at')
->where(function (Builder $q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
});
}
// ── Factories / Helper ───────────────────────────────────────────────────
/**
* Erzeugt einen neuen Access-Token für eine Version. Der Klartext-Token
* wird einmalig zurückgegeben (`$result->plain_token`), die DB speichert
* nur den SHA-256-Hash.
*
* @param OfferVersion $version
* @param Carbon|null $expiresAt null → kein Ablauf (bewusst auf Caller-Ebene entscheiden)
*/
public static function generate(OfferVersion $version, ?Carbon $expiresAt = null): self
{
$plain = Str::random(self::TOKEN_LENGTH);
$token = new self([
'offer_id' => $version->offer_id,
'offer_version_id' => $version->id,
'token_hash' => hash('sha256', $plain),
'expires_at' => $expiresAt,
]);
$token->save();
$token->plain_token = $plain;
return $token;
}
/**
* Findet einen aktiven Token anhand seines Klartext-Werts (Mail-Link).
* Gibt null zurück, wenn der Token unbekannt, widerrufen oder abgelaufen ist.
*/
public static function findActiveByPlainToken(string $plain): ?self
{
$hash = hash('sha256', $plain);
return self::query()->active()->where('token_hash', $hash)->first();
}
/**
* Widerruft diesen Token — er ist danach nicht mehr nutzbar. Idempotent.
*/
public function revoke(): void
{
if ($this->revoked_at === null) {
$this->revoked_at = now();
$this->save();
}
}
}