From 1ffda21ae38d203e381bedbf7bdbbd69c9031062 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Fri, 20 May 2022 14:54:26 -0600 Subject: [PATCH] Implement ratelimit on authentications --- config.nix | 3 +++ srv/default.nix | 2 ++ srv/src/http/api.go | 33 +++++++++++++++++++++++++-------- srv/src/http/auth.go | 30 ++++++++++++++++++++++++------ srv/src/http/auth_test.go | 11 +++++++---- srv/src/http/posts.go | 28 +++++++++++++++++++++++----- 6 files changed, 84 insertions(+), 23 deletions(-) diff --git a/config.nix b/config.nix index 2aa8462..e6b6697 100644 --- a/config.nix +++ b/config.nix @@ -16,4 +16,7 @@ httpAuthUsers = { "foo" = "$2a$13$0JdWlUfHc.3XimEMpEu1cuu6RodhUvzD9l7iiAqa4YkM3mcFV5Pxi"; }; + + # Very low, should be increased for prod. + httpAuthRatelimit = "1s"; } diff --git a/srv/default.nix b/srv/default.nix index 6f93f2f..c55a987 100644 --- a/srv/default.nix +++ b/srv/default.nix @@ -29,6 +29,8 @@ export MEDIOCRE_BLOG_LISTEN_PROTO="${config.listenProto}" export MEDIOCRE_BLOG_LISTEN_ADDR="${config.listenAddr}" export MEDIOCRE_BLOG_HTTP_AUTH_USERS='${builtins.toJSON config.httpAuthUsers}' + + export MEDIOCRE_BLOG_HTTP_AUTH_RATELIMIT='${config.httpAuthRatelimit}' ''; build = buildGoModule { diff --git a/srv/src/http/api.go b/srv/src/http/api.go index 4d049ed..85a6375 100644 --- a/srv/src/http/api.go +++ b/srv/src/http/api.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "os" + "time" "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" "github.com/mediocregopher/blog.mediocregopher.com/srv/chat" @@ -52,6 +53,10 @@ type Params struct { // and the values are the password hash which accompanies those users. The // password hash must have been produced by NewPasswordHash. AuthUsers map[string]string + + // AuthRatelimit indicates how much time must pass between subsequent auth + // attempts. + AuthRatelimit time.Duration } // SetupCfg implement the cfg.Cfger interface. @@ -61,10 +66,20 @@ func (p *Params) SetupCfg(cfg *cfg.Cfg) { httpAuthUsersStr := cfg.String("http-auth-users", "{}", "JSON object with usernames as values and password hashes (produced by the hash-password binary) as values. Denotes users which are able to edit server-side data") + httpAuthRatelimitStr := cfg.String("http-auth-ratelimit", "5s", "Minimum duration which must be waited between subsequent auth attempts") + cfg.OnInit(func(context.Context) error { - if err := json.Unmarshal([]byte(*httpAuthUsersStr), &p.AuthUsers); err != nil { + + err := json.Unmarshal([]byte(*httpAuthUsersStr), &p.AuthUsers) + + if err != nil { return fmt.Errorf("unmarshaling -http-auth-users: %w", err) } + + if p.AuthRatelimit, err = time.ParseDuration(*httpAuthRatelimitStr); err != nil { + return fmt.Errorf("unmarshaling -http-auth-ratelimit: %w", err) + } + return nil }) } @@ -73,6 +88,7 @@ func (p *Params) SetupCfg(cfg *cfg.Cfg) { func (p *Params) Annotate(a mctx.Annotations) { a["listenProto"] = p.ListenProto a["listenAddr"] = p.ListenAddr + a["authRatelimit"] = p.AuthRatelimit } // API will listen on the port configured for it, and serve HTTP requests for @@ -86,6 +102,7 @@ type api struct { srv *http.Server redirectTpl *template.Template + auther Auther } // New initializes and returns a new API instance, including setting up all @@ -105,6 +122,7 @@ func New(params Params) (API, error) { a := &api{ params: params, + auther: NewAuther(params.AuthUsers, params.AuthRatelimit), } a.redirectTpl = a.mustParseTpl("redirect.html") @@ -124,6 +142,7 @@ func New(params Params) (API, error) { } func (a *api) Shutdown(ctx context.Context) error { + defer a.auther.Close() if err := a.srv.Shutdown(ctx); err != nil { return err } @@ -149,8 +168,6 @@ func (a *api) handler() http.Handler { return h } - auther := NewAuther(a.params.AuthUsers) - mux := http.NewServeMux() { @@ -179,13 +196,13 @@ func (a *api) handler() http.Handler { apiutil.MethodMux(map[string]http.Handler{ "GET": a.renderPostHandler(), "EDIT": a.editPostHandler(), - "POST": authMiddleware(auther, + "POST": authMiddleware(a.auther, formMiddleware(a.postPostHandler()), ), - "DELETE": authMiddleware(auther, + "DELETE": authMiddleware(a.auther, formMiddleware(a.deletePostHandler()), ), - "PREVIEW": authMiddleware(auther, + "PREVIEW": authMiddleware(a.auther, formMiddleware(a.previewPostHandler()), ), }), @@ -194,10 +211,10 @@ func (a *api) handler() http.Handler { mux.Handle("/assets/", http.StripPrefix("/assets", apiutil.MethodMux(map[string]http.Handler{ "GET": a.getPostAssetHandler(), - "POST": authMiddleware(auther, + "POST": authMiddleware(a.auther, formMiddleware(a.postPostAssetHandler()), ), - "DELETE": authMiddleware(auther, + "DELETE": authMiddleware(a.auther, formMiddleware(a.deletePostAssetHandler()), ), }), diff --git a/srv/src/http/auth.go b/srv/src/http/auth.go index cd247a3..9527cc8 100644 --- a/srv/src/http/auth.go +++ b/srv/src/http/auth.go @@ -1,7 +1,9 @@ package http import ( + "context" "net/http" + "time" "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil" "golang.org/x/crypto/bcrypt" @@ -19,21 +21,37 @@ func NewPasswordHash(plaintext string) string { // Auther determines who can do what. type Auther interface { - Allowed(username, password string) bool + Allowed(ctx context.Context, username, password string) bool + Close() error } type auther struct { - users map[string]string + users map[string]string + ticker *time.Ticker } // NewAuther initializes and returns an Auther will which allow the given // username and password hash combinations. Password hashes must have been // created using NewPasswordHash. -func NewAuther(users map[string]string) Auther { - return &auther{users: users} +func NewAuther(users map[string]string, ratelimit time.Duration) Auther { + return &auther{ + users: users, + ticker: time.NewTicker(ratelimit), + } +} + +func (a *auther) Close() error { + a.ticker.Stop() + return nil } -func (a *auther) Allowed(username, password string) bool { +func (a *auther) Allowed(ctx context.Context, username, password string) bool { + + select { + case <-ctx.Done(): + return false + case <-a.ticker.C: + } hashedPassword, ok := a.users[username] if !ok { @@ -64,7 +82,7 @@ func authMiddleware(auther Auther, h http.Handler) http.Handler { return } - if !auther.Allowed(username, password) { + if !auther.Allowed(r.Context(), username, password) { respondUnauthorized(rw, r) return } diff --git a/srv/src/http/auth_test.go b/srv/src/http/auth_test.go index 2a1e6e9..9e2d440 100644 --- a/srv/src/http/auth_test.go +++ b/srv/src/http/auth_test.go @@ -1,21 +1,24 @@ package http import ( + "context" "testing" + "time" "github.com/stretchr/testify/assert" ) func TestAuther(t *testing.T) { + ctx := context.Background() password := "foo" hashedPassword := NewPasswordHash(password) auther := NewAuther(map[string]string{ "FOO": hashedPassword, - }) + }, 1*time.Millisecond) - assert.False(t, auther.Allowed("BAR", password)) - assert.False(t, auther.Allowed("FOO", "bar")) - assert.True(t, auther.Allowed("FOO", password)) + assert.False(t, auther.Allowed(ctx, "BAR", password)) + assert.False(t, auther.Allowed(ctx, "FOO", "bar")) + assert.True(t, auther.Allowed(ctx, "FOO", password)) } diff --git a/srv/src/http/posts.go b/srv/src/http/posts.go index 0aea3e3..816e361 100644 --- a/srv/src/http/posts.go +++ b/srv/src/http/posts.go @@ -197,7 +197,7 @@ func (a *api) editPostHandler() http.Handler { }) } -func postFromPostReq(r *http.Request) post.Post { +func postFromPostReq(r *http.Request) (post.Post, error) { p := post.Post{ ID: r.PostFormValue("id"), @@ -207,18 +207,30 @@ func postFromPostReq(r *http.Request) post.Post { Series: r.PostFormValue("series"), } - p.Body = strings.TrimSpace(r.PostFormValue("body")) // textareas encode newlines as CRLF for historical reasons p.Body = strings.ReplaceAll(p.Body, "\r\n", "\n") + p.Body = strings.TrimSpace(r.PostFormValue("body")) + + if p.ID == "" || + p.Title == "" || + p.Description == "" || + p.Body == "" || + len(p.Tags) == 0 { + return post.Post{}, errors.New("ID, Title, Description, Tags, and Body are all required") + } - return p + return p, nil } func (a *api) postPostHandler() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - p := postFromPostReq(r) + p, err := postFromPostReq(r) + if err != nil { + apiutil.BadRequest(rw, r, err) + return + } if err := a.params.PostStore.Set(p, time.Now()); err != nil { apiutil.InternalServerError( @@ -267,8 +279,14 @@ func (a *api) previewPostHandler() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + p, err := postFromPostReq(r) + if err != nil { + apiutil.BadRequest(rw, r, err) + return + } + storedPost := post.StoredPost{ - Post: postFromPostReq(r), + Post: p, PublishedAt: time.Now(), }