Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.49% covered (success)
91.49%
43 / 47
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Session
91.49% covered (success)
91.49%
43 / 47
84.62% covered (warning)
84.62%
11 / 13
26.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 setUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 holds
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 authenticatedUserId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 remember
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 forgetRemembered
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getAuthToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 signalActivity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lastActivity
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 read
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 stamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rememberCookieOptions
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
3.03
 sameSite
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
6.60
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Session\Session as BaseSession;
8use SessionHandlerInterface;
9
10/**
11 * The CMS session: the signed-in user and the remember-me cookie. Session
12 * data goes through celema/session; the remember-me cookie is read from
13 * the request's cookies, not from `$_COOKIE`.
14 */
15class Session extends BaseSession
16{
17    protected string $authCookie;
18    protected ?string $authToken;
19
20    /**
21     * @param array<string, mixed> $options
22     * @param array<array-key, mixed> $cookies The request's cookies.
23     */
24    public function __construct(
25        array $options = [],
26        string $name = '',
27        ?SessionHandlerInterface $handler = null,
28        array $cookies = [],
29    ) {
30        parent::__construct($options, $name, $handler);
31
32        $this->authCookie = $name ? $name . '_auth' : 'cosray_auth';
33        $token = $cookies[$this->authCookie] ?? null;
34        $this->authToken = is_string($token) && $token !== '' ? $token : null;
35    }
36
37    public function setUser(User $user): void
38    {
39        $this->set('user_id', $user->id);
40        $this->set('user_stamp', self::stamp($user));
41    }
42
43    /**
44     * A session opened before the user's password last changed no longer
45     * counts. One without a stamp predates the stamp and is left alone.
46     */
47    public function holds(User $user): bool
48    {
49        $stamp = $this->read('user_stamp');
50
51        return !is_string($stamp) || hash_equals($stamp, self::stamp($user));
52    }
53
54    public function authenticatedUserId(): ?int
55    {
56        $id = $this->read('user_id');
57
58        return is_int($id) ? $id : null;
59    }
60
61    public function remember(#[\SensitiveParameter] Token $token, int $expires): void
62    {
63        $value = $token->get();
64        // Later reads in the same request see the new token.
65        $this->authToken = $value;
66
67        setcookie(
68            $this->authCookie,
69            $value,
70            $this->rememberCookieOptions($expires),
71        );
72    }
73
74    public function forgetRemembered(): void
75    {
76        $this->authToken = null;
77
78        setcookie(
79            $this->authCookie,
80            '',
81            $this->rememberCookieOptions(time() - (60 * 60 * 24)),
82        );
83    }
84
85    public function getAuthToken(): ?string
86    {
87        return $this->authToken;
88    }
89
90    public function signalActivity(): void
91    {
92        $this->set('last_activity', time());
93    }
94
95    public function lastActivity(): ?int
96    {
97        $time = $this->read('last_activity');
98
99        return is_int($time) ? $time : null;
100    }
101
102    /**
103     * A value of the current session, or null when the session is not
104     * active, for example after it was destroyed by a logout.
105     *
106     * @param non-empty-string $key
107     */
108    private function read(string $key): mixed
109    {
110        return $this->active() ? $this->get($key, null) : null;
111    }
112
113    private static function stamp(User $user): string
114    {
115        return substr(hash('sha256', $user->password), 0, 32);
116    }
117
118    /** @return array{expires: int, path: string, domain?: string, secure: bool, httponly: bool, samesite: 'Lax'|'Strict'|'None', partitioned?: bool} */
119    private function rememberCookieOptions(int $expires): array
120    {
121        $options = [
122            'expires' => $expires,
123            'path' => (string) ($this->options['cookie_path'] ?? '/'),
124            'secure' => (bool) ($this->options['cookie_secure'] ?? true),
125            'httponly' => (bool) ($this->options['cookie_httponly'] ?? true),
126            'samesite' => self::sameSite($this->options['cookie_samesite'] ?? 'Lax'),
127        ];
128
129        $domain = (string) ($this->options['cookie_domain'] ?? '');
130        if ($domain !== '') {
131            $options['domain'] = $domain;
132        }
133
134        if (isset($this->options['cookie_partitioned'])) {
135            $options['partitioned'] = (bool) $this->options['cookie_partitioned'];
136        }
137
138        return $options;
139    }
140
141    /** @return 'Lax'|'Strict'|'None' */
142    private static function sameSite(mixed $value): string
143    {
144        return match (strtolower(is_string($value) ? $value : '')) {
145            'strict' => 'Strict',
146            'none' => 'None',
147            default => 'Lax',
148        };
149    }
150}