Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.07% covered (success)
94.07%
111 / 118
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Auth
94.07% covered (success)
94.07%
111 / 118
64.71% covered (warning)
64.71%
11 / 17
51.54
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
 logout
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 authenticate
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 authenticateByOneTimeToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getOneTimeToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 invalidateOneTimeToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 user
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 userFromToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 clientIp
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 getAuthToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 remember
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 login
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 startSession
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 rememberUser
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 forgetRemembered
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getSessionTokenHash
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 secret
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 Cosray;
6
7use Cosray\Util\IpRange;
8use Psr\Http\Message\ServerRequestInterface as Request;
9use Psr\Log\LoggerInterface as Logger;
10use Psr\Log\NullLogger;
11use RuntimeException;
12
13class Auth
14{
15    /**
16     * An Argon2id hash with PHP's default cost, like stored passwords. Unknown
17     * logins are verified against it so they take as long as a wrong password
18     * and response times don't reveal which accounts exist.
19     */
20    private const string UNKNOWN_ACCOUNT_HASH = '$argon2id$v=19$m=65536,t=4,p=1$LmdRei83enV3ZU80a3JCcA$nNwR27KMtv19PZECIhkY4mRM9Xp2bngKjiRHZdZ9lwM';
21
22    public function __construct(
23        protected Request $request,
24        protected Users $users,
25        protected Config $config,
26        protected ?Session $session = null,
27        protected Logger $logger = new NullLogger(),
28    ) {}
29
30    public function logout(): void
31    {
32        if (!$this->session) {
33            return;
34        }
35
36        $session = $this->session;
37        $hash = $this->getSessionTokenHash();
38
39        if ($hash) {
40            $this->users->forget($hash);
41            $session->forgetRemembered();
42        }
43
44        if ($session->active()) {
45            $session->destroy();
46        }
47    }
48
49    public function authenticate(
50        string $login,
51        #[\SensitiveParameter]
52        string $password,
53        bool $remember,
54        bool $initSession,
55    ): User|false {
56        $user = $this->users->byLogin($login);
57
58        if (!$user) {
59            password_verify($password, self::UNKNOWN_ACCOUNT_HASH);
60            // The submitted name stays out of the log: it may be a password
61            // typed into the wrong field.
62            $this->logger->warning('Login failed for an unknown account from {ip}', ['ip' => $this->clientIp()]);
63
64            return false;
65        }
66
67        if (!password_verify($password, $user->password)) {
68            $this->logger->warning('Login failed for {login} from {ip}', [
69                'login' => $user->loginName(),
70                'ip' => $this->clientIp(),
71            ]);
72
73            return false;
74        }
75
76        if ($initSession) {
77            $this->login($user, $remember);
78        }
79
80        $this->logger->info('Login succeeded for {login} from {ip}', [
81            'login' => $user->loginName(),
82            'ip' => $this->clientIp(),
83        ]);
84
85        return $user;
86    }
87
88    public function authenticateByOneTimeToken(
89        #[\SensitiveParameter]
90        string $token,
91        bool $initSession,
92    ): User|false {
93        $user = $this->users->byOneTimeToken($token);
94
95        if (!$user) {
96            $this->logger->warning('One-time token login failed from {ip}', ['ip' => $this->clientIp()]);
97
98            return false;
99        }
100
101        if ($initSession) {
102            $this->login($user, false);
103        }
104
105        $this->logger->info('One-time token login succeeded for {login} from {ip}', [
106            'login' => $user->loginName(),
107            'ip' => $this->clientIp(),
108        ]);
109
110        return $user;
111    }
112
113    public function getOneTimeToken(
114        #[\SensitiveParameter]
115        string $token,
116    ): string|false {
117        $user = $this->users->byAuthToken($token);
118
119        if (!$user) {
120            return false;
121        }
122
123        return $this->users->createOneTimeToken($user->id);
124    }
125
126    public function invalidateOneTimeToken(
127        #[\SensitiveParameter]
128        string $token,
129    ): void {
130        $this->users->removeOneTimeToken($token);
131    }
132
133    public function user(): ?User
134    {
135        // A request with a bearer token skips the CSRF check, so its cookies
136        // must not authenticate it: it acts as the token's user or as no one.
137        if (!$this->session || $this->getAuthToken() !== '') {
138            return $this->userFromToken();
139        }
140
141        // Verify if user is logged in via cookie session
142        $userId = $this->session->authenticatedUserId();
143
144        if ($userId) {
145            $user = $this->users->byId($userId);
146
147            return $user !== null && $this->session->holds($user) ? $user : null;
148        }
149
150        $hash = $this->getSessionTokenHash();
151
152        if ($hash) {
153            $user = $this->users->bySession($hash);
154
155            if ($user && $user->expires !== null && strtotime($user->expires) >= time()) {
156                $this->startSession($user);
157                $this->rememberUser($user->id);
158
159                return $user;
160            }
161
162            $this->users->forget($hash);
163            $this->session->forgetRemembered();
164        }
165
166        // Fall back to token auth if session auth failed
167        return $this->userFromToken();
168    }
169
170    protected function userFromToken(): ?User
171    {
172        $authToken = $this->getAuthToken();
173
174        if ($authToken) {
175            return $this->users->byAuthToken($authToken);
176        }
177
178        return null;
179    }
180
181    /**
182     * The connecting address, or behind the proxies of `app.trusted_proxies`
183     * the last address in `X-Forwarded-For` that is not one of them. Without
184     * trusted proxies the header is ignored, as anyone can send it.
185     */
186    public function clientIp(): string
187    {
188        $client = $this->request->getServerParams()['REMOTE_ADDR'] ?? null;
189
190        if (!is_string($client) || $client === '') {
191            return 'unknown';
192        }
193
194        $proxies = $this->config->app->trustedProxies;
195        $hops = array_map(trim(...), explode(',', $this->request->getHeaderLine('X-Forwarded-For')));
196
197        foreach (array_reverse($hops) as $hop) {
198            $trusted = array_any($proxies, static fn(string $proxy): bool => IpRange::contains($proxy, $client));
199
200            if (!$trusted || filter_var($hop, FILTER_VALIDATE_IP) === false) {
201                break;
202            }
203
204            $client = $hop;
205        }
206
207        return $client;
208    }
209
210    public function getAuthToken(): string
211    {
212        $header = $this->request->getHeaderLine('Authorization');
213
214        return preg_match('/^Bearer\s+(\S+)\s*$/i', $header, $matches) === 1 ? $matches[1] : '';
215    }
216
217    protected function remember(int $userId): RememberDetails
218    {
219        $token = new Token($this->secret());
220        $expires = time() + $this->config->auth->rememberLifetime;
221
222        $remembered = $this->users->remember(
223            $token->hash(),
224            $userId,
225            date(DATE_ATOM, $expires),
226        );
227
228        if ($remembered) {
229            return new RememberDetails($token, $expires);
230        }
231
232        throw new RuntimeException('Could not remember user');
233    }
234
235    protected function login(User $user, bool $remember): void
236    {
237        $this->startSession($user);
238
239        if ($remember) {
240            $this->rememberUser($user->id);
241        } else {
242            $this->forgetRemembered();
243        }
244    }
245
246    private function startSession(User $user): void
247    {
248        $session = $this->session;
249
250        if (!$session) {
251            throw new RuntimeException('Cannot initialize auth session without session service');
252        }
253
254        if (!$session->active()) {
255            $session->start();
256        }
257
258        // Regenerate the session id before setting the user id
259        // to mitigate session fixation attack.
260        $session->regenerate();
261        $session->setUser($user);
262    }
263
264    private function rememberUser(int $userId): void
265    {
266        if (!$this->session) {
267            throw new RuntimeException('Cannot remember user without session service');
268        }
269
270        $details = $this->remember($userId);
271
272        $this->session->remember(
273            $details->token,
274            $details->expires,
275        );
276    }
277
278    private function forgetRemembered(): void
279    {
280        if (!$this->session) {
281            return;
282        }
283
284        $hash = $this->getSessionTokenHash();
285
286        if ($hash !== null) {
287            $this->users->forget($hash);
288            $this->session->forgetRemembered();
289        }
290    }
291
292    protected function getSessionTokenHash(): ?string
293    {
294        if (!$this->session) {
295            return null;
296        }
297
298        $token = $this->session->getAuthToken();
299
300        if ($token) {
301            return new Token($this->secret(), $token)->hash();
302        }
303
304        return null;
305    }
306
307    private function secret(): string
308    {
309        return $this->config->app->secret ?? throw new RuntimeException('Login tokens need app.secret (APP_SECRET)');
310    }
311}