Skip to content
Snippets Groups Projects
Commit 3429fb62 authored by Cam Saul's avatar Cam Saul
Browse files

make throttling its own namespace

parent d87850e4
Branches
Tags
No related merge requests found
(ns metabase.api.common.throttle
(:require [clojure.math.numeric-tower :as math])
(:import (clojure.lang Atom
Keyword)))
;;; # THROTTLING
;;
;; A `Throttler` is a simple object used for throttling API endpoints. It keeps track of all calls to an API endpoint
;; with some value over some past period of time. If the number of calls with this values exceeds some threshold,
;; an exception is thrown, telling a user they must wait some period of time before trying again.
;;
;; ### EXAMPLE
;;
;; Let's consider the email throttling done by POST /api/session.
;; The basic concept here is to keep a list of failed logins over the last hour. This list looks like:
;;
;; (["cam@metabase.com" 1438045261132]
;; ["cam@metabase.com" 1438045260450]
;; ["cam@metabase.com" 1438045259037]
;; ["cam@metabase.com" 1438045258204])
;;
;; Every time there's a login attempt, push a new pair of [email timestamp (milliseconds)] to the front of the list.
;; The list is thus automatically ordered by date, and we can drop the portion of the list with logins that are over
;; an hour old as needed.
;;
;; Once a User has reached some number of login attempts over the past hour (e.g. 5), calculate some delay before
;; they're allowed to try to log in again (e.g., 15 seconds). This number will increase exponentially as the number of
;; recent failures increases (e.g., 40 seconds for 6 failed attempts, 90 for 7 failed attempts, etc).
;;
;; If applicable, calucate the time since the last failed attempt, and throw an exception telling the user the number
;; of seconds they must wait before trying again.
;;
;; ### USAGE
;;
;; Define a new throttler with `make-throttler`, overriding default settings as needed.
;;
;; (require '[metabase.api.common.throttle :as throttle])
;; (def email-throttler (throttle/make-throttler :email, :attempts-threshold 10))
;;
;; Then call `check` within the body of an endpoint with some value to apply throttling.
;;
;; (defendpoint POST [:as {{:keys [email]} :body}]
;; (throttle/check email-throttler email)
;; ...)
;;; # PUBLIC INTERFACE
(declare calculate-delay
remove-old-attempts)
(defrecord Throttler [;; Name of the API field/value being checked. Used to generate appropriate API error messages, so they'll be displayed in the
;; right part of the screen
^Keyword field-name
;; [Internal] List of attempt entries. These are pairs of [key timestamp (ms)], e.g. ["cam@metabase.com" 1438045261132]
^Atom attempts
;; Amount of time to keep an entry in ATTEMPTS before dropping it.
^Integer attempt-ttl-ms
;; Number of attempts allowed with a given key before throttling is applied.
^Integer attempts-threshold
;; Once throttling is in effect, initial delay before allowing another attempt. This grows according to DELAY-EXPONENT.
^Integer initial-delay-ms
;; For each subsequent failure past ATTEMPTS-THRESHOLD, increase the delay by
;; (num-attempts-over-theshold ^ DELAY-EXPONENT). e.g. if `initial-delay-ms` is 15 and
;; `delay-exponent` is 2, the first attempt past attempts-threshold will require the user to wait 15 seconds (15 * 1^2),
;; the next attempt after that 60 seconds (15 * 2^2), then 135, and so on.
^Integer delay-exponent])
;; These are made private because you should use `make-throttler` instead.
(alter-meta! #'->Throttler assoc :private true)
(alter-meta! #'map->Throttler assoc :private true)
(def ^:private ^:const throttler-defaults
{:initial-delay-ms (* 15 1000)
:attempts-threshold 5
:delay-exponent 1.5
:attempt-ttl-ms (* 1000 60 60)})
;;
;;
;;
;; Then call `check` within the body of an endpoint with some value to apply throttling.
;;
;; (defendpoint POST [:as {{:keys [email]} :body}]
;; (throttle/check email-throttler email)
;; ...)
(defn make-throttler
"Create a new `Throttler`.
(require '[metabase.api.common.throttle :as throttle])
(def email-throttler (throttle/make-throttler :attempts-threshold 10))"
[field-name & {:as kwargs}]
(map->Throttler (merge throttler-defaults kwargs {:attempts (atom '())
:field-name field-name})))
(defn check
"Throttle an API call based on values of KEYY. Each call to this function will record KEYY to THROTTLER's internal list;
if the number of entires containing KEYY exceed THROTTLER's thresholds, throw an exception.
(defendpoint POST [:as {{:keys [email]} :body}]
(throttle/check email-throttler email)
...)"
[^Throttler {:keys [attempts field-name], :as throttler} keyy]
{:pre [(or (= (type throttler) Throttler)
(println "THROTTLER IS: " (type throttler)))
keyy]}
(println "RECENT ATTEMPTS:\n" (metabase.util/pprint-to-str 'cyan @(:attempts throttler))) ;; TODO - remove debug logging
(remove-old-attempts throttler)
(when-let [delay-ms (calculate-delay throttler keyy)]
(let [message (format "Too many attempts! You must wait %d seconds before trying again." (int (math/round (/ delay-ms 1000))))]
(throw (ex-info message {:status-code 400
:errors {field-name message}}))))
(swap! attempts conj [keyy (System/currentTimeMillis)]))
;;; # INTERNAL IMPLEMENTATION
(defn- remove-old-attempts
"Remove THROTTLER entires past the TTL."
[^Throttler {:keys [attempts attempt-ttl-ms]}]
(let [old-attempt-cutoff (- (System/currentTimeMillis) attempt-ttl-ms)
non-old-attempt? (fn [[_ timestamp]]
(> timestamp old-attempt-cutoff))]
(reset! attempts (take-while non-old-attempt? @attempts))))
(defn- calculate-delay
"Calculate the delay in milliseconds, if any, that should be applied to a given THROTTLER / KEYY combination."
([^Throttler {:keys [attempts initial-delay-ms attempts-threshold delay-exponent]} keyy]
(let [[[_ most-recent-attempt-ms], :as keyy-attempts] (filter (fn [[k _]] (= k keyy)) @attempts)]
(when most-recent-attempt-ms
(let [num-recent-attempts (count keyy-attempts)
num-attempts-over-threshold (- num-recent-attempts attempts-threshold)]
(when (> num-attempts-over-threshold 0)
(let [delay-ms (* (math/expt num-attempts-over-threshold delay-exponent)
initial-delay-ms)
next-login-allowed-at (+ most-recent-attempt-ms delay-ms)
ms-till-next-login (- next-login-allowed-at (System/currentTimeMillis))]
(when (> ms-till-next-login 0)
ms-till-next-login))))))))
(ns metabase.api.session
"/api/session endpoints"
(:require [clojure.math.numeric-tower :as math]
[clojure.tools.logging :as log]
(:require [clojure.tools.logging :as log]
[cemerick.friend.credentials :as creds]
[compojure.core :refer [defroutes GET POST DELETE]]
[hiccup.core :refer [html]]
[korma.core :as k]
[metabase.api.common :refer :all]
[metabase.api.common.throttle :as throttle]
[metabase.db :refer :all]
[metabase.email.messages :as email]
(metabase.models [user :refer [User set-user-password set-user-password-reset-token]]
......@@ -24,118 +24,26 @@
:user_id user-id)
session-id))
;;; ## Login Throttling
;; The basic concept here is to keep a list of failed logins over the last hour. This list looks like:
;;
;; (["cam@metabase.com" 1438045261132]
;; ["cam@metabase.com" 1438045260450]
;; ["cam@metabase.com" 1438045259037]
;; ["cam@metabase.com" 1438045258204])
;;
;; Every time there's a failed login, push a new pair of [email timestamp (milliseconds)] to the front of the list.
;; The list is thus automatically ordered by date, and we can drop the portion of the list with failed logins that
;; are over an hour old as needed.
;;
;; Once a User has some number of failed login attempts over the past hour (e.g. 4), calculate some delay before
;; they're allowed to try to login again (e.g., 15 seconds). This number will increase exponentially as the number of
;; recent failures increases (e.g., 40 seconds for 5 failed attempts, 90 for 6 failed attempts, etc).
;;
;; If applicable, calucate the time since the last failed attempt, and throw an exception telling the user the number of
;; seconds they must wait before trying again.
(def ^:private ^:const failed-login-attempts-initial-delay-seconds
"If a user makes the number of failed login attempts specified by `failed-login-attempts-throttling-threshold` in the
last hour, require them to wait this many seconds after the last failed attempt before trying again."
15)
(def ^:private ^:const failed-login-attempts-throttling-threshold
"If a user has had more than this many failed login attempts in the last hour, make them
wait `failed-login-attempts-initial-delay-seconds` since the last failed attempt before trying again."
5)
(def ^:private ^:const failed-login-delay-exponent
"Multiply `failed-login-attempts-initial-delay-seconds` by the number of failed login attempts in the last hour
over `failed-login-attempts-throttling-threshold` times this exponent.
e.g. if this number is `2`, and a User has to wait `15` seconds initially, they'll have to wait 60 for the next
failure (15 * 2^2), then 135 seconds the next time (15 * 3^3), and so on."
1.5)
(def ^:private failed-login-attempts
"Failed login attempts over the last hour. Vector of pairs of `[email-address time]`"
(atom '()))
(defn- remove-old-failed-login-attempts
"Remove `failed-login-attempts` older than an hour."
[]
(let [one-hour-ago (- (System/currentTimeMillis) (* 1000 60 60))
less-than-one-hour-old? (fn [[_ timestamp]]
(> timestamp one-hour-ago))]
(reset! failed-login-attempts (take-while less-than-one-hour-old? @failed-login-attempts))))
(defn- push-failed-login-attempt
"Record a failed login attempt. Add a new pair to `failed-login-attempts` for EMAIL."
[email]
{:pre [(string? email)]}
(remove-old-failed-login-attempts) ; First filter out old failed login attempts
(swap! failed-login-attempts conj [email (System/currentTimeMillis)])) ; Now push the new one to the front
(defn- calculate-login-delay
"Calculate the appropriate delay (in seconds) before a user should be allowed to login again based on
MOST-RECENT-ATTEMPT and NUM-RECENT-ATTEMPTS. This function returns `nil` if there is no delay that should be required."
[most-recent-attempt-ms num-recent-attempts]
(when most-recent-attempt-ms
(let [num-attempts-over-threshold (- num-recent-attempts failed-login-attempts-throttling-threshold)]
(when (> num-attempts-over-threshold 0)
(let [delay-ms (* (math/expt num-attempts-over-threshold failed-login-delay-exponent)
failed-login-attempts-initial-delay-seconds
1000)
next-login-allowed-at (+ most-recent-attempt-ms delay-ms)
ms-till-next-login (- next-login-allowed-at (System/currentTimeMillis))]
(when (> ms-till-next-login 0)
;; convert to seconds
(-> (/ ms-till-next-login 1000)
math/round
int)))))))
(defn- check-throttle-login-attempts
"Throw an Exception if a User has tried (and failed) to log in too many times recently."
[email]
{:pre [(string? email)]}
;; Remove any out-of-date failed login attempts
(remove-old-failed-login-attempts)
;; Now count the number of recent attempts with this email
(let [[[_ most-recent-attempt-ms] :as recent-attempts] (filter (fn [[attempt-email _]]
(= email attempt-email))
@failed-login-attempts)]
(println "RECENT ATTEMPTS:\n" (metabase.util/pprint-to-str 'cyan recent-attempts)) ;; TODO - remove debug logging
(when-let [login-delay (calculate-login-delay most-recent-attempt-ms (count recent-attempts))]
(let [message (format "Too many recent failed logins! You must wait %d seconds before trying again." login-delay)]
(throw (ex-info message {:status-code 400
:errors {:email message}}))))))
;;; ## API Endpoints
(def ^:private login-throttlers
{:email (throttle/make-throttler :email)
:ip-address (throttle/make-throttler :email, :attempts-threshold 50)}) ; IP Address doesn't have an actual UI field so just show error by email
(defendpoint POST "/"
"Login."
[:as {{:keys [email password] :as body} :body}]
[:as {{:keys [email password] :as body} :body, remote-address :remote-addr}]
{email [Required Email]
password [Required NonEmptyString]}
(check-throttle-login-attempts email)
(let [user (sel :one :fields [User :id :password_salt :password] :email email (k/where {:is_active true}))
login-fail (fn []
(push-failed-login-attempt email)
(throw (ex-info "Password did not match stored password." {:status-code 400
:errors {:password "did not match stored password"}})))]
(throttle/check (login-throttlers :ip-address) remote-address)
(throttle/check (login-throttlers :email) email)
(let [user (sel :one :fields [User :id :password_salt :password], :email email (k/where {:is_active true}))]
;; Don't leak whether the account doesn't exist or the password was incorrect
(when-not user
(login-fail))
;; Verify that password matches up
(when-not (pass/verify-password password (:password_salt user) (:password user))
(login-fail))
;; OK! Create new Session
(when-not (and user
(pass/verify-password password (:password_salt user) (:password user)))
(throw (ex-info "Password did not match stored password." {:status-code 400
:errors {:password "did not match stored password"}})))
(let [session-id (create-session (:id user))]
{:id session-id})))
......@@ -154,10 +62,16 @@
;;
;; There's also no need to salt the token because it's already random <3
(def ^:private forgot-password-throttlers
{:email (throttle/make-throttler :email)
:ip-address (throttle/make-throttler :email, :attempts-threshold 50)})
(defendpoint POST "/forgot_password"
"Send a reset email when user has forgotten their password."
[:as {:keys [server-name] {:keys [email]} :body, :as request}]
[:as {:keys [server-name] {:keys [email]} :body, remote-address :remote-addr, :as request}]
{email [Required Email]}
(throttle/check (forgot-password-throttlers :ip-address) remote-address)
(throttle/check (forgot-password-throttlers :email) email)
;; Don't leak whether the account doesn't exist, just pretend everything is ok
(when-let [user-id (sel :one :id User :email email)]
(let [reset-token (set-user-password-reset-token user-id)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment