Neustrukturierung Customer / Lead / Booking Phase 2
This commit is contained in:
parent
313f0dbf4e
commit
6df9c401af
69 changed files with 3809 additions and 374 deletions
|
|
@ -10,33 +10,39 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Kundenseitiger Zugriffstoken für /angebot/{token} (Modul 6 / Phase D).
|
||||
* Einmal-Token für den kundenseitigen Angebots-Freigabe-Link
|
||||
* (`/angebot/{token}` — siehe Phase D).
|
||||
*
|
||||
* In der Datenbank wird ausschließlich der SHA-256-Hash des Klartext-
|
||||
* Tokens gespeichert. Der Klartext wird einmalig bei der Erzeugung
|
||||
* zurückgegeben (siehe {@see self::generate()}) und an den Kunden
|
||||
* per Mail-Link ausgeliefert.
|
||||
* 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.
|
||||
*
|
||||
* Pro Angebot + Version existiert genau ein aktiver Token; wird eine
|
||||
* neue Version versendet, setzt der OfferService den Vorgänger auf
|
||||
* `revoked_at`.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $offer_id
|
||||
* @property int $offer_version_id
|
||||
* @property string $token_hash
|
||||
* @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 $version
|
||||
* @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 = [
|
||||
|
|
@ -56,65 +62,85 @@ class OfferAccessToken extends Model
|
|||
'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);
|
||||
return $this->belongsTo(Offer::class, 'offer_id');
|
||||
}
|
||||
|
||||
public function version(): BelongsTo
|
||||
public function offerVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $q): Builder
|
||||
// ── Scopes ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Nur Tokens, die aktuell wirklich verwendet werden können:
|
||||
* nicht widerrufen, nicht abgelaufen.
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $q->whereNull('revoked_at')
|
||||
return $query
|
||||
->whereNull('revoked_at')
|
||||
->where(function (Builder $q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein neues Token für die angegebene Version und liefert
|
||||
* den Klartext-Token zurück (nur einmalig abrufbar). In der
|
||||
* Datenbank wird nur der Hash persistiert.
|
||||
*/
|
||||
public static function generate(
|
||||
Offer $offer,
|
||||
OfferVersion $version,
|
||||
?Carbon $expiresAt = null
|
||||
): array {
|
||||
$plain = Str::random(48);
|
||||
$hash = hash('sha256', $plain);
|
||||
// ── Factories / Helper ───────────────────────────────────────────────────
|
||||
|
||||
/** @var self $token */
|
||||
$token = self::create([
|
||||
'offer_id' => $offer->id,
|
||||
/**
|
||||
* 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,
|
||||
'token_hash' => hash('sha256', $plain),
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
$token->save();
|
||||
|
||||
return ['plain' => $plain, 'token' => $token];
|
||||
$token->plain_token = $plain;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup per Klartext-Token (konstantzeitig via DB-Unique-Index).
|
||||
* 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 findByPlainToken(string $plain): ?self
|
||||
public static function findActiveByPlainToken(string $plain): ?self
|
||||
{
|
||||
return self::where('token_hash', hash('sha256', $plain))->first();
|
||||
$hash = hash('sha256', $plain);
|
||||
|
||||
return self::query()->active()->where('token_hash', $hash)->first();
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
/**
|
||||
* Widerruft diesen Token — er ist danach nicht mehr nutzbar. Idempotent.
|
||||
*/
|
||||
public function revoke(): void
|
||||
{
|
||||
if ($this->revoked_at !== null) {
|
||||
return false;
|
||||
if ($this->revoked_at === null) {
|
||||
$this->revoked_at = now();
|
||||
$this->save();
|
||||
}
|
||||
if ($this->expires_at !== null && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue