Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.83% covered (success)
94.83%
55 / 58
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
CompromisedPasswordCheckService
94.74% covered (success)
94.74%
54 / 57
60.00% covered (warning)
60.00%
3 / 5
22.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCompromised
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 loadIntegrationRecord
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
8
 getCache
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 setCache
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Integrations\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Integrations\Domain\Contract\CompromisedPasswordProviderInterface;
12use App\Modules\Integrations\Infrastructure\Provider\HaveIBeenPwnedProvider;
13use PDO;
14use Throwable;
15use Yiisoft\Cache\CacheInterface;
16
17/**
18 * Application service for compromised password verification via registered security integrations.
19 *
20 * Checks database integration status and delegates to the configured provider with caching resilience.
21 */
22final class CompromisedPasswordCheckService
23{
24    private const string CACHE_PREFIX = 'integ_pwd_comp_';
25    private const int CACHE_TTL_SECONDS = 3600;
26
27    private readonly CompromisedPasswordProviderInterface $provider;
28
29    public function __construct(
30        private readonly ?PDO $pdo = null,
31        ?CompromisedPasswordProviderInterface $provider = null,
32        private readonly ?CacheInterface $cache = null
33    ) {
34        $this->provider = $provider ?? new HaveIBeenPwnedProvider();
35    }
36
37    /**
38     * Checks if the given password is known to be compromised in accordance with the active integration.
39     *
40     * @param string $password Candidate plain text password.
41     * @return bool True if compromised, false if safe or service disabled.
42     */
43    public function isCompromised(string $password): bool
44    {
45        $normalized = trim($password);
46        if ($normalized === '') {
47            return false;
48        }
49
50        $integration = $this->loadIntegrationRecord();
51        if ($integration === null) {
52            // Fallback to provider defaults when integration record is not yet seeded
53            return $this->provider->isCompromised($normalized);
54        }
55
56        if (($integration['status'] ?? 'inactive') !== 'active') {
57            $offlineFallback = (bool) ($integration['config']['offline_fallback'] ?? false);
58            return $offlineFallback
59                ? $this->provider->isCompromised($normalized, null, ['mock_mode' => true])
60                : false;
61        }
62
63        $cacheKey = self::CACHE_PREFIX . hash('sha256', $normalized);
64        $cached = $this->getCache($cacheKey);
65        if ($cached !== null) {
66            return $cached;
67        }
68
69        $isCompromised = $this->provider->isCompromised(
70            password: $normalized,
71            apiUrl: $integration['api_url'] ?? null,
72            config: $integration['config'] ?? []
73        );
74
75        $this->setCache($cacheKey, $isCompromised);
76
77        return $isCompromised;
78    }
79
80    /**
81     * Retrieves the integration record for HaveIBeenPwned from the database.
82     *
83     * @return array{status: string, api_url: ?string, config: array<string, mixed>}|null
84     */
85    public function loadIntegrationRecord(): ?array
86    {
87        if ($this->pdo === null) {
88            return null;
89        }
90
91        try {
92            $stmt = $this->pdo->prepare(
93                'SELECT status, api_url, config_parameters ' .
94                'FROM a_mod_integrations_records ' .
95                'WHERE driver = :driver AND special_access = 1 ' .
96                'LIMIT 1'
97            );
98            $stmt->execute([':driver' => $this->provider->getDriver()]);
99            $row = $stmt->fetch(PDO::FETCH_ASSOC);
100
101            if (!$row) {
102                return null;
103            }
104
105            $config = [];
106            if (!empty($row['config_parameters']) && is_string($row['config_parameters'])) {
107                $decoded = json_decode($row['config_parameters'], true);
108                if (is_array($decoded)) {
109                    $config = $decoded;
110                }
111            }
112
113            return [
114                'status'  => (string) ($row['status'] ?? 'inactive'),
115                'api_url' => !empty($row['api_url']) ? (string) $row['api_url'] : null,
116                'config'  => $config,
117            ];
118        } catch (Throwable) {
119            return null;
120        }
121    }
122
123    private function getCache(string $key): ?bool
124    {
125        if ($this->cache === null) {
126            return null;
127        }
128
129        try {
130            $cached = $this->cache->psr()->get($key);
131            return is_bool($cached) ? $cached : null;
132        } catch (Throwable) {
133            return null;
134        }
135    }
136
137    private function setCache(string $key, bool $value): void
138    {
139        if ($this->cache === null) {
140            return;
141        }
142
143        try {
144            $this->cache->psr()->set($key, $value, self::CACHE_TTL_SECONDS);
145        } catch (Throwable) {
146            // Gracefully ignore cache writing errors
147        }
148    }
149}