xref: /webtrees/app/Services/RateLimitService.php (revision d11be7027e34e3121be11cc025421873364403f9)
19ed332c7SGreg Roach<?php
29ed332c7SGreg Roach
39ed332c7SGreg Roach/**
49ed332c7SGreg Roach * webtrees: online genealogy
5*d11be702SGreg Roach * Copyright (C) 2023 webtrees development team
69ed332c7SGreg Roach * This program is free software: you can redistribute it and/or modify
79ed332c7SGreg Roach * it under the terms of the GNU General Public License as published by
89ed332c7SGreg Roach * the Free Software Foundation, either version 3 of the License, or
99ed332c7SGreg Roach * (at your option) any later version.
109ed332c7SGreg Roach * This program is distributed in the hope that it will be useful,
119ed332c7SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
129ed332c7SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
139ed332c7SGreg Roach * GNU General Public License for more details.
149ed332c7SGreg Roach * You should have received a copy of the GNU General Public License
159ed332c7SGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
169ed332c7SGreg Roach */
179ed332c7SGreg Roach
189ed332c7SGreg Roachdeclare(strict_types=1);
199ed332c7SGreg Roach
209ed332c7SGreg Roachnamespace Fisharebest\Webtrees\Services;
219ed332c7SGreg Roach
229ed332c7SGreg Roachuse Fisharebest\Webtrees\Contracts\UserInterface;
239ed332c7SGreg Roachuse Fisharebest\Webtrees\Http\Exceptions\HttpTooManyRequestsException;
249ed332c7SGreg Roachuse Fisharebest\Webtrees\Site;
259ed332c7SGreg Roachuse LogicException;
269ed332c7SGreg Roach
279ed332c7SGreg Roachuse function array_filter;
2810e06497SGreg Roachuse function count;
299ed332c7SGreg Roachuse function explode;
309ed332c7SGreg Roachuse function intdiv;
319ed332c7SGreg Roachuse function strlen;
329ed332c7SGreg Roachuse function time;
339ed332c7SGreg Roach
349ed332c7SGreg Roach/**
359ed332c7SGreg Roach * Throttle events to prevent abuse.
369ed332c7SGreg Roach */
379ed332c7SGreg Roachclass RateLimitService
389ed332c7SGreg Roach{
399ed332c7SGreg Roach    private int $now;
409ed332c7SGreg Roach
419ed332c7SGreg Roach    /**
429ed332c7SGreg Roach     *
439ed332c7SGreg Roach     */
449ed332c7SGreg Roach    public function __construct()
459ed332c7SGreg Roach    {
469ed332c7SGreg Roach        $this->now = time();
479ed332c7SGreg Roach    }
489ed332c7SGreg Roach
499ed332c7SGreg Roach    /**
509ed332c7SGreg Roach     * Rate limit for actions related to a user, such as password reset request.
519ed332c7SGreg Roach     * Allow $num requests every $seconds
529ed332c7SGreg Roach     *
539ed332c7SGreg Roach     * @param int    $num     allow this number of events
549ed332c7SGreg Roach     * @param int    $seconds in a rolling window of this number of seconds
559ed332c7SGreg Roach     * @param string $limit   name of limit to enforce
569ed332c7SGreg Roach     *
579ed332c7SGreg Roach     * @return void
589ed332c7SGreg Roach     */
599ed332c7SGreg Roach    public function limitRateForSite(int $num, int $seconds, string $limit): void
609ed332c7SGreg Roach    {
619ed332c7SGreg Roach        $history = Site::getPreference($limit);
629ed332c7SGreg Roach
639ed332c7SGreg Roach        $history = $this->checkLimitReached($num, $seconds, $history);
649ed332c7SGreg Roach
659ed332c7SGreg Roach        Site::setPreference($limit, $history);
669ed332c7SGreg Roach    }
679ed332c7SGreg Roach
689ed332c7SGreg Roach    /**
699ed332c7SGreg Roach     * Rate limit for actions related to a user, such as password reset request.
709ed332c7SGreg Roach     * Allow $num requests every $seconds
719ed332c7SGreg Roach     *
729ed332c7SGreg Roach     * @param UserInterface $user    limit events for this user
739ed332c7SGreg Roach     * @param int           $num     allow this number of events
749ed332c7SGreg Roach     * @param int           $seconds in a rolling window of this number of seconds
759ed332c7SGreg Roach     * @param string        $limit   name of limit to enforce
769ed332c7SGreg Roach     *
779ed332c7SGreg Roach     * @return void
789ed332c7SGreg Roach     */
799ed332c7SGreg Roach    public function limitRateForUser(UserInterface $user, int $num, int $seconds, string $limit): void
809ed332c7SGreg Roach    {
819ed332c7SGreg Roach        $history = $user->getPreference($limit);
829ed332c7SGreg Roach
839ed332c7SGreg Roach        $history = $this->checkLimitReached($num, $seconds, $history);
849ed332c7SGreg Roach
859ed332c7SGreg Roach        $user->setPreference($limit, $history);
869ed332c7SGreg Roach    }
879ed332c7SGreg Roach
889ed332c7SGreg Roach    /**
899ed332c7SGreg Roach     * Rate limit - allow $num requests every $seconds
909ed332c7SGreg Roach     *
919ed332c7SGreg Roach     * @param int    $num     allow this number of events
929ed332c7SGreg Roach     * @param int    $seconds in a rolling window of this number of seconds
939ed332c7SGreg Roach     * @param string $history comma-separated list of previous timestamps
949ed332c7SGreg Roach     *
959ed332c7SGreg Roach     * @return string updated list of timestamps
969ed332c7SGreg Roach     * @throws HttpTooManyRequestsException
979ed332c7SGreg Roach     */
989ed332c7SGreg Roach    private function checkLimitReached(int $num, int $seconds, string $history): string
999ed332c7SGreg Roach    {
1009ed332c7SGreg Roach        // Make sure we can store enough previous timestamps in a database field.
1019ed332c7SGreg Roach        $max = intdiv(256, strlen($this->now . ','));
1029ed332c7SGreg Roach        if ($num > $max) {
1039ed332c7SGreg Roach            throw new LogicException('Cannot store ' . $num . ' previous events in the database');
1049ed332c7SGreg Roach        }
1059ed332c7SGreg Roach
1069ed332c7SGreg Roach        // Extract the timestamps.
1079ed332c7SGreg Roach        $timestamps = array_filter(explode(',', $history));
1089ed332c7SGreg Roach
1099ed332c7SGreg Roach        // Filter events within our time window.
1109ed332c7SGreg Roach        $filter    = fn (string $x): bool => (int) $x >= $this->now - $seconds && (int) $x <= $this->now;
1119ed332c7SGreg Roach        $in_window = array_filter($timestamps, $filter);
1129ed332c7SGreg Roach
1139ed332c7SGreg Roach        if (count($in_window) >= $num) {
1149ed332c7SGreg Roach            throw new HttpTooManyRequestsException();
1159ed332c7SGreg Roach        }
1169ed332c7SGreg Roach
1179ed332c7SGreg Roach        $timestamps[] = (string) $this->now;
1189ed332c7SGreg Roach
1199ed332c7SGreg Roach        while (count($timestamps) > $max) {
1209ed332c7SGreg Roach            array_shift($timestamps);
1219ed332c7SGreg Roach        }
1229ed332c7SGreg Roach
1239ed332c7SGreg Roach        return implode(',', $timestamps);
1249ed332c7SGreg Roach    }
1259ed332c7SGreg Roach}
126