From 8c9ad7bf078871295e391f416bfcb10c9156a35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hoguin?= Date: Sun, 4 Nov 2018 11:51:35 +0100 Subject: Add the rate_limited/2 REST callback --- doc/src/guide/resource_design.asciidoc | 3 + doc/src/guide/rest_handlers.asciidoc | 1 + doc/src/guide/rest_start.png | Bin 105640 -> 110820 bytes doc/src/guide/rest_start.svg | 678 ++++++++++++++++++++++++--------- doc/src/manual/cowboy_rest.asciidoc | 25 ++ src/cowboy_rest.erl | 30 +- test/handlers/rate_limited_h.erl | 24 ++ test/rest_handler_SUITE.erl | 26 ++ 8 files changed, 597 insertions(+), 190 deletions(-) create mode 100644 test/handlers/rate_limited_h.erl diff --git a/doc/src/guide/resource_design.asciidoc b/doc/src/guide/resource_design.asciidoc index fa0c612..4ea0390 100644 --- a/doc/src/guide/resource_design.asciidoc +++ b/doc/src/guide/resource_design.asciidoc @@ -122,6 +122,9 @@ Can access to a resource be forbidden regardless of access being authorized? A simple example of that is censorship of a resource. Implement the `forbidden` callback. +Can access be rate-limited for authenticated users? Use the +`rate_limited` callback. + Are there any constraints on the length of the resource URI? For example, the URI may be used as a key in storage and may have a limit in length. Implement `uri_too_long`. diff --git a/doc/src/guide/rest_handlers.asciidoc b/doc/src/guide/rest_handlers.asciidoc index dab5bea..baf8e6a 100644 --- a/doc/src/guide/rest_handlers.asciidoc +++ b/doc/src/guide/rest_handlers.asciidoc @@ -84,6 +84,7 @@ if it is undefined, moving directly to the next step. Similarly, | multiple_choices | `false` | options | `ok` | previously_existed | `false` +| rate_limited | `false` | resource_exists | `true` | service_available | `true` | uri_too_long | `false` diff --git a/doc/src/guide/rest_start.png b/doc/src/guide/rest_start.png index 1f1e312..4c230a0 100644 Binary files a/doc/src/guide/rest_start.png and b/doc/src/guide/rest_start.png differ diff --git a/doc/src/guide/rest_start.svg b/doc/src/guide/rest_start.svg index 076c619..6f1dd87 100644 --- a/doc/src/guide/rest_start.svg +++ b/doc/src/guide/rest_start.svg @@ -15,7 +15,7 @@ height="1052.3622047" id="svg2" version="1.1" - inkscape:version="0.48.4 r9939" + inkscape:version="0.92.2 2405546, 2018-03-11" sodipodi:docname="rest_start.svg" inkscape:export-filename="/home/essen/Dropbox/Public/drawing.png" inkscape:export-xdpi="90" @@ -65,15 +65,15 @@ inkscape:pageopacity="1" inkscape:pageshadow="2" inkscape:zoom="1.0000001" - inkscape:cx="171.11305" - inkscape:cy="549.52821" + inkscape:cx="213.11305" + inkscape:cy="726.77495" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="false" - inkscape:window-width="2560" - inkscape:window-height="1402" + inkscape:window-width="1920" + inkscape:window-height="1043" inkscape:window-x="0" - inkscape:window-y="38" + inkscape:window-y="0" inkscape:window-maximized="1" inkscape:snap-global="true" showguides="true"> @@ -111,7 +111,9 @@ style="fill:none;stroke:#6d8e41;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:1.99999999, 3.99999998;stroke-dashoffset:0" /> + id="g5650-7" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-9" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-0" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-94" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-93" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-3" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-6" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-34" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-5" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-1" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-0" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-6" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-4" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-04" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-8" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-1" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-3" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-44" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + id="g5650-2-12" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> - + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> @@ -758,171 +806,187 @@ width="744.09448" height="1052.3622" inkscape:export-filename="/home/essen/extend/cowboy/guide/http_req_resp.png" - inkscape:export-xdpi="89.926643" - inkscape:export-ydpi="89.926643" /> + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> some text + y="114.39204" + style="font-size:16px;line-height:1.25;font-family:sans-serif">some text some text + y="53.112247" + style="font-size:16px;line-height:1.25;font-family:sans-serif">some text uri_too_long + y="310.19913" + style="font-size:16px;line-height:1.25;font-family:sans-serif">uri_too_long malformed_request + y="477.47531" + style="font-size:16px;line-height:1.25;font-family:sans-serif">malformed_request some text + y="236.95154" + style="font-size:16px;line-height:1.25;font-family:sans-serif">some text init + id="tspan17171" + style="font-size:16px;line-height:1.25;font-family:sans-serif">init is_authorized + y="561.14258" + style="font-size:16px;line-height:1.25;font-family:sans-serif">is_authorized forbidden + y="646.58331" + style="font-size:16px;line-height:1.25;font-family:sans-serif">forbidden valid_content_headers + y="728.47717" + style="font-size:16px;line-height:1.25;font-family:sans-serif">valid_content_headers valid_entity_length + y="812.14441" + style="font-size:16px;line-height:1.25;font-family:sans-serif">valid_entity_length ... + y="895.81165" + style="font-size:16px;line-height:1.25;font-family:sans-serif">... service_available + y="142.80627" + style="font-size:16px;line-height:1.25;font-family:sans-serif">service_available known_methods + y="226.4736" + style="font-size:16px;line-height:1.25;font-family:sans-serif">known_methods allowed_methods + y="393.80801" + style="font-size:16px;line-height:1.25;font-family:sans-serif">allowed_methods true + y="185.95248" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true known* + y="269.61978" + style="font-size:16px;line-height:1.25;font-family:sans-serif">known* false + y="353.28702" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false allowed* + y="436.95425" + style="font-size:16px;line-height:1.25;font-family:sans-serif">allowed* false + y="520.62152" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false true + y="604.28876" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true false + y="687.95599" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false true + y="771.62329" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true true + y="855.29053" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true @@ -1058,103 +1140,121 @@ false + y="123.86062" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false unknown* + y="207.30568" + style="font-size:16px;line-height:1.25;font-family:sans-serif">unknown* true + y="290.75076" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true unallowed* + y="374.19577" + style="font-size:16px;line-height:1.25;font-family:sans-serif">unallowed* true + y="457.64084" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true false* + y="541.08588" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false* true + y="624.53094" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true false + y="707.97595" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false false + y="791.42102" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> 503 service unavailable + id="tspan18994" + style="font-size:16px;line-height:1.25;font-family:sans-serif">503 service unavailable + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> + rx="15" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" /> 501 not implemented + y="227.80464" + style="font-size:16px;line-height:1.25;font-family:sans-serif">501 not implemented 414 request URI too long + y="311.49661" + style="font-size:16px;line-height:1.25;font-family:sans-serif">414 request URI too long 405 method not allowed + y="395.18857" + style="font-size:16px;line-height:1.25;font-family:sans-serif">405 method not allowed 400 bad request + y="478.88046" + style="font-size:16px;line-height:1.25;font-family:sans-serif">400 bad request 401 unauthorized + y="562.57239" + style="font-size:16px;line-height:1.25;font-family:sans-serif">401 unauthorized 403 forbidden + y="646.26434" + style="font-size:16px;line-height:1.25;font-family:sans-serif">403 forbidden 501 not implemented + y="729.9563" + style="font-size:16px;line-height:1.25;font-family:sans-serif">501 not implemented 413 request entity too large + y="813.64819" + style="font-size:16px;line-height:1.25;font-family:sans-serif">413 request entity too large middlewares + y="-354.17184" + style="font-size:16px;line-height:1.25;font-family:sans-serif">middlewares + + + + + + + + + + + rate_limited + ... + true + false + true* + + 429 too many requests diff --git a/doc/src/manual/cowboy_rest.asciidoc b/doc/src/manual/cowboy_rest.asciidoc index dd5fa7e..4babcc5 100644 --- a/doc/src/manual/cowboy_rest.asciidoc +++ b/doc/src/manual/cowboy_rest.asciidoc @@ -603,6 +603,30 @@ release. // @todo Add a way to switch to loop handler for streaming the body. +=== rate_limited + +[source,erlang] +---- +rate_limited(Req, State) -> {Result, Req, State} + +Result :: false | {true, RetryAfter} +RetryAfter :: non_neg_integer() | calendar:datetime() +Default - false +---- + +Return whether the user is rate limited. + +This function can be used to temporarily restrict +access to a resource when the user has issued too +many requests. + +When the resource is rate limited the `RetryAfter` +value will be sent in the retry-after header for the +'429 Too Many Requests' response. It indicates when +the resource will become available again and can be +specified as a number of seconds in the future or a +specific date/time. + === resource_exists [source,erlang] @@ -696,6 +720,7 @@ listed here, like the authorization header. == Changelog +* *2.6*: The callback `rate_limited` was added. * *2.1*: The `switch_handler` return value was added. * *1.0*: Behavior introduced. diff --git a/src/cowboy_rest.erl b/src/cowboy_rest.erl index 512dc5b..8ab576c 100644 --- a/src/cowboy_rest.erl +++ b/src/cowboy_rest.erl @@ -180,6 +180,13 @@ when Req::cowboy_req:req(), State::any(). -optional_callbacks([previously_existed/2]). +-callback rate_limited(Req, State) + -> {{true, non_neg_integer() | calendar:datetime()} | false, Req, State} + | {stop, Req, State} + | {switch_handler(), Req, State} + when Req::cowboy_req:req(), State::any(). +-optional_callbacks([rate_limited/2]). + -callback resource_exists(Req, State) -> {boolean(), Req, State} | {stop, Req, State} @@ -363,7 +370,28 @@ is_authorized(Req, State) -> end. forbidden(Req, State) -> - expect(Req, State, forbidden, false, fun valid_content_headers/2, 403). + expect(Req, State, forbidden, false, fun rate_limited/2, 403). + +rate_limited(Req, State) -> + case call(Req, State, rate_limited) of + no_call -> + valid_content_headers(Req, State); + {stop, Req2, HandlerState} -> + terminate(Req2, State#state{handler_state=HandlerState}); + {Switch, Req2, HandlerState} when element(1, Switch) =:= switch_handler -> + switch_handler(Switch, Req2, HandlerState); + {false, Req2, HandlerState} -> + valid_content_headers(Req2, State#state{handler_state=HandlerState}); + {{true, RetryAfter0}, Req2, HandlerState} -> + RetryAfter = if + is_integer(RetryAfter0), RetryAfter0 >= 0 -> + integer_to_binary(RetryAfter0); + is_tuple(RetryAfter0) -> + cowboy_clock:rfc1123(RetryAfter0) + end, + Req3 = cowboy_req:set_resp_header(<<"retry-after">>, RetryAfter, Req2), + respond(Req3, State#state{handler_state=HandlerState}, 429) + end. valid_content_headers(Req, State) -> expect(Req, State, valid_content_headers, true, diff --git a/test/handlers/rate_limited_h.erl b/test/handlers/rate_limited_h.erl new file mode 100644 index 0000000..e54249c --- /dev/null +++ b/test/handlers/rate_limited_h.erl @@ -0,0 +1,24 @@ +%% This module does rate limiting based on the query string value. + +-module(rate_limited_h). + +-export([init/2]). +-export([rate_limited/2]). +-export([content_types_provided/2]). +-export([get_text_plain/2]). + +init(Req, State) -> + {cowboy_rest, Req, State}. + +rate_limited(Req=#{qs := <<"false">>}, State) -> + {false, Req, State}; +rate_limited(Req=#{qs := <<"true-date">>}, State) -> + {{true, {{2222, 2, 22}, {11, 11, 11}}}, Req, State}; +rate_limited(Req=#{qs := <<"true">>}, State) -> + {{true, 3600}, Req, State}. + +content_types_provided(Req, State) -> + {[{{<<"text">>, <<"plain">>, []}, get_text_plain}], Req, State}. + +get_text_plain(Req, State) -> + {<<"This is REST!">>, Req, State}. diff --git a/test/rest_handler_SUITE.erl b/test/rest_handler_SUITE.erl index 93532fe..09ce8fb 100644 --- a/test/rest_handler_SUITE.erl +++ b/test/rest_handler_SUITE.erl @@ -48,6 +48,7 @@ init_dispatch(_) -> {"/charset_in_content_types_provided_implicit_no_callback", charset_in_content_types_provided_implicit_no_callback_h, []}, {"/provide_callback_missing", provide_callback_missing_h, []}, + {"/rate_limited", rate_limited_h, []}, {"/switch_handler", switch_handler_h, run}, {"/switch_handler_opts", switch_handler_h, hibernate} ]}]). @@ -287,6 +288,31 @@ provide_callback_missing(Config) -> {response, fin, 500, _} = gun:await(ConnPid, Ref), ok. +rate_limited(Config) -> + doc("A 429 response must be sent when the rate_limited callback returns true. " + "The retry-after header is specified as an integer."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/rate_limited?true", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 429, Headers} = gun:await(ConnPid, Ref), + {_, <<"3600">>} = lists:keyfind(<<"retry-after">>, 1, Headers), + ok. + +rate_limited_datetime(Config) -> + doc("A 429 response must be sent when the rate_limited callback returns true. " + "The retry-after header is specified as a date/time tuple."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/rate_limited?true-date", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 429, Headers} = gun:await(ConnPid, Ref), + {_, <<"Fri, 22 Feb 2222 11:11:11 GMT">>} = lists:keyfind(<<"retry-after">>, 1, Headers), + ok. + +rate_not_limited(Config) -> + doc("A success response must be sent when the rate_limited callback returns false."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/rate_limited?false", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, nofin, 200, _} = gun:await(ConnPid, Ref), + ok. + switch_handler(Config) -> doc("Switch REST to loop handler for streaming the response body."), ConnPid = gun_open(Config), -- cgit v1.2.3