From 320d1b016747ba4501da9417d9ce5f99368a5768 Mon Sep 17 00:00:00 2001 From: Frédéric Guillot Date: Tue, 2 Jan 2018 22:04:48 -0800 Subject: Refactor packages to have more idiomatic code base --- http/cookie/cookie.go | 44 +++++++++++ http/handler/context.go | 160 ++++++++++++++++++++++++++++++++++++++++ http/handler/handler.go | 70 ++++++++++++++++++ http/handler/html_response.go | 65 ++++++++++++++++ http/handler/json_response.go | 111 ++++++++++++++++++++++++++++ http/handler/request.go | 124 +++++++++++++++++++++++++++++++ http/handler/response.go | 82 ++++++++++++++++++++ http/handler/xml_response.go | 23 ++++++ http/middleware/basic_auth.go | 72 ++++++++++++++++++ http/middleware/context_keys.go | 46 ++++++++++++ http/middleware/fever.go | 57 ++++++++++++++ http/middleware/middleware.go | 36 +++++++++ http/middleware/session.go | 84 +++++++++++++++++++++ http/middleware/user_session.go | 78 ++++++++++++++++++++ http/route/route.go | 38 ++++++++++ 15 files changed, 1090 insertions(+) create mode 100644 http/cookie/cookie.go create mode 100644 http/handler/context.go create mode 100644 http/handler/handler.go create mode 100644 http/handler/html_response.go create mode 100644 http/handler/json_response.go create mode 100644 http/handler/request.go create mode 100644 http/handler/response.go create mode 100644 http/handler/xml_response.go create mode 100644 http/middleware/basic_auth.go create mode 100644 http/middleware/context_keys.go create mode 100644 http/middleware/fever.go create mode 100644 http/middleware/middleware.go create mode 100644 http/middleware/session.go create mode 100644 http/middleware/user_session.go create mode 100644 http/route/route.go (limited to 'http') diff --git a/http/cookie/cookie.go b/http/cookie/cookie.go new file mode 100644 index 0000000..d1f3e72 --- /dev/null +++ b/http/cookie/cookie.go @@ -0,0 +1,44 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package cookie + +import ( + "net/http" + "time" +) + +// Cookie names. +const ( + CookieSessionID = "sessionID" + CookieUserSessionID = "userSessionID" + + // Cookie duration in days. + cookieDuration = 30 +) + +// New creates a new cookie. +func New(name, value string, isHTTPS bool) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: value, + Path: "/", + Secure: isHTTPS, + HttpOnly: true, + Expires: time.Now().Add(cookieDuration * 24 * time.Hour), + } +} + +// Expired returns an expired cookie. +func Expired(name string, isHTTPS bool) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: "", + Path: "/", + Secure: isHTTPS, + HttpOnly: true, + MaxAge: -1, + Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), + } +} diff --git a/http/handler/context.go b/http/handler/context.go new file mode 100644 index 0000000..a04a816 --- /dev/null +++ b/http/handler/context.go @@ -0,0 +1,160 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package handler + +import ( + "net/http" + + "github.com/miniflux/miniflux/crypto" + "github.com/miniflux/miniflux/http/middleware" + "github.com/miniflux/miniflux/http/route" + "github.com/miniflux/miniflux/locale" + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/model" + "github.com/miniflux/miniflux/storage" + + "github.com/gorilla/mux" +) + +// Context contains helper functions related to the current request. +type Context struct { + writer http.ResponseWriter + request *http.Request + store *storage.Storage + router *mux.Router + user *model.User + translator *locale.Translator +} + +// IsAdminUser checks if the logged user is administrator. +func (c *Context) IsAdminUser() bool { + if v := c.request.Context().Value(middleware.IsAdminUserContextKey); v != nil { + return v.(bool) + } + return false +} + +// UserTimezone returns the timezone used by the logged user. +func (c *Context) UserTimezone() string { + value := c.getContextStringValue(middleware.UserTimezoneContextKey) + if value == "" { + value = "UTC" + } + return value +} + +// IsAuthenticated returns a boolean if the user is authenticated. +func (c *Context) IsAuthenticated() bool { + if v := c.request.Context().Value(middleware.IsAuthenticatedContextKey); v != nil { + return v.(bool) + } + return false +} + +// UserID returns the UserID of the logged user. +func (c *Context) UserID() int64 { + if v := c.request.Context().Value(middleware.UserIDContextKey); v != nil { + return v.(int64) + } + return 0 +} + +// LoggedUser returns all properties related to the logged user. +func (c *Context) LoggedUser() *model.User { + if c.user == nil { + var err error + c.user, err = c.store.UserByID(c.UserID()) + if err != nil { + logger.Fatal("[Context] %v", err) + } + + if c.user == nil { + logger.Fatal("Unable to find user from context") + } + } + + return c.user +} + +// UserLanguage get the locale used by the current logged user. +func (c *Context) UserLanguage() string { + user := c.LoggedUser() + return user.Language +} + +// Translate translates a message in the current language. +func (c *Context) Translate(message string, args ...interface{}) string { + return c.translator.GetLanguage(c.UserLanguage()).Get(message, args...) +} + +// CSRF returns the current CSRF token. +func (c *Context) CSRF() string { + return c.getContextStringValue(middleware.CSRFContextKey) +} + +// SessionID returns the current session ID. +func (c *Context) SessionID() string { + return c.getContextStringValue(middleware.SessionIDContextKey) +} + +// UserSessionToken returns the current user session token. +func (c *Context) UserSessionToken() string { + return c.getContextStringValue(middleware.UserSessionTokenContextKey) +} + +// OAuth2State returns the current OAuth2 state. +func (c *Context) OAuth2State() string { + return c.getContextStringValue(middleware.OAuth2StateContextKey) +} + +// GenerateOAuth2State generate a new OAuth2 state. +func (c *Context) GenerateOAuth2State() string { + state := crypto.GenerateRandomString(32) + c.store.UpdateSessionField(c.SessionID(), "oauth2_state", state) + return state +} + +// SetFlashMessage defines a new flash message. +func (c *Context) SetFlashMessage(message string) { + c.store.UpdateSessionField(c.SessionID(), "flash_message", message) +} + +// FlashMessage returns the flash message and remove it. +func (c *Context) FlashMessage() string { + message := c.getContextStringValue(middleware.FlashMessageContextKey) + c.store.UpdateSessionField(c.SessionID(), "flash_message", "") + return message +} + +// SetFlashErrorMessage defines a new flash error message. +func (c *Context) SetFlashErrorMessage(message string) { + c.store.UpdateSessionField(c.SessionID(), "flash_error_message", message) +} + +// FlashErrorMessage returns the error flash message and remove it. +func (c *Context) FlashErrorMessage() string { + message := c.getContextStringValue(middleware.FlashErrorMessageContextKey) + c.store.UpdateSessionField(c.SessionID(), "flash_error_message", "") + return message +} + +func (c *Context) getContextStringValue(key *middleware.ContextKey) string { + if v := c.request.Context().Value(key); v != nil { + return v.(string) + } + + logger.Error("[Core:Context] Missing key: %s", key) + return "" +} + +// Route returns the path for the given arguments. +func (c *Context) Route(name string, args ...interface{}) string { + return route.Path(c.router, name, args...) +} + +// NewContext creates a new Context. +func NewContext(r *http.Request, store *storage.Storage, router *mux.Router, translator *locale.Translator) *Context { + return &Context{request: r, store: store, router: router, translator: translator} +} diff --git a/http/handler/handler.go b/http/handler/handler.go new file mode 100644 index 0000000..3dd1d1b --- /dev/null +++ b/http/handler/handler.go @@ -0,0 +1,70 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package handler + +import ( + "net/http" + "time" + + "github.com/miniflux/miniflux/config" + "github.com/miniflux/miniflux/http/middleware" + "github.com/miniflux/miniflux/locale" + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/storage" + "github.com/miniflux/miniflux/template" + "github.com/miniflux/miniflux/timer" + + "github.com/gorilla/mux" + "github.com/tomasen/realip" +) + +// ControllerFunc is an application HTTP handler. +type ControllerFunc func(ctx *Context, request *Request, response *Response) + +// Handler manages HTTP handlers and middlewares. +type Handler struct { + cfg *config.Config + store *storage.Storage + translator *locale.Translator + template *template.Engine + router *mux.Router + middleware *middleware.Chain +} + +// Use is a wrapper around an HTTP handler. +func (h *Handler) Use(f ControllerFunc) http.Handler { + return h.middleware.WrapFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer timer.ExecutionTime(time.Now(), r.URL.Path) + logger.Debug("[HTTP] %s %s %s", realip.RealIP(r), r.Method, r.URL.Path) + + if r.Header.Get("X-Forwarded-Proto") == "https" { + h.cfg.IsHTTPS = true + } + + ctx := NewContext(r, h.store, h.router, h.translator) + request := NewRequest(r) + response := NewResponse(w, r, h.template) + + if ctx.IsAuthenticated() { + h.template.SetLanguage(ctx.UserLanguage()) + } else { + h.template.SetLanguage("en_US") + } + + f(ctx, request, response) + })) +} + +// NewHandler returns a new Handler. +func NewHandler(cfg *config.Config, store *storage.Storage, router *mux.Router, template *template.Engine, translator *locale.Translator, middleware *middleware.Chain) *Handler { + return &Handler{ + cfg: cfg, + store: store, + translator: translator, + router: router, + template: template, + middleware: middleware, + } +} diff --git a/http/handler/html_response.go b/http/handler/html_response.go new file mode 100644 index 0000000..26e5270 --- /dev/null +++ b/http/handler/html_response.go @@ -0,0 +1,65 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package handler + +import ( + "net/http" + + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/template" +) + +// HTMLResponse handles HTML responses. +type HTMLResponse struct { + writer http.ResponseWriter + request *http.Request + template *template.Engine +} + +// Render execute a template and send to the client the generated HTML. +func (h *HTMLResponse) Render(template string, args map[string]interface{}) { + h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") + h.template.Execute(h.writer, template, args) +} + +// ServerError sends a 500 error to the browser. +func (h *HTMLResponse) ServerError(err error) { + h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") + h.writer.WriteHeader(http.StatusInternalServerError) + + if err != nil { + logger.Error("[Internal Server Error] %v", err) + h.writer.Write([]byte("Internal Server Error: " + err.Error())) + } else { + h.writer.Write([]byte("Internal Server Error")) + } +} + +// BadRequest sends a 400 error to the browser. +func (h *HTMLResponse) BadRequest(err error) { + h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") + h.writer.WriteHeader(http.StatusBadRequest) + + if err != nil { + logger.Error("[Bad Request] %v", err) + h.writer.Write([]byte("Bad Request: " + err.Error())) + } else { + h.writer.Write([]byte("Bad Request")) + } +} + +// NotFound sends a 404 error to the browser. +func (h *HTMLResponse) NotFound() { + h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") + h.writer.WriteHeader(http.StatusNotFound) + h.writer.Write([]byte("Page Not Found")) +} + +// Forbidden sends a 403 error to the browser. +func (h *HTMLResponse) Forbidden() { + h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") + h.writer.WriteHeader(http.StatusForbidden) + h.writer.Write([]byte("Access Forbidden")) +} diff --git a/http/handler/json_response.go b/http/handler/json_response.go new file mode 100644 index 0000000..a79268c --- /dev/null +++ b/http/handler/json_response.go @@ -0,0 +1,111 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/miniflux/miniflux/logger" +) + +// JSONResponse handles JSON responses. +type JSONResponse struct { + writer http.ResponseWriter + request *http.Request +} + +// Standard sends a JSON response with the status code 200. +func (j *JSONResponse) Standard(v interface{}) { + j.commonHeaders() + j.writer.WriteHeader(http.StatusOK) + j.writer.Write(j.toJSON(v)) +} + +// Created sends a JSON response with the status code 201. +func (j *JSONResponse) Created(v interface{}) { + j.commonHeaders() + j.writer.WriteHeader(http.StatusCreated) + j.writer.Write(j.toJSON(v)) +} + +// NoContent sends a JSON response with the status code 204. +func (j *JSONResponse) NoContent() { + j.commonHeaders() + j.writer.WriteHeader(http.StatusNoContent) +} + +// BadRequest sends a JSON response with the status code 400. +func (j *JSONResponse) BadRequest(err error) { + logger.Error("[Bad Request] %v", err) + j.commonHeaders() + j.writer.WriteHeader(http.StatusBadRequest) + + if err != nil { + j.writer.Write(j.encodeError(err)) + } +} + +// NotFound sends a JSON response with the status code 404. +func (j *JSONResponse) NotFound(err error) { + logger.Error("[Not Found] %v", err) + j.commonHeaders() + j.writer.WriteHeader(http.StatusNotFound) + j.writer.Write(j.encodeError(err)) +} + +// ServerError sends a JSON response with the status code 500. +func (j *JSONResponse) ServerError(err error) { + logger.Error("[Internal Server Error] %v", err) + j.commonHeaders() + j.writer.WriteHeader(http.StatusInternalServerError) + + if err != nil { + j.writer.Write(j.encodeError(err)) + } +} + +// Forbidden sends a JSON response with the status code 403. +func (j *JSONResponse) Forbidden() { + logger.Info("[API:Forbidden]") + j.commonHeaders() + j.writer.WriteHeader(http.StatusForbidden) + j.writer.Write(j.encodeError(errors.New("Access Forbidden"))) +} + +func (j *JSONResponse) commonHeaders() { + j.writer.Header().Set("Accept", "application/json") + j.writer.Header().Set("Content-Type", "application/json; charset=utf-8") +} + +func (j *JSONResponse) encodeError(err error) []byte { + type errorMsg struct { + ErrorMessage string `json:"error_message"` + } + + tmp := errorMsg{ErrorMessage: err.Error()} + data, err := json.Marshal(tmp) + if err != nil { + logger.Error("encoding error: %v", err) + } + + return data +} + +func (j *JSONResponse) toJSON(v interface{}) []byte { + b, err := json.Marshal(v) + if err != nil { + logger.Error("encoding error: %v", err) + return []byte("") + } + + return b +} + +// NewJSONResponse returns a new JSONResponse. +func NewJSONResponse(w http.ResponseWriter, r *http.Request) *JSONResponse { + return &JSONResponse{request: r, writer: w} +} diff --git a/http/handler/request.go b/http/handler/request.go new file mode 100644 index 0000000..7289a70 --- /dev/null +++ b/http/handler/request.go @@ -0,0 +1,124 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package handler + +import ( + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/miniflux/miniflux/logger" +) + +// Request is a thin wrapper around "http.Request". +type Request struct { + request *http.Request +} + +// Request returns the raw Request struct. +func (r *Request) Request() *http.Request { + return r.request +} + +// Body returns the request body. +func (r *Request) Body() io.ReadCloser { + return r.request.Body +} + +// File returns uploaded file properties. +func (r *Request) File(name string) (multipart.File, *multipart.FileHeader, error) { + return r.request.FormFile(name) +} + +// Cookie returns the cookie value. +func (r *Request) Cookie(name string) string { + cookie, err := r.request.Cookie(name) + if err == http.ErrNoCookie { + return "" + } + + return cookie.Value +} + +// FormValue returns a form value as integer. +func (r *Request) FormValue(param string) string { + return r.request.FormValue(param) +} + +// FormIntegerValue returns a form value as integer. +func (r *Request) FormIntegerValue(param string) int64 { + value := r.request.FormValue(param) + integer, _ := strconv.Atoi(value) + return int64(integer) +} + +// IntegerParam returns an URL parameter as integer. +func (r *Request) IntegerParam(param string) (int64, error) { + vars := mux.Vars(r.request) + value, err := strconv.Atoi(vars[param]) + if err != nil { + logger.Error("[IntegerParam] %v", err) + return 0, fmt.Errorf("%s parameter is not an integer", param) + } + + if value < 0 { + return 0, nil + } + + return int64(value), nil +} + +// StringParam returns an URL parameter as string. +func (r *Request) StringParam(param, defaultValue string) string { + vars := mux.Vars(r.request) + value := vars[param] + if value == "" { + value = defaultValue + } + return value +} + +// QueryStringParam returns a querystring parameter as string. +func (r *Request) QueryStringParam(param, defaultValue string) string { + value := r.request.URL.Query().Get(param) + if value == "" { + value = defaultValue + } + return value +} + +// QueryIntegerParam returns a querystring parameter as string. +func (r *Request) QueryIntegerParam(param string, defaultValue int) int { + value := r.request.URL.Query().Get(param) + if value == "" { + return defaultValue + } + + val, err := strconv.Atoi(value) + if err != nil { + return defaultValue + } + + if val < 0 { + return defaultValue + } + + return val +} + +// HasQueryParam checks if the query string contains the given parameter. +func (r *Request) HasQueryParam(param string) bool { + values := r.request.URL.Query() + _, ok := values[param] + return ok +} + +// NewRequest returns a new Request. +func NewRequest(r *http.Request) *Request { + return &Request{r} +} diff --git a/http/handler/response.go b/http/handler/response.go new file mode 100644 index 0000000..34980a3 --- /dev/null +++ b/http/handler/response.go @@ -0,0 +1,82 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package handler + +import ( + "net/http" + "time" + + "github.com/miniflux/miniflux/template" +) + +// Response handles HTTP responses. +type Response struct { + writer http.ResponseWriter + request *http.Request + template *template.Engine +} + +// SetCookie send a cookie to the client. +func (r *Response) SetCookie(cookie *http.Cookie) { + http.SetCookie(r.writer, cookie) +} + +// JSON returns a JSONResponse. +func (r *Response) JSON() *JSONResponse { + r.commonHeaders() + return NewJSONResponse(r.writer, r.request) +} + +// HTML returns a HTMLResponse. +func (r *Response) HTML() *HTMLResponse { + r.commonHeaders() + return &HTMLResponse{writer: r.writer, request: r.request, template: r.template} +} + +// XML returns a XMLResponse. +func (r *Response) XML() *XMLResponse { + r.commonHeaders() + return &XMLResponse{writer: r.writer, request: r.request} +} + +// Redirect redirects the user to another location. +func (r *Response) Redirect(path string) { + http.Redirect(r.writer, r.request, path, http.StatusFound) +} + +// NotModified sends a response with a 304 status code. +func (r *Response) NotModified() { + r.commonHeaders() + r.writer.WriteHeader(http.StatusNotModified) +} + +// Cache returns a response with caching headers. +func (r *Response) Cache(mimeType, etag string, content []byte, duration time.Duration) { + r.writer.Header().Set("Content-Type", mimeType) + r.writer.Header().Set("ETag", etag) + r.writer.Header().Set("Cache-Control", "public") + r.writer.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123)) + + if etag == r.request.Header.Get("If-None-Match") { + r.writer.WriteHeader(http.StatusNotModified) + } else { + r.writer.Write(content) + } +} + +func (r *Response) commonHeaders() { + r.writer.Header().Set("X-XSS-Protection", "1; mode=block") + r.writer.Header().Set("X-Content-Type-Options", "nosniff") + r.writer.Header().Set("X-Frame-Options", "DENY") + + // Even if the directive "frame-src" has been deprecated in Firefox, + // we keep it to stay compatible with other browsers. + r.writer.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *") +} + +// NewResponse returns a new Response. +func NewResponse(w http.ResponseWriter, r *http.Request, template *template.Engine) *Response { + return &Response{writer: w, request: r, template: template} +} diff --git a/http/handler/xml_response.go b/http/handler/xml_response.go new file mode 100644 index 0000000..b5a24ca --- /dev/null +++ b/http/handler/xml_response.go @@ -0,0 +1,23 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package handler + +import ( + "fmt" + "net/http" +) + +// XMLResponse handles XML responses. +type XMLResponse struct { + writer http.ResponseWriter + request *http.Request +} + +// Download force the download of a XML document. +func (x *XMLResponse) Download(filename, data string) { + x.writer.Header().Set("Content-Type", "text/xml") + x.writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + x.writer.Write([]byte(data)) +} diff --git a/http/middleware/basic_auth.go b/http/middleware/basic_auth.go new file mode 100644 index 0000000..35a9f81 --- /dev/null +++ b/http/middleware/basic_auth.go @@ -0,0 +1,72 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package middleware + +import ( + "context" + "net/http" + + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/storage" +) + +// BasicAuthMiddleware is the middleware for HTTP Basic authentication. +type BasicAuthMiddleware struct { + store *storage.Storage +} + +// Handler executes the middleware. +func (b *BasicAuthMiddleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + errorResponse := `{"error_message": "Not Authorized"}` + + username, password, authOK := r.BasicAuth() + if !authOK { + logger.Debug("[Middleware:BasicAuth] No authentication headers sent") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(errorResponse)) + return + } + + if err := b.store.CheckPassword(username, password); err != nil { + logger.Info("[Middleware:BasicAuth] Invalid username or password: %s", username) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(errorResponse)) + return + } + + user, err := b.store.UserByUsername(username) + if err != nil { + logger.Error("[Middleware:BasicAuth] %v", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(errorResponse)) + return + } + + if user == nil { + logger.Info("[Middleware:BasicAuth] User not found: %s", username) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(errorResponse)) + return + } + + logger.Info("[Middleware:BasicAuth] User authenticated: %s", username) + b.store.SetLastLogin(user.ID) + + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDContextKey, user.ID) + ctx = context.WithValue(ctx, UserTimezoneContextKey, user.Timezone) + ctx = context.WithValue(ctx, IsAdminUserContextKey, user.IsAdmin) + ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// NewBasicAuthMiddleware returns a new BasicAuthMiddleware. +func NewBasicAuthMiddleware(s *storage.Storage) *BasicAuthMiddleware { + return &BasicAuthMiddleware{store: s} +} diff --git a/http/middleware/context_keys.go b/http/middleware/context_keys.go new file mode 100644 index 0000000..31ad286 --- /dev/null +++ b/http/middleware/context_keys.go @@ -0,0 +1,46 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package middleware + +// ContextKey represents a context key. +type ContextKey struct { + name string +} + +func (c ContextKey) String() string { + return c.name +} + +var ( + // UserIDContextKey is the context key used to store the user ID. + UserIDContextKey = &ContextKey{"UserID"} + + // UserTimezoneContextKey is the context key used to store the user timezone. + UserTimezoneContextKey = &ContextKey{"UserTimezone"} + + // IsAdminUserContextKey is the context key used to store the user role. + IsAdminUserContextKey = &ContextKey{"IsAdminUser"} + + // IsAuthenticatedContextKey is the context key used to store the authentication flag. + IsAuthenticatedContextKey = &ContextKey{"IsAuthenticated"} + + // UserSessionTokenContextKey is the context key used to store the user session ID. + UserSessionTokenContextKey = &ContextKey{"UserSessionToken"} + + // SessionIDContextKey is the context key used to store the session ID. + SessionIDContextKey = &ContextKey{"SessionID"} + + // CSRFContextKey is the context key used to store CSRF token. + CSRFContextKey = &ContextKey{"CSRF"} + + // OAuth2StateContextKey is the context key used to store OAuth2 state. + OAuth2StateContextKey = &ContextKey{"OAuth2State"} + + // FlashMessageContextKey is the context key used to store a flash message. + FlashMessageContextKey = &ContextKey{"FlashMessage"} + + // FlashErrorMessageContextKey is the context key used to store a flash error message. + FlashErrorMessageContextKey = &ContextKey{"FlashErrorMessage"} +) diff --git a/http/middleware/fever.go b/http/middleware/fever.go new file mode 100644 index 0000000..54eb0ca --- /dev/null +++ b/http/middleware/fever.go @@ -0,0 +1,57 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package middleware + +import ( + "context" + "net/http" + + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/storage" +) + +// FeverMiddleware is the middleware that handles Fever API. +type FeverMiddleware struct { + store *storage.Storage +} + +// Handler executes the middleware. +func (f *FeverMiddleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger.Debug("[Middleware:Fever]") + + apiKey := r.FormValue("api_key") + user, err := f.store.UserByFeverToken(apiKey) + if err != nil { + logger.Error("[Fever] %v", err) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"api_version": 3, "auth": 0}`)) + return + } + + if user == nil { + logger.Info("[Middleware:Fever] Fever authentication failure") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"api_version": 3, "auth": 0}`)) + return + } + + logger.Info("[Middleware:Fever] User #%d is authenticated", user.ID) + f.store.SetLastLogin(user.ID) + + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDContextKey, user.ID) + ctx = context.WithValue(ctx, UserTimezoneContextKey, user.Timezone) + ctx = context.WithValue(ctx, IsAdminUserContextKey, user.IsAdmin) + ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// NewFeverMiddleware returns a new FeverMiddleware. +func NewFeverMiddleware(s *storage.Storage) *FeverMiddleware { + return &FeverMiddleware{store: s} +} diff --git a/http/middleware/middleware.go b/http/middleware/middleware.go new file mode 100644 index 0000000..9853bc3 --- /dev/null +++ b/http/middleware/middleware.go @@ -0,0 +1,36 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package middleware + +import ( + "net/http" +) + +// Middleware represents a HTTP middleware. +type Middleware func(http.Handler) http.Handler + +// Chain handles a list of middlewares. +type Chain struct { + middlewares []Middleware +} + +// Wrap adds a HTTP handler into the chain. +func (m *Chain) Wrap(h http.Handler) http.Handler { + for i := range m.middlewares { + h = m.middlewares[len(m.middlewares)-1-i](h) + } + + return h +} + +// WrapFunc adds a HTTP handler function into the chain. +func (m *Chain) WrapFunc(fn http.HandlerFunc) http.Handler { + return m.Wrap(fn) +} + +// NewChain returns a new Chain. +func NewChain(middlewares ...Middleware) *Chain { + return &Chain{append(([]Middleware)(nil), middlewares...)} +} diff --git a/http/middleware/session.go b/http/middleware/session.go new file mode 100644 index 0000000..a0e9fbd --- /dev/null +++ b/http/middleware/session.go @@ -0,0 +1,84 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package middleware + +import ( + "context" + "net/http" + + "github.com/miniflux/miniflux/config" + "github.com/miniflux/miniflux/http/cookie" + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/model" + "github.com/miniflux/miniflux/storage" +) + +// SessionMiddleware represents a session middleware. +type SessionMiddleware struct { + cfg *config.Config + store *storage.Storage +} + +// Handler execute the middleware. +func (s *SessionMiddleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + session := s.getSessionValueFromCookie(r) + + if session == nil { + logger.Debug("[Middleware:Session] Session not found") + session, err = s.store.CreateSession() + if err != nil { + logger.Error("[Middleware:Session] %v", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS)) + } else { + logger.Debug("[Middleware:Session] %s", session) + } + + if r.Method == "POST" { + formValue := r.FormValue("csrf") + headerValue := r.Header.Get("X-Csrf-Token") + + if session.Data.CSRF != formValue && session.Data.CSRF != headerValue { + logger.Error(`[Middleware:Session] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue) + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid or missing CSRF session!")) + return + } + } + + ctx := r.Context() + ctx = context.WithValue(ctx, SessionIDContextKey, session.ID) + ctx = context.WithValue(ctx, CSRFContextKey, session.Data.CSRF) + ctx = context.WithValue(ctx, OAuth2StateContextKey, session.Data.OAuth2State) + ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage) + ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *SessionMiddleware) getSessionValueFromCookie(r *http.Request) *model.Session { + sessionCookie, err := r.Cookie(cookie.CookieSessionID) + if err == http.ErrNoCookie { + return nil + } + + session, err := s.store.Session(sessionCookie.Value) + if err != nil { + logger.Error("[Middleware:Session] %v", err) + return nil + } + + return session +} + +// NewSessionMiddleware returns a new SessionMiddleware. +func NewSessionMiddleware(cfg *config.Config, store *storage.Storage) *SessionMiddleware { + return &SessionMiddleware{cfg, store} +} diff --git a/http/middleware/user_session.go b/http/middleware/user_session.go new file mode 100644 index 0000000..d67445f --- /dev/null +++ b/http/middleware/user_session.go @@ -0,0 +1,78 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package middleware + +import ( + "context" + "net/http" + + "github.com/miniflux/miniflux/http/cookie" + "github.com/miniflux/miniflux/http/route" + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/model" + "github.com/miniflux/miniflux/storage" + + "github.com/gorilla/mux" +) + +// UserSessionMiddleware represents a user session middleware. +type UserSessionMiddleware struct { + store *storage.Storage + router *mux.Router +} + +// Handler execute the middleware. +func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := s.getSessionFromCookie(r) + + if session == nil { + logger.Debug("[Middleware:UserSession] Session not found") + if s.isPublicRoute(r) { + next.ServeHTTP(w, r) + } else { + http.Redirect(w, r, route.Path(s.router, "login"), http.StatusFound) + } + } else { + logger.Debug("[Middleware:UserSession] %s", session) + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDContextKey, session.UserID) + ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true) + ctx = context.WithValue(ctx, UserSessionTokenContextKey, session.Token) + + next.ServeHTTP(w, r.WithContext(ctx)) + } + }) +} + +func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool { + route := mux.CurrentRoute(r) + switch route.GetName() { + case "login", "checkLogin", "stylesheet", "javascript", "oauth2Redirect", "oauth2Callback", "appIcon", "favicon", "webManifest": + return true + default: + return false + } +} + +func (s *UserSessionMiddleware) getSessionFromCookie(r *http.Request) *model.UserSession { + sessionCookie, err := r.Cookie(cookie.CookieUserSessionID) + if err == http.ErrNoCookie { + return nil + } + + session, err := s.store.UserSessionByToken(sessionCookie.Value) + if err != nil { + logger.Error("[Middleware:UserSession] %v", err) + return nil + } + + return session +} + +// NewUserSessionMiddleware returns a new UserSessionMiddleware. +func NewUserSessionMiddleware(s *storage.Storage, r *mux.Router) *UserSessionMiddleware { + return &UserSessionMiddleware{store: s, router: r} +} diff --git a/http/route/route.go b/http/route/route.go new file mode 100644 index 0000000..ee574a5 --- /dev/null +++ b/http/route/route.go @@ -0,0 +1,38 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package route + +import ( + "strconv" + + "github.com/gorilla/mux" + "github.com/miniflux/miniflux/logger" +) + +// Path returns the defined route based on given arguments. +func Path(router *mux.Router, name string, args ...interface{}) string { + route := router.Get(name) + if route == nil { + logger.Fatal("[Route] Route not found: %s", name) + } + + var pairs []string + for _, param := range args { + switch param.(type) { + case string: + pairs = append(pairs, param.(string)) + case int64: + val := param.(int64) + pairs = append(pairs, strconv.FormatInt(val, 10)) + } + } + + result, err := route.URLPath(pairs...) + if err != nil { + logger.Fatal("[Route] %v", err) + } + + return result.String() +} -- cgit v1.2.3