Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.11% covered (success)
98.11%
52 / 53
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserSessionApiController
98.08% covered (success)
98.08%
51 / 52
83.33% covered (warning)
83.33%
5 / 6
19
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
 listSessions
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 revokeSession
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 revokeOtherSessions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 resolveAuthUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveCurrentSessionToken
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\User\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\User\Domain\Repository\UserRepositoryInterface;
12use App\Shared\Infrastructure\Http\ApiResponseTrait;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16use Yiisoft\Session\SessionInterface;
17use Yiisoft\User\CurrentUser;
18
19/**
20 * REST API Controller for Active User Session Management (OWASP ASVS V3, NIST SP 800-63B).
21 *
22 * Exposes endpoints for inspecting active sessions, revoking specific sessions, and
23 * revoking all other active concurrent sessions remotely.
24 *
25 * @package App\Modules\User\Presentation\Api
26 */
27final readonly class UserSessionApiController
28{
29    use ApiResponseTrait;
30
31    private const string ERROR_UNAUTHORIZED = 'Unauthorized access';
32
33    /**
34     * UserSessionApiController constructor.
35     *
36     * @param ResponseFactoryInterface $responseFactory PSR-7 response factory.
37     * @param UserRepositoryInterface $userRepository User repository for session operations.
38     * @param CurrentUser $currentUser Current authenticated user service.
39     * @param SessionInterface $session Active session service.
40     */
41    public function __construct(
42        private ResponseFactoryInterface $responseFactory,
43        private UserRepositoryInterface $userRepository,
44        private CurrentUser $currentUser,
45        private SessionInterface $session
46    ) {
47    }
48
49    /**
50     * Lists active concurrent sessions for currently authenticated user.
51     *
52     * @param ServerRequestInterface $request PSR-7 server request.
53     * @return ResponseInterface JSON API response.
54     */
55    public function listSessions(ServerRequestInterface $request): ResponseInterface
56    {
57        $userId = $this->resolveAuthUserId($request);
58        if ($userId <= 0) {
59            return $this->jsonError($this->responseFactory, self::ERROR_UNAUTHORIZED, 401);
60        }
61
62        $currentToken = $this->resolveCurrentSessionToken();
63        $sessions = $this->userRepository->getUserActiveSessions($userId);
64        $mapped = [];
65
66        foreach ($sessions as $sessionRow) {
67            $isCurrent = $currentToken !== '' && hash_equals($sessionRow['session'], $currentToken);
68            $mapped[] = [
69                'id'            => $sessionRow['id'],
70                'ip_address'    => $sessionRow['ip_address'],
71                'user_agent'    => $sessionRow['user_agent'],
72                'last_activity' => $sessionRow['last_activity'],
73                'created_at'    => $sessionRow['created_at'],
74                'is_current'    => $isCurrent,
75            ];
76        }
77
78        return $this->jsonSuccess($this->responseFactory, [
79            'sessions' => $mapped,
80            'count'    => count($mapped),
81        ]);
82    }
83
84    /**
85     * Revokes a specific active session belonging to current authenticated user.
86     *
87     * @param ServerRequestInterface $request PSR-7 server request.
88     * @return ResponseInterface JSON API response.
89     */
90    public function revokeSession(ServerRequestInterface $request): ResponseInterface
91    {
92        $userId = $this->resolveAuthUserId($request);
93        if ($userId <= 0) {
94            return $this->jsonError($this->responseFactory, self::ERROR_UNAUTHORIZED, 401);
95        }
96
97        $sessionId = (int) ($request->getAttribute('id') ?? 0);
98        if ($sessionId <= 0) {
99            $sessionId = (int) ($this->parseJsonBody($request)['id'] ?? 0);
100        }
101
102        if ($sessionId <= 0 || !$this->userRepository->revokeUserSession($userId, $sessionId)) {
103            $status = $sessionId <= 0 ? 422 : 404;
104            $msg = $sessionId <= 0 ? 'Invalid session identifier.' : 'Session not found or already revoked.';
105            return $this->jsonError($this->responseFactory, $msg, $status);
106        }
107
108        return $this->jsonSuccess($this->responseFactory, [
109            'revoked'    => true,
110            'session_id' => $sessionId,
111            'message'    => 'Session has been successfully revoked.',
112        ]);
113    }
114
115    /**
116     * Revokes all other active sessions for current user except the active one.
117     *
118     * @param ServerRequestInterface $request PSR-7 server request.
119     * @return ResponseInterface JSON API response.
120     */
121    public function revokeOtherSessions(ServerRequestInterface $request): ResponseInterface
122    {
123        $userId = $this->resolveAuthUserId($request);
124        if ($userId <= 0) {
125            return $this->jsonError($this->responseFactory, self::ERROR_UNAUTHORIZED, 401);
126        }
127
128        $currentToken = $this->resolveCurrentSessionToken();
129        if ($currentToken === '') {
130            return $this->jsonError($this->responseFactory, 'Active session token required.', 400);
131        }
132
133        $revokedCount = $this->userRepository->revokeOtherUserSessions($userId, $currentToken);
134
135        return $this->jsonSuccess($this->responseFactory, [
136            'revoked_count' => $revokedCount,
137            'message'       => sprintf('Successfully revoked %d other active session(s).', $revokedCount),
138        ]);
139    }
140
141    /**
142     * Resolves authenticated user ID from request, session, or identity.
143     */
144    private function resolveAuthUserId(ServerRequestInterface $request): int
145    {
146        return $this->resolveCurrentUserId($request, $this->session, $this->currentUser);
147    }
148
149    /**
150     * Resolves current session token string from session instance or native session.
151     */
152    private function resolveCurrentSessionToken(): string
153    {
154        $token = (string) $this->session->getId();
155        if ($token === '' && session_status() === PHP_SESSION_ACTIVE) {
156            $token = session_id();
157        }
158
159        return $token;
160    }
161}