Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.70% covered (warning)
78.70%
85 / 108
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
DualAuthMiddleware
78.70% covered (warning)
78.70%
85 / 108
75.00% covered (warning)
75.00%
9 / 12
41.89
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
 call
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 handleJwtAuth
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 handleLegacyApiKey
28.57% covered (danger)
28.57%
8 / 28
0.00% covered (danger)
0.00%
0 / 1
14.11
 hasValidSession
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 handleSessionAuth
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 setAuthContext
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 unauthorized
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 isPublicRoute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 startsWith
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTokenService
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 addPublicRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Dual Authentication Middleware
4 *
5 * Slim 2.x compatible middleware that supports multiple authentication methods:
6 * - JWT Bearer tokens (modern API)
7 * - Legacy API keys (existing mobile apps)
8 * - PHP Session (web login)
9 *
10 * @package BuyerKiosk\Auth\Middleware
11 */
12
13namespace BuyerKiosk\Auth\Middleware;
14
15use BuyerKiosk\Auth\Services\TokenService;
16
17class DualAuthMiddleware extends \Slim\Middleware
18{
19    /**
20     * @var TokenService|null Token service instance
21     */
22    private $tokenService;
23
24    /**
25     * @var array Routes that don't require authentication
26     */
27    private $publicRoutes = [
28        '/auth/login',
29        '/auth/refresh',
30        '/auth/forgot-password',
31        '/account/login',
32        '/account/register',
33        '/account/forgot-password',
34        '/account/activate',
35        '/account/resend-activation',
36        '/account/captcha',
37        '/api/mobile/auth',
38        '/public'
39    ];
40
41    /**
42     * Constructor
43     *
44     * @param TokenService|null $tokenService Token service (auto-created if null)
45     */
46    public function __construct(?TokenService $tokenService = null)
47    {
48        $this->tokenService = $tokenService;
49    }
50
51    /**
52     * Slim 2 middleware entry point
53     */
54    public function call()
55    {
56        $app = $this->app;
57        $request = $app->request;
58        $path = $request->getPathInfo();
59
60        // Check if route is public
61        if ($this->isPublicRoute($path)) {
62            $this->next->call();
63            return;
64        }
65
66        // Get Authorization header (Slim 2 idiom)
67        $auth = $request->headers->get('Authorization');
68        $auth = !empty($auth) ? $auth : null;
69
70        // Get X-Api-Key header for legacy mobile apps
71        $xApiKey = $request->headers->get('X-Api-Key');
72        $hasXApiKey = !empty($xApiKey);
73
74        // Try JWT Bearer authentication
75        if ($auth !== null && $this->startsWith($auth, 'Bearer ')) {
76            $this->handleJwtAuth($auth);
77            $this->next->call();
78            return;
79        }
80
81        // Try legacy API key authentication
82        if (($auth !== null && $this->startsWith($auth, 'ApiKey ')) || $hasXApiKey) {
83            $apiKey = $hasXApiKey ? $xApiKey : substr($auth, 7);
84            $this->handleLegacyApiKey($apiKey);
85            $this->next->call();
86            return;
87        }
88
89        // Try session authentication
90        if ($this->hasValidSession()) {
91            $this->handleSessionAuth();
92            $this->next->call();
93            return;
94        }
95
96        // No valid authentication
97        $this->unauthorized('No valid authentication provided');
98    }
99
100    /**
101     * Handle JWT Bearer token authentication
102     *
103     * @param string $authHeader Full Authorization header
104     */
105    private function handleJwtAuth(string $authHeader): void
106    {
107        $token = substr($authHeader, 7); // Remove "Bearer "
108
109        $tokenService = $this->getTokenService();
110        $payload = $tokenService->validateAccessToken($token);
111
112        if (!$payload) {
113            $this->unauthorized('Invalid or expired access token');
114            return;
115        }
116
117        // Set user context in app environment
118        $this->setAuthContext([
119            'method' => 'jwt',
120            'user_id' => $payload['sub'] ?? null,
121            'user' => $payload['user'] ?? null,
122            'stores' => $payload['stores'] ?? [],
123            'permissions' => $payload['permissions'] ?? [],
124            'token_id' => $payload['jti'] ?? null
125        ]);
126    }
127
128    /**
129     * Handle legacy API key authentication
130     *
131     * @param string $apiKey The API key
132     */
133    private function handleLegacyApiKey(string $apiKey): void
134    {
135        $app = $this->app;
136
137        // Use existing API key validation from MySqlUser
138        try {
139            $db = dbConnectByName('kiosk_users');
140
141            // Look up API key in uf_apiKey_user table
142            $stmt = $db->prepare("
143                SELECT au.userID, au.active, u.user_name, u.display_name, u.email, u.enabled
144                FROM uf_apiKey_user au
145                JOIN uf_user u ON au.userID = u.id
146                WHERE au.key = :key
147                LIMIT 1
148            ");
149            $stmt->execute(['key' => $apiKey]);
150            $row = $stmt->fetch(\PDO::FETCH_ASSOC);
151
152            if (!$row || !$row['active'] || !$row['enabled']) {
153                $this->unauthorized('Invalid or inactive API key');
154                return;
155            }
156
157            // Load user's stores from legacy store_user table
158            $storeStmt = $db->prepare("
159                SELECT s.typeNum FROM store_user su
160                JOIN stores s ON su.storeID = s.storeID
161                WHERE su.userID = :userId
162            ");
163            $storeStmt->execute(['userId' => $row['userID']]);
164            $stores = $storeStmt->fetchAll(\PDO::FETCH_COLUMN);
165
166            $this->setAuthContext([
167                'method' => 'api_key',
168                'user_id' => $row['userID'],
169                'user' => [
170                    'id' => (int)$row['userID'],
171                    'username' => $row['user_name'],
172                    'displayName' => $row['display_name'],
173                    'email' => $row['email']
174                ],
175                'stores' => $stores,
176                'permissions' => [] // API keys have limited permissions
177            ]);
178
179        } catch (\Exception $e) {
180            error_log("DualAuthMiddleware: API key validation failed - " . $e->getMessage());
181            $this->unauthorized('API key validation failed');
182        }
183    }
184
185    /**
186     * Check if a valid session exists
187     *
188     * @return bool True if valid session
189     */
190    private function hasValidSession(): bool
191    {
192        // Check if session is started and has user
193        if (session_status() !== PHP_SESSION_ACTIVE) {
194            return false;
195        }
196
197        return !empty($_SESSION['userfrosting']['user_id']);
198    }
199
200    /**
201     * Handle session-based authentication
202     */
203    private function handleSessionAuth(): void
204    {
205        $app = $this->app;
206
207        // User is already authenticated via session
208        // The existing UserFrosting user loading will handle this
209        // Just mark the auth method for logging/audit purposes
210        if (isset($app->user) && !$app->user->isGuest()) {
211            $this->setAuthContext([
212                'method' => 'session',
213                'user_id' => $app->user->id,
214                'user' => [
215                    'id' => (int)$app->user->id,
216                    'username' => $app->user->user_name,
217                    'displayName' => $app->user->display_name,
218                    'email' => $app->user->email
219                ],
220                'stores' => $app->user->getStores(),
221                'permissions' => [] // Session users get permissions via normal UserFrosting flow
222            ]);
223        }
224    }
225
226    /**
227     * Set authentication context for downstream use
228     *
229     * @param array $context Authentication context
230     */
231    private function setAuthContext(array $context): void
232    {
233        $app = $this->app;
234
235        // Store in app environment for access by controllers
236        $app->authContext = $context;
237
238        // Also set as environment variable for compatibility
239        $app->environment['AUTH_METHOD'] = $context['method'];
240        $app->environment['AUTH_USER_ID'] = $context['user_id'];
241    }
242
243    /**
244     * Return unauthorized response
245     *
246     * @param string $message Error message
247     */
248    private function unauthorized(string $message = 'Unauthorized'): void
249    {
250        $app = $this->app;
251        $response = $app->response;
252
253        $response->status(401);
254        $response->headers->set('Content-Type', 'application/json');
255        $response->headers->set('WWW-Authenticate', 'Bearer realm="BuyerKiosk API"');
256
257        $response->body(json_encode([
258            'error' => 'unauthorized',
259            'message' => $message
260        ]));
261
262        // Stop middleware chain
263        $app->stop();
264    }
265
266    /**
267     * Check if a route is public (doesn't require auth)
268     *
269     * @param string $path Request path
270     * @return bool True if public
271     */
272    private function isPublicRoute(string $path): bool
273    {
274        foreach ($this->publicRoutes as $route) {
275            if ($this->startsWith($path, $route)) {
276                return true;
277            }
278        }
279        return false;
280    }
281
282    /**
283     * PHP 7 compatible string prefix check
284     *
285     * @param string $haystack String to check
286     * @param string $needle Prefix to look for
287     * @return bool True if haystack starts with needle
288     */
289    private function startsWith(string $haystack, string $needle): bool
290    {
291        return strncmp($haystack, $needle, strlen($needle)) === 0;
292    }
293
294    /**
295     * Get or create TokenService instance
296     *
297     * @return TokenService
298     */
299    private function getTokenService(): TokenService
300    {
301        if ($this->tokenService === null) {
302            $db = dbConnectByName('kiosk_users');
303            $this->tokenService = new TokenService($db);
304        }
305        return $this->tokenService;
306    }
307
308    /**
309     * Add a route to the public routes list
310     *
311     * @param string $route Route pattern
312     */
313    public function addPublicRoute(string $route): void
314    {
315        $this->publicRoutes[] = $route;
316    }
317}