Skip to content
GitLab
Explore
Sign in
Primary navigation
Search or go to…
Project
M
Metabase
Manage
Activity
Members
Labels
Plan
Issues
Issue boards
Milestones
Iterations
Wiki
Requirements
Code
Merge requests
Repository
Branches
Commits
Tags
Repository graph
Compare revisions
Snippets
Locked files
Build
Pipelines
Jobs
Pipeline schedules
Test cases
Artifacts
Deploy
Releases
Package registry
Container registry
Model registry
Operate
Environments
Terraform modules
Monitor
Incidents
Service Desk
Analyze
Value stream analytics
Contributor analytics
CI/CD analytics
Repository analytics
Code review analytics
Issue analytics
Insights
Model experiments
Help
Help
Support
GitLab documentation
Compare GitLab plans
Community forum
Contribute to GitLab
Provide feedback
Terms and privacy
Keyboard shortcuts
?
Snippets
Groups
Projects
Show more breadcrumbs
Engineering Digital Service
Metabase
Commits
3429fb62
Commit
3429fb62
authored
9 years ago
by
Cam Saul
Browse files
Options
Downloads
Patches
Plain Diff
make throttling its own namespace
parent
d87850e4
Branches
Branches containing commit
Tags
Tags containing commit
No related merge requests found
Changes
2
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
src/metabase/api/common/throttle.clj
+141
-0
141 additions, 0 deletions
src/metabase/api/common/throttle.clj
src/metabase/api/session.clj
+21
-107
21 additions, 107 deletions
src/metabase/api/session.clj
with
162 additions
and
107 deletions
src/metabase/api/common/throttle.clj
0 → 100644
+
141
−
0
View file @
3429fb62
(
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
))))))))
This diff is collapsed.
Click to expand it.
src/metabase/api/session.clj
+
21
−
107
View file @
3429fb62
(
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
)
...
...
This diff is collapsed.
Click to expand it.
Preview
0%
Loading
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Save comment
Cancel
Please
register
or
sign in
to comment