Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 534
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccountController
0.00% covered (danger)
0.00%
0 / 534
0.00% covered (danger)
0.00%
0 / 22
14762
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isAjaxRequest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 haltJson
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 redirectToLogin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 failLogin
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 pageHome
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 pageLogin
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 pageRegister
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 pageForgotPassword
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 pageResetPassword
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 pageResendActivation
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 pageAccountSettings
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 login
0.00% covered (danger)
0.00%
0 / 110
0.00% covered (danger)
0.00%
0 / 1
342
 logout
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 register
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
272
 activate
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 forgotPassword
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
56
 resetPassword
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
 denyResetPassword
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 resendActivation
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
72
 accountSettings
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
702
 captcha
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Core\Controllers;
4
5/*******
6
7/account/*
8
9*******/
10
11// Handles account-related activities, including login, registration, password recovery, and account settings
12class AccountController extends BaseController {
13
14    public function __construct($app){
15        $this->_app = $app;
16    }
17
18    private function isAjaxRequest(): bool
19    {
20        $headers = $this->_app->request->headers;
21
22        $xRequestedWith = $headers->get('X-Requested-With');
23        if (is_string($xRequestedWith) && strtolower($xRequestedWith) === 'xmlhttprequest') {
24            return true;
25        }
26
27        $accept = $headers->get('Accept');
28        return is_string($accept) && stripos($accept, 'application/json') !== false;
29    }
30
31    private function haltJson(int $status, array $payload): void
32    {
33        $this->_app->response->headers->set('Content-Type', 'application/json');
34        $this->_app->halt($status, json_encode($payload));
35    }
36
37    private function redirectToLogin(): void
38    {
39        $this->_app->redirect('/account/login', 303);
40    }
41
42    private function failLogin(int $status, array $payload = []): void
43    {
44        if ($this->isAjaxRequest()) {
45            $this->haltJson($status, $payload ?: ['success' => false]);
46        }
47
48        $this->redirectToLogin();
49    }
50
51    public function pageHome(){
52        $this->_app->render('common/home.html', [
53            'page' => [
54                'author' =>         $this->_app->site->author,
55                'title' =>          "A secure, modern user management system based on UserCake, jQuery, and Bootstrap.",
56                'description' =>    "Main landing page for public access to this website.",
57                'alerts' =>         $this->_app->alerts->getAndClearMessages(),
58                'active_page' =>    ""
59            ],
60            //'stats' => getBuyerKioskStats()
61
62        ]);
63    }
64
65    public function pageLogin($loginType = null, $typeNum = null){
66        // Forward to home page if user is already logged in
67        if (!$this->_app->user->isGuest()){
68            $landing_page = $this->_app->user->landing_page;
69            // Remove www.buyerkiosk.com and convert to relative path
70            $landing_page = str_replace(['https://www.buyerkiosk.com', 'http://www.buyerkiosk.com', 'https://buyerkiosk.com', 'http://buyerkiosk.com'], '', $landing_page);
71            // Ensure it starts with /
72            if (!empty($landing_page) && $landing_page[0] !== '/') {
73                $landing_page = '/' . $landing_page;
74            }
75            $this->_app->redirect($landing_page);
76        }
77
78        $schema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/login.json");
79        $validators = new \Fortress\FormValidationAdapter($this->_app->translator, $schema);
80
81        // Generate CSRF token for the login form
82        $csrf_key = 'csrf_token';
83        $csrf_token = \NoCSRF::generate($csrf_key);
84
85        $this->_app->render('common/login.html', [
86            'page' => [
87                'author' =>         $this->_app->site->author,
88                'loginType' =>      $loginType,
89                'typeNum' =>        $typeNum,
90                'title' =>          "Login",
91                'description' =>    "Login to your UserFrosting account.",
92                'alerts' =>         $this->_app->alerts->getAndClearMessages(),     // Starting to violate the Law of Demeter here...
93                'active_page' =>    "account/login",
94            ],
95            'validators' => $validators->formValidationRulesJson(),
96            'csrf_key' => $csrf_key,
97            'csrf_token' => $csrf_token
98        ]);
99    }
100
101
102    public function pageRegister($can_register = false){
103        // Get the alert message stream
104        $ms = $this->_app->alerts;
105
106        // Prevent the user from registering if he/she is already logged in
107        if(!$this->_app->user->isGuest()) {
108            $ms->addMessageTranslated("danger", "ACCOUNT_REGISTRATION_LOGOUT");
109            $this->_app->redirect($this->_app->urlFor('uri_home'));
110        }
111
112        // Security measure: do not allow registering new users until the master account has been created.
113        if (!\UserFrosting\UserLoader::exists($this->_app->config('user_id_master'))){
114            $ms->addMessageTranslated("danger", "MASTER_ACCOUNT_NOT_EXISTS");
115            $this->_app->redirect($this->_app->urlFor('uri_install'));
116        }
117
118        // Load validator rules
119        $schema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/register.json");
120        $validators = new \Fortress\FormValidationAdapter($this->_app->translator, $schema);
121
122        $settings = $this->_app->site;
123
124        // If registration is disabled, send them back to the home page with an error message
125        if (!$settings->can_register){
126            $this->_app->alerts->addMessageTranslated("danger", "ACCOUNT_REGISTRATION_DISABLED");
127            $this->_app->redirect('login');
128        }
129
130        $this->_app->render('common/register.html', [
131            'page' => [
132                'author' =>         $settings->author,
133                'title' =>          "Register",
134                'description' =>    "Register for a new UserFrosting account.",
135                'alerts' =>         $this->_app->alerts->getAndClearMessages(),
136                'active_page' =>    "account/register"
137            ],
138            'captcha_image' =>  $this->generateCaptcha(),
139            'validators' => $validators->formValidationRulesJson()
140        ]);
141    }
142
143    public function pageForgotPassword(){
144
145        $schema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/forgot-password.json");
146        $validators = new \Fortress\FormValidationAdapter($this->_app->translator, $schema);
147
148       $this->_app->render('common/forgot-password.html', [
149            'page' => [
150                'author' =>         $this->_app->site->author,
151                'title' =>          "Reset Password",
152                'description' =>    "Reset your UserFrosting password.",
153                'alerts' =>         $this->_app->alerts->getAndClearMessages(),
154                'active_page' =>    ""
155            ],
156            'validators' => $validators->formValidationRulesJson()
157        ]);
158    }
159
160    public function pageResetPassword(){
161
162        $schema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/reset-password.json");
163        $validators = new \Fortress\FormValidationAdapter($this->_app->translator, $schema);
164
165       $this->_app->render('common/reset-password.html', [
166            'page' => [
167                'author' =>         $this->_app->site->author,
168                'title' =>          "Choose a New Password",
169                'description' =>    "Reset your UserFrosting password.",
170                'alerts' =>         $this->_app->alerts->getAndClearMessages(),
171                'active_page' =>    ""
172            ],
173            'activation_token' => $this->_app->request->get()['activation_token'],
174            'validators' => $validators->formValidationRulesJson()
175        ]);
176    }
177
178    public function pageResendActivation(){
179
180        $schema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/resend-activation.json");
181        $validators = new \Fortress\FormValidationAdapter($this->_app->translator, $schema);
182
183        $this->_app->render('common/resend-activation.html', [
184            'page' => [
185                'author' =>         $this->_app->site->author,
186                'title' =>          "Resend Activation",
187                'description' =>    "Resend the activation email for your new UserFrosting account.",
188                'alerts' =>         $this->_app->alerts->getAndClearMessages(),
189                'active_page' =>    ""
190            ],
191            'validators' => $validators->formValidationRulesJson()
192        ]);
193    }
194
195    public function pageAccountSettings($typeNum = null){
196        // Access-controlled page
197        if (!$this->_app->user->checkAccess('uri_account_settings')){
198            $this->_app->notFound();
199        }
200        $schema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/account-settings.json");
201        $validators = new \Fortress\FormValidationAdapter($this->_app->translator, $schema);
202        if(isset($typeNum)) {
203            $store = new \Store();
204            $store->createStore($typeNum);
205
206            $this->_app->render('account-settings.html', [
207                'page' => [
208                    'author' =>         $this->_app->site->author,
209                    'title' =>          "Account Settings",
210                    'description' =>    "Update your account settings, including email, display name, and password.",
211                    'alerts' =>         $this->_app->alerts->getAndClearMessages()
212                ],
213                "locales" => $this->_app->site->getLocales(),
214                "validators" => $validators->formValidationRulesJson(),
215                "store" => getStoreInfo($store),
216                "dailyEmail" => $this->_app->user->dailyReport
217            ]);
218        } else {
219            $this->_app->render('account-settings.html', [
220                'page' => [
221                    'author' =>         $this->_app->site->author,
222                    'title' =>          "Account Settings",
223                    'description' =>    "Update your account settings, including email, display name, and password.",
224                    'alerts' =>         $this->_app->alerts->getAndClearMessages()
225                ],
226                "locales" => $this->_app->site->getLocales(),
227                "validators" => $validators->formValidationRulesJson(),
228                "dailyEmail" => $this->_app->user->dailyReport
229            ]);
230        }
231
232
233
234
235
236    }
237
238    public function login(){
239        // Load the request schema
240        $requestSchema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/login.json");
241
242        // Get the alert message stream
243        $ms = $this->_app->alerts;
244        // Forward the user to their default page if he/she is already logged in
245        if(!$this->_app->user->isGuest()) {
246            $ms->addMessageTranslated("warning", "LOGIN_ALREADY_COMPLETE");
247            if ($this->isAjaxRequest()) {
248                $this->haltJson(200, ['success' => true]);
249            }
250            $this->_app->redirect('/redirect', 303);
251            return;
252        }
253
254        // CSRF token check - validates token from login form
255        // Using $multiple=true to allow retry on failed login without regenerating token
256        if (!\NoCSRF::check('csrf_token', $this->_app->request->post(), false, null, true)) {
257            $ms->addMessageTranslated("danger", "Your session has expired. Please refresh the page and try again.");
258            $this->failLogin(403);
259            return;
260        }
261
262        // Rate limiting check (per IP) - protects against brute force attacks
263        $rateLimiter = new \BuyerKiosk\Auth\Services\RateLimiter();
264
265        // Initialize audit logger for authentication events
266        $auditLogger = new \BuyerKiosk\Auth\Services\AuditLogger(dbConnectByName('kiosk_users'));
267        $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
268        $rateResult = $rateLimiter->checkLimit('login', $clientIp);
269
270        if ($rateResult->isBlocked()) {
271            $ms->addMessageTranslated("danger", "Too many login attempts. Please try again in " . ceil($rateResult->getRetryAfter() / 60) . " minutes.");
272            $this->failLogin(429);
273            return;
274        }
275
276        // Set up Fortress to process the request
277        $postData = $this->_app->request->post();
278
279        $rf = new \Fortress\HTTPRequestFortress($ms, $requestSchema, $postData);
280
281        // Sanitize data
282        $rf->sanitize();
283
284        // Validate, and halt on validation errors.
285        if (!$rf->validate(true)) {
286            $this->failLogin(400);
287            return;
288        }
289
290        // Get the filtered data
291        $data = $rf->data();
292
293        // Determine whether we are trying to log in with an email address or a username
294        $isEmail = filter_var($data['user_name'], FILTER_VALIDATE_EMAIL);
295
296        // If it's an email address, but email login is not enabled, raise an error.
297        if ($isEmail && !$this->_app->site->email_login){
298            $ms->addMessageTranslated("danger", "ACCOUNT_USER_OR_PASS_INVALID");
299            error_log("Email login is not enabled, but user tried to login with email address");
300            $this->failLogin(403);
301            return;
302        }
303
304        // Load user by email address
305        if($isEmail){
306            $user = \UserFrosting\UserLoader::fetch($data['user_name'], 'email');
307            if (!$user){
308                $ms->addMessageTranslated("danger", "ACCOUNT_USER_OR_PASS_INVALID");
309                error_log("Email login is not enabled, but user tried to login with email address");
310                $this->failLogin(403);
311                return;
312            }
313        // Load user by user name
314        } else {
315            $user = \UserFrosting\UserLoader::fetch($data['user_name'], 'user_name');
316            if (!$user) {
317                error_log("No user found with user name: " . $data['user_name']);
318                $ms->addMessageTranslated("danger", "ACCOUNT_USER_OR_PASS_INVALID");
319                $this->failLogin(403);
320                return;
321            }
322        }
323
324
325        // Check that the user's account is enabled
326        if ($user->enabled == 0){
327            error_log("User account is disabled: " . $user->user_name);
328            $ms->addMessageTranslated("danger", "ACCOUNT_DISABLED");
329            $this->failLogin(403);
330            return;
331        }
332
333        // Check that the user's account is activated
334        if ($user->active == 0) {
335            error_log("User account is not activated: " . $user->user_name);
336            $ms->addMessageTranslated("danger", "ACCOUNT_INACTIVE");
337            $this->failLogin(403);
338            return;
339        }
340
341        // Account lockout check (per-user) - protects against credential stuffing across multiple IPs
342        $userIdentifier = strtolower($user->email ?: $user->user_name);
343        $userRateResult = $rateLimiter->checkLimit('login_user', $userIdentifier);
344
345        if ($userRateResult->isBlocked()) {
346            error_log("Account locked due to failed login attempts: " . $user->user_name);
347            $ms->addMessageTranslated("danger", "Account temporarily locked due to multiple failed login attempts. Please try again in " . ceil($userRateResult->getRetryAfter() / 60) . " minutes.");
348            $this->failLogin(403);
349            return;
350        }
351
352        // Here is my password.  May I please assume the identify of this user now?
353        if ($user->verifyPassword($data['password']))  {
354            // Clear rate limits on successful login
355            $rateLimiter->clearAttempts('login', $clientIp);
356            $rateLimiter->clearAttempts('login_user', $userIdentifier);
357
358            $user->login($data['password']);
359            session_regenerate_id();
360
361            // Handle remember-me using our secure service
362            if(!empty($data['rememberme'])) {
363                $token = $this->_app->remember_me_service->createToken($user->id);
364                $this->_app->remember_me_service->setCookie($token);
365            } else {
366                $this->_app->remember_me_service->clearCookie();
367            }
368            // Create the session
369            $_SESSION["userfrosting"]["user"] = $user;
370            $this->_app->user = $_SESSION["userfrosting"]["user"];
371
372            $tokenId    = base64_encode(openssl_random_pseudo_bytes(128));
373            $issuedAt   = time();
374            $notBefore  = $issuedAt + 10;             //Adding 10 seconds
375            $expire     = $notBefore + 60;            // Adding 60 seconds
376            $serverName = serverName;// Retrieve the server name from config file
377            /*
378             * Create the token as an array
379             */
380            $data = [
381                'iat'  => $issuedAt,         // Issued at: time when the token was generated
382                'jti'  => $tokenId,          // Json Token Id: an unique identifier for the token
383                'iss'  => $serverName,       // Issuer
384                'nbf'  => $notBefore,        // Not before
385                'exp'  => $expire,           // Expire
386                'data' => [                  // Data related to the signer user
387                    'userName' => $data['user_name'], // User name
388                ]
389            ];
390            $secretKey = base64_decode(SECRET_KEY);
391            $jwt = \JWT::encode(
392                $data,      //Data to be encoded in the JWT
393                $secretKey, // The signing key
394                'HS256'     // Algorithm used to sign the token, see https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40#section-3
395            );
396
397            $unencodedArray = ['jwt' => $jwt];
398            setcookie("pineapple", base64_encode($jwt), time()+259200, '/');
399
400            // Log successful login to audit log
401            $auditLogger->logLogin($user->id, 'password');
402
403            $ms->addMessageTranslated("success", "ACCOUNT_WELCOME", $this->_app->user->export());
404        } else {
405            // Record failed login attempt for rate limiting (both per-IP and per-user)
406            $rateLimiter->recordFailure('login', $clientIp);
407            $rateLimiter->recordFailure('login_user', $userIdentifier);
408
409            // Log failed login to audit log
410            $auditLogger->logLoginFailed($user->id, 'invalid_password', $user->user_name);
411
412            //Again, we know the password is at fault here, but lets not give away the combination in case of someone bruteforcing
413            error_log("Invalid password for user: " . $user->user_name);
414            $ms->addMessageTranslated("danger", "ACCOUNT_USER_OR_PASS_INVALID");
415            $this->failLogin(403);
416            return;
417        }
418
419        if ($this->isAjaxRequest()) {
420            $this->haltJson(200, ['success' => true]);
421        }
422
423        $this->_app->redirect('/redirect', 303);
424        return;
425    }
426
427    public function logout($complete = false){
428        // Log logout to audit log before destroying session
429        if (!$this->_app->user->isGuest()) {
430            $auditLogger = new \BuyerKiosk\Auth\Services\AuditLogger(dbConnectByName('kiosk_users'));
431            $auditLogger->logLogout($this->_app->user->id, $complete ? 'manual_all_devices' : 'manual');
432        }
433
434        // Revoke remember-me tokens using our secure service
435        if ($complete) {
436            // Full logout: revoke ALL tokens for this user (logout from all devices)
437            $this->_app->remember_me_service->revokeAllForUser($this->_app->user->id);
438        }
439
440        // Always clear the cookie on this device
441        $this->_app->remember_me_service->clearCookie();
442
443        session_regenerate_id(true);
444        session_destroy();
445        $this->_app->deleteCookie('UserFrosting');
446        setcookie('pineapple', '', time() - 3600, '/');
447        $this->_app->redirect("/");
448    }
449
450    public function register(){
451        // POST: user_name, display_name, email, title, password, passwordc, captcha, spiderbro, csrf_token
452        $post = $this->_app->request->post();
453
454        // Get the alert message stream
455        $ms = $this->_app->alerts;
456
457        // Check the honeypot. 'spiderbro' is not a real field, it is hidden on the main page and must be submitted with its default value for this to be processed.
458        if (!$post['spiderbro'] || $post['spiderbro'] != "http://"){
459            error_log("Possible spam received:" . print_r($this->_app->request->post(), true));
460            $ms->addMessage("danger", "Aww hellllls no!");
461            $this->_app->halt(500);     // Don't let on about why the request failed ;-)
462        }
463
464        // Load the request schema
465        $requestSchema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/register.json");
466
467        // Set up Fortress to process the request
468        $rf = new \Fortress\HTTPRequestFortress($ms, $requestSchema, $post);
469
470        // Security measure: do not allow registering new users until the master account has been created.
471        if (!\UserFrosting\UserLoader::exists($this->_app->config('user_id_master'))){
472            $ms->addMessageTranslated("danger", "MASTER_ACCOUNT_NOT_EXISTS");
473            $this->_app->halt(403);
474        }
475
476        // Check if registration is currently enabled
477        if (!$this->_app->site->can_register){
478            $ms->addMessageTranslated("danger", "ACCOUNT_REGISTRATION_DISABLED");
479            $this->_app->halt(403);
480        }
481
482        // Prevent the user from registering if he/she is already logged in
483        if(!$this->_app->user->isGuest()) {
484            $ms->addMessageTranslated("danger", "ACCOUNT_REGISTRATION_LOGOUT");
485            $this->_app->halt(200);
486        }
487
488        // Sanitize data
489        $rf->sanitize();
490
491        // Validate, and halt on validation errors.
492        $error = !$rf->validate(true);
493
494        // Get the filtered data
495        $data = $rf->data();
496
497        // Check captcha, if required
498        if ($this->_app->site->enable_captcha == "1"){
499            if (!$data['captcha'] || md5($data['captcha']) != $_SESSION['userfrosting']['captcha']){
500                $ms->addMessageTranslated("danger", "CAPTCHA_FAIL");
501                $error = true;
502            }
503        }
504
505        // Remove captcha, password confirmation from object data
506        $rf->removeFields(['captcha', 'passwordc']);
507
508        // Perform desired data transformations.  Is this a feature we could add to Fortress?
509        $data['user_name'] = strtolower(trim($data['user_name']));
510        $data['display_name'] = trim($data['display_name']);
511        $data['email'] = strtolower(trim($data['email']));
512        $data['locale'] = $this->_app->site->default_locale;
513
514        if ($this->_app->site->require_activation)
515            $data['active'] = 0;
516        else
517            $data['active'] = 1;
518
519        // Check if username or email already exists
520        if (\UserFrosting\UserLoader::exists($data['user_name'], 'user_name')){
521            $ms->addMessageTranslated("danger", "ACCOUNT_USERNAME_IN_USE", $data);
522            $error = true;
523        }
524
525        if (\UserFrosting\UserLoader::exists($data['email'], 'email')){
526            $ms->addMessageTranslated("danger", "ACCOUNT_EMAIL_IN_USE", $data);
527            $error = true;
528        }
529
530        // Halt on any validation errors
531        if ($error) {
532            $this->_app->halt(400);
533        }
534
535        // Get default primary group (is_default = GROUP_DEFAULT_PRIMARY)
536        $primaryGroup = \UserFrosting\GroupLoader::fetch(GROUP_DEFAULT_PRIMARY, "is_default");
537        $data['primary_group_id'] = $primaryGroup->id;
538        // Set default title for new users
539        $data['title'] = $primaryGroup->new_user_title;
540        // Hash password
541        $data['password'] = \UserFrosting\Authentication::hashPassword($data['password']);
542
543        // Create the user
544        $user = new \UserFrosting\User($data);
545
546        // Add user to default groups, including default primary group
547        $defaultGroups = \UserFrosting\GroupLoader::fetchAll(GROUP_DEFAULT, "is_default");
548        $user->addGroup($primaryGroup->id);
549        foreach ($defaultGroups as $group_id => $group)
550            $user->addGroup($group_id);
551
552        // Store new user to database
553        $user->store();
554        if ($this->_app->site->require_activation) {
555            // Create and send activation email
556
557            $mail = new \PHPMailer;
558
559            $mail->From = $this->_app->site->admin_email;
560            $mail->FromName = $this->_app->site->site_title;
561            $mail->addAddress($user->email);     // Add a recipient
562            $mail->addReplyTo($this->_app->site->admin_email, $this->_app->site->site_title);
563
564            $mail->Subject = $this->_app->site->site_title . " - please activate your account";
565            $mail->Body    = $this->_app->view()->render("common/mail/activate-new.html", [
566                "user" => $user
567            ]);
568
569            $mail->isHTML(true);                                  // Set email format to HTML
570
571            if(!$mail->send()) {
572                $ms->addMessageTranslated("danger", "MAIL_ERROR");
573                error_log('Mailer Error: ' . $mail->ErrorInfo);
574                $this->_app->halt(500);
575            }
576
577            // Activation required
578            $ms->addMessageTranslated("success", "ACCOUNT_REGISTRATION_COMPLETE_TYPE2");
579        } else
580            // No activation required
581            $ms->addMessageTranslated("success", "ACCOUNT_REGISTRATION_COMPLETE_TYPE1");
582
583    }
584
585    // Allow a newly registered user to activate their account via an email link
586    public function activate(){
587        $data = $this->_app->request->get();
588
589        // Load the request schema
590        $requestSchema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/account-activate.json");
591
592        // Get the alert message stream
593        $ms = $this->_app->alerts;
594
595        // Set up Fortress to validate the request
596        $rf = new \Fortress\HTTPRequestFortress($ms, $requestSchema, $data);
597
598        // Validate
599        if (!$rf->validate()) {
600            $this->_app->redirect($this->_app->urlFor('uri_home'));
601        }
602
603        // Ok, try to find a user with the specified activation token
604        $user = \UserFrosting\UserLoader::fetch($data['activation_token'], 'activation_token');
605
606        if (!$user || $user->active == "1"){
607            $ms->addMessageTranslated("danger", "ACCOUNT_TOKEN_NOT_FOUND");
608            $this->_app->redirect($this->_app->urlFor('uri_home'));
609        }
610
611        $user->active = "1";
612        $user->store();
613        $ms->addMessageTranslated("success", "ACCOUNT_ACTIVATION_COMPLETE");
614
615        // Forward to login page
616        $this->_app->redirect($this->_app->urlFor('uri_home'));
617    }
618
619    // Emails a forgotten password reset link to the specified user
620    public function forgotPassword(){
621        $data = $this->_app->request->post();
622
623        // Load the request schema
624        $requestSchema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/forgot-password.json");
625
626        // Get the alert message stream
627        $ms = $this->_app->alerts;
628
629        // Set up Fortress to validate the request
630        $rf = new \Fortress\HTTPRequestFortress($ms, $requestSchema, $data);
631
632        // Validate
633        if (!$rf->validate()) {
634            $this->_app->halt(400);
635        }
636
637        // Rate limiting - limit password reset requests to 3 per hour per email
638        // Check BEFORE validating user exists to prevent email enumeration
639        $rateLimiter = new \BuyerKiosk\Auth\Services\RateLimiter();
640        $email = strtolower(trim($data['email'] ?? ''));
641        $rateResult = $rateLimiter->checkLimit('password_reset', $email);
642
643        if ($rateResult->isBlocked()) {
644            // Return generic message to prevent email enumeration
645            $ms->addMessageTranslated("success", "FORGOTPASS_REQUEST_SUCCESS");
646            return; // Silently exit - don't reveal rate limit was hit
647        }
648
649        // Always record the attempt (regardless of whether email exists)
650        $rateLimiter->recordFailure('password_reset', $email);
651
652        // Check that the username exists
653        if(!\UserFrosting\UserLoader::exists($data['user_name'], 'user_name')) {
654            // Return generic success message to prevent username enumeration
655            $ms->addMessageTranslated("success", "FORGOTPASS_REQUEST_SUCCESS");
656            return;
657        }
658
659        // Load the user, by username
660        $user = \UserFrosting\UserLoader::fetch($data['user_name'], 'user_name');
661
662        // Check that the specified email is correct
663        if ($user->email != $data['email']){
664            // Return generic success message to prevent email enumeration
665            $ms->addMessageTranslated("success", "FORGOTPASS_REQUEST_SUCCESS");
666            return;
667        }
668
669        // Check if the user has any outstanding lost password requests
670        if($user->lost_password_request == 1) {
671            // Return generic success message - don't reveal request already exists
672            $ms->addMessageTranslated("success", "FORGOTPASS_REQUEST_SUCCESS");
673            return;
674        }
675
676        // Generate a new activation token.  This will also be used as the password reset token.
677        $user->activation_token = \UserFrosting\UserLoader::generateActivationToken();
678        $user->last_activation_request = date("Y-m-d H:i:s");
679        $user->lost_password_request = "1";
680        $user->lost_password_timestamp = date("Y-m-d H:i:s");
681
682        // Email the user asking to confirm this change password request
683        $mail = new \PHPMailer;
684
685        $mail->From = $this->_app->site->admin_email;
686        $mail->FromName = $this->_app->site->site_title;
687        $mail->addAddress($user->email);     // Add a recipient
688        $mail->addReplyTo($this->_app->site->admin_email, $this->_app->site->site_title);
689
690        $mail->Subject = $this->_app->site->site_title . " - reset your password";
691        $mail->Body    = $this->_app->view()->render("common/mail/password-reset.html", [
692            "user" => $user,
693            "request_date" => date("Y-m-d H:i:s")
694        ]);
695
696        $mail->isHTML(true);                                  // Set email format to HTML
697
698        if(!$mail->send()) {
699            $ms->addMessageTranslated("danger", "MAIL_ERROR");
700            error_log('Mailer Error: ' . $mail->ErrorInfo);
701            $this->_app->halt(500);
702        }
703
704        $user->store();
705        $ms->addMessageTranslated("success", "FORGOTPASS_REQUEST_SUCCESS");
706    }
707
708    // Resets a user's password with a valid activation token
709    public function resetPassword(){
710        $data = $this->_app->request->post();
711
712        // Load the request schema
713        $requestSchema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/reset-password.json");
714
715        // Get the alert message stream
716        $ms = $this->_app->alerts;
717
718        // Set up Fortress to validate the request
719        $rf = new \Fortress\HTTPRequestFortress($ms, $requestSchema, $data);
720
721        // Validate
722        if (!$rf->validate()) {
723            $this->_app->halt(400);
724        }
725
726        // Fetch the user, by looking up the submitted activation token
727        $user = \UserFrosting\UserLoader::fetch($data['activation_token'], 'activation_token');
728
729        if (!$user){
730            $ms->addMessageTranslated("danger", "FORGOTPASS_INVALID_TOKEN");
731            $this->_app->halt(400);
732        }
733
734        // Check that the username matches the activation token
735        if ($user->user_name != trim(strtolower($data['user_name']))){
736            $ms->addMessageTranslated("danger", "ACCOUNT_INVALID_USERNAME");
737            $this->_app->halt(400);
738        }
739
740        // Check that a lost password request is in progress and has not expired
741        if ($user->lost_password_request == 0 || $user->lost_password_timestamp === null){
742            $ms->addMessageTranslated("danger", "FORGOTPASS_INVALID_TOKEN");
743            $this->_app->halt(400);
744        }
745
746        // Check the time to see if the token is still valid based on the timeout value. If not valid make the user restart the password request
747        $current_time = new \DateTime("now");
748        $last_request_time = new \DateTime($user->lost_password_timestamp);
749        $current_token_life = $current_time->getTimestamp() - $last_request_time->getTimestamp();
750
751        if($current_token_life >= $this->_app->site->reset_password_timeout || $current_token_life < 0){
752            // Reset the password flag
753            // TODO: should we do this here, or just when there is a new reset request?
754            $user->lost_password_request = "0";
755            $user->store();
756            $ms->addMessageTranslated("danger", "FORGOTPASS_OLD_TOKEN");
757            $this->_app->halt(400);
758        }
759
760        // Reset the password flag
761        $user->lost_password_request = "0";
762
763        // Hash the user's password and update
764        $user->password = \UserFrosting\Authentication::hashPassword($data['password']);
765
766        if (!$user->password){
767            $ms->addMessageTranslated("danger", "PASSWORD_HASH_FAILED");
768            $this->_app->halt(500);
769        }
770
771        // Security: Revoke all remember-me tokens when password is reset
772        // This ensures any stolen tokens become invalid
773        $this->_app->remember_me_service->revokeAllForUser($user->id);
774
775        // Store the updated info
776        $user->store();
777        $ms->addMessageTranslated("success", "ACCOUNT_PASSWORD_UPDATED");
778    }
779
780    // Cancel a password reset request (via GET)
781    public function denyResetPassword(){
782        $data = $this->_app->request->get();
783
784        // Load the request schema
785        $requestSchema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/deny-password.json");
786
787        // Get the alert message stream
788        $ms = $this->_app->alerts;
789
790        // Set up Fortress to validate the request
791        $rf = new \Fortress\HTTPRequestFortress($ms, $requestSchema, $data);
792
793        // Validate
794        if (!$rf->validate()) {
795            $this->_app->redirect($this->_app->urlFor('uri_home'));
796        }
797
798        // Fetch the user, by looking up the submitted activation token
799        $user = \UserFrosting\UserLoader::fetch($data['activation_token'], 'activation_token');
800
801        if (!$user){
802            $ms->addMessageTranslated("danger", "FORGOTPASS_INVALID_TOKEN");
803            $this->_app->redirect($this->_app->urlFor('uri_home'));
804        }
805
806        // Reset the password flag
807        $user->lost_password_request = "0";
808
809        // Store the updated info
810        $user->store();
811        $ms->addMessageTranslated("success", "FORGOTPASS_REQUEST_CANNED");
812        $this->_app->redirect($this->_app->urlFor('uri_home'));
813    }
814
815    // Resend the activation email for a new user account
816    public function resendActivation(){
817        $data = $this->_app->request->post();
818
819        // Load the request schema
820        $requestSchema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/resend-activation.json");
821
822        // Get the alert message stream
823        $ms = $this->_app->alerts;
824
825        // Set up Fortress to validate the request
826        $rf = new \Fortress\HTTPRequestFortress($ms, $requestSchema, $data);
827
828        // Validate
829        if (!$rf->validate()) {
830            $this->_app->halt(400);
831        }
832
833        // Check that the username exists
834        if(!\UserFrosting\UserLoader::exists($data['user_name'], 'user_name')) {
835            $ms->addMessageTranslated("danger", "ACCOUNT_INVALID_USERNAME");
836            $this->_app->halt(400);
837        }
838
839        // Load the user, by username
840        $user = \UserFrosting\UserLoader::fetch($data['user_name'], 'user_name');
841
842        // Check that the specified email is correct
843        if ($user->email != $data['email']){
844            $ms->addMessageTranslated("danger", "ACCOUNT_USER_OR_EMAIL_INVALID");
845            $this->_app->halt(400);
846        }
847
848        // Check if user's account is already active
849        if ($user->active == "1") {
850            $ms->addMessageTranslated("danger", "ACCOUNT_ALREADY_ACTIVE");
851            $this->_app->halt(400);
852        }
853
854        // Check the time since the last activation request
855        $current_time = new \DateTime("now");
856        $last_request_time = new \DateTime($user->last_activation_request);
857        $time_since_last_request = $current_time->getTimestamp() - $last_request_time->getTimestamp();
858
859        // If an activation request has been sent too recently, they must wait
860        if($time_since_last_request < $this->_app->site->resend_activation_threshold || $time_since_last_request < 0){
861            $ms->addMessageTranslated("danger", "ACCOUNT_LINK_ALREADY_SENT", ["resend_activation_threshold" => $this->_app->site->resend_activation_threshold]);
862            $this->_app->halt(429); // "Too many requests" code (http://tools.ietf.org/html/rfc6585#section-4)
863        }
864
865        // We're good to go - create a new activation token and send the email
866        $user->activation_token = \UserFrosting\UserLoader::generateActivationToken();
867        $user->last_activation_request = date("Y-m-d H:i:s");
868        $user->lost_password_timestamp = date("Y-m-d H:i:s");
869
870        // Email the user
871        $mail = new \PHPMailer;
872
873        $mail->From = $this->_app->site->admin_email;
874        $mail->FromName = $this->_app->site->site_title;
875        $mail->addAddress($user->email);     // Add a recipient
876        $mail->addReplyTo($this->_app->site->admin_email, $this->_app->site->site_title);
877
878        $mail->Subject = $this->_app->site->site_title . " - activate your account";
879        $mail->Body    = $this->_app->view()->render("common/mail/resend-activation.html", [
880            "user" => $user,
881            "activation_token" => $user->activation_token
882        ]);
883
884        $mail->isHTML(true);                                  // Set email format to HTML
885
886        if(!$mail->send()) {
887            $ms->addMessageTranslated("danger", "MAIL_ERROR");
888            error_log('Mailer Error: ' . $mail->ErrorInfo);
889            $this->_app->halt(500);
890        }
891
892        $user->store();
893        $ms->addMessageTranslated("success", "ACCOUNT_NEW_ACTIVATION_SENT");
894    }
895
896    public function accountSettings(){
897        // Load the request schema
898        $requestSchema = new \Fortress\RequestSchema($this->_app->config('schema.path') . "/forms/account-settings.json");
899
900        // Get the alert message stream
901        $ms = $this->_app->alerts;
902
903        // Access control for entire page
904        if (!$this->_app->user->checkAccess('uri_account_settings')){
905            $ms->addMessageTranslated("danger", "ACCESS_DENIED");
906            $this->_app->halt(403);
907        }
908
909        $data = $this->_app->request->post();
910
911        // Remove csrf_token
912        unset($data['csrf_token']);
913
914        // Check current password
915        if (!isset($data['passwordcheck']) || !$this->_app->user->verifyPassword($data['passwordcheck'])){
916            $ms->addMessageTranslated("danger", "ACCOUNT_PASSWORD_INVALID");
917            $this->_app->halt(403);
918        }
919
920        // Validate new email, if specified
921        if (isset($data['email']) && $data['email'] != $this->_app->user->email){
922            // Check authorization
923            if (!$this->_app->user->checkAccess('update_account_setting', ['user' => $this->_app->user, 'property' => 'email'])){
924                $ms->addMessageTranslated("danger", "ACCESS_DENIED");
925                $this->_app->halt(403);
926            }
927            // Check if address is in use
928            if (\UserFrosting\UserLoader::exists($data['email'], 'email')){
929                $ms->addMessageTranslated("danger", "ACCOUNT_EMAIL_IN_USE", $data);
930                $this->_app->halt(400);
931            }
932        } else {
933            $data['email'] = $this->_app->user->email;
934        }
935
936        // Validate locale, if specified
937        if (isset($data['locale']) && $data['locale'] != $this->_app->user->locale){
938            // Check authorization
939            if (!$this->_app->user->checkAccess('update_account_setting', ['user' => $this->_app->user, 'property' => 'locale'])){
940                $ms->addMessageTranslated("danger", "ACCESS_DENIED");
941                $this->_app->halt(403);
942            }
943            // Validate locale
944            if (!in_array($data['locale'], $this->_app->site->getLocales())){
945                $ms->addMessageTranslated("danger", "ACCOUNT_SPECIFY_LOCALE");
946                $this->_app->halt(400);
947            }
948        } else {
949            $data['locale'] = $this->_app->user->locale;
950        }
951        // Validate dailyEmail, if specified
952        if (isset($data['daily_email'])){
953            if($data['daily_email'] == 'on') {
954                $data['daily_email'] = 1;
955            } else if($data['daily_email'] == 'off') {
956                $data['daily_email'] = 0;
957            }
958            if($data['daily_email'] != $this->_app->user->dailyReport) {
959                // Check authorization
960                if (!$this->_app->user->checkAccess('update_account_setting', ['user' => $this->_app->user, 'property' => 'dailyReport'])){
961                    $ms->addMessageTranslated("danger", "ACCESS_DENIED");
962                    $this->_app->halt(403);
963                }
964            }
965        } else {
966            $data['daily_email'] = $this->_app->user->dailyReport;
967        }
968        $data['dailyReport']  = $data['daily_email'];
969        unset($data['daily_email']);
970
971        // Validate display_name, if specified
972        if (isset($data['display_name']) && $data['display_name'] != $this->_app->user->display_name){
973            // Check authorization
974            if (!$this->_app->user->checkAccess('update_account_setting', ['user' => $this->_app->user, 'property' => 'display_name'])){
975                $ms->addMessageTranslated("danger", "ACCESS_DENIED");
976                $this->_app->halt(403);
977            }
978        } else {
979            $data['display_name'] = $this->_app->user->display_name;
980        }
981
982        // Validate password, if specified and not empty
983        if (isset($data['password']) && !empty($data['password'])){
984            // Check authorization
985            if (!$this->_app->user->checkAccess('update_account_setting', ['user' => $this->_app->user, 'property' => 'password'])){
986                $ms->addMessageTranslated("danger", "ACCESS_DENIED");
987                $this->_app->halt(403);
988            }
989        } else {
990            // Do not pass to model if no password is specified
991            unset($data['password']);
992            unset($data['passwordc']);
993        }
994
995        // Set up Fortress to validate the request
996        $rf = new \Fortress\HTTPRequestFortress($ms, $requestSchema, $data);
997
998        // Validate
999        if (!$rf->validate()) {
1000            $this->_app->halt(400);
1001        }
1002
1003        // If a new password was specified, hash it and revoke remember-me tokens
1004        if (isset($data['password'])) {
1005            $data['password'] = \UserFrosting\Authentication::hashPassword($data['password']);
1006
1007            // Security: Revoke all remember-me tokens when password changes
1008            // This ensures any stolen tokens become invalid
1009            $this->_app->remember_me_service->revokeAllForUser($this->_app->user->id);
1010        }
1011
1012        // Remove passwordc, passwordcheck
1013        unset($data['passwordc']);
1014        unset($data['passwordcheck']);
1015
1016        // Looks good, let's update with new values!
1017        foreach ($data as $name => $value){
1018            $this->_app->user->$name = $value;
1019        }
1020
1021        $this->_app->user->store();
1022
1023        $ms->addMessageTranslated("success", "ACCOUNT_SETTINGS_UPDATED");
1024    }
1025
1026    public function captcha(){
1027        echo $this->generateCaptcha();
1028    }
1029
1030        }
1031
1032?>