Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.35% covered (success)
95.35%
41 / 43
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PortalTokenService
95.35% covered (success)
95.35%
41 / 43
75.00% covered (warning)
75.00%
3 / 4
12
0.00% covered (danger)
0.00%
0 / 1
 issue
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 publicUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolve
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
8.04
 revoke
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\ClientPortal\Application\Services;
6
7use Illuminate\Support\Str;
8use ImovelZapAi\ClientPortal\Domain\Exceptions\ClientPortalException;
9use ImovelZapAi\ClientPortal\Infrastructure\Persistence\Eloquent\ClientPortalModel;
10use ImovelZapAi\ClientPortal\Infrastructure\Persistence\Eloquent\PortalAccessTokenModel;
11
12final class PortalTokenService
13{
14    /**
15     * @return array{plain: string, model: PortalAccessTokenModel, url: string}
16     */
17    public function issue(ClientPortalModel $portal, ?\DateTimeInterface $expiresAt = null): array
18    {
19        $plain = Str::lower(Str::random(40));
20        $signature = hash_hmac('sha256', $portal->id.'|'.$plain, (string) config('app.key'));
21        $expires = $expiresAt ?? now()->addDays((int) config('client_portal.token_ttl_days', 14));
22
23        $model = PortalAccessTokenModel::query()->create([
24            'tenant_id' => $portal->tenant_id,
25            'client_portal_id' => $portal->id,
26            'token_prefix' => substr($plain, 0, 8),
27            'token_hash' => hash('sha256', $plain),
28            'signature' => $signature,
29            'expires_at' => $expires,
30        ]);
31
32        return [
33            'plain' => $plain,
34            'model' => $model,
35            'url' => $this->publicUrl($plain),
36        ];
37    }
38
39    public function publicUrl(string $plainToken): string
40    {
41        $base = rtrim((string) (config('client_portal.public_base_url') ?: config('app.url')), '/');
42
43        return $base.'/p/'.$plainToken;
44    }
45
46    public function resolve(string $plainToken): array
47    {
48        $hash = hash('sha256', $plainToken);
49        $token = PortalAccessTokenModel::query()
50            ->where('token_hash', $hash)
51            ->first();
52
53        if ($token === null) {
54            throw ClientPortalException::tokenInvalid();
55        }
56
57        $expected = hash_hmac(
58            'sha256',
59            $token->client_portal_id.'|'.$plainToken,
60            (string) config('app.key'),
61        );
62
63        if (! hash_equals($token->signature, $expected)) {
64            throw ClientPortalException::tokenInvalid();
65        }
66
67        if ($token->revoked_at !== null) {
68            throw ClientPortalException::tokenRevoked();
69        }
70
71        if ($token->expires_at !== null && $token->expires_at->isPast()) {
72            throw ClientPortalException::tokenInvalid();
73        }
74
75        $portal = ClientPortalModel::query()
76            ->whereKey($token->client_portal_id)
77            ->first();
78
79        if ($portal === null || ! $portal->isPubliclyAccessible()) {
80            throw ClientPortalException::tokenInvalid();
81        }
82
83        $token->forceFill(['last_used_at' => now()])->save();
84
85        return ['token' => $token, 'portal' => $portal];
86    }
87
88    public function revoke(PortalAccessTokenModel $token): void
89    {
90        $token->forceFill(['revoked_at' => now()])->save();
91    }
92}