From 00257988ef1707a028cd7dd0b1f8f68e6e8fac53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guillot?= Date: Sat, 16 Dec 2017 18:07:53 -0800 Subject: [PATCH] Session management refactoring --- locale/translations.go | 8 ++- locale/translations/fr_FR.json | 4 +- model/session.go | 56 ++++++++++++++++ model/token.go | 17 ----- server/cookie/cookie.go | 40 ++++++++++++ server/core/context.go | 87 +++++++++++++++++++++---- server/core/handler.go | 2 +- server/middleware/context_keys.go | 34 ++++++++-- server/middleware/session.go | 70 +++++++++++--------- server/middleware/token.go | 81 ----------------------- server/middleware/user_session.go | 78 ++++++++++++++++++++++ server/routes.go | 4 +- server/template/common.go | 10 ++- server/template/html/common/layout.html | 6 ++ server/ui/controller/controller.go | 9 +-- server/ui/controller/login.go | 33 ++-------- server/ui/controller/oauth2.go | 22 ++----- server/ui/controller/session.go | 7 +- server/ui/controller/settings.go | 1 + server/ui/controller/unread.go | 2 +- sql/schema_version_10.sql | 8 +++ sql/sql.go | 29 ++++++--- storage/migration.go | 2 +- storage/session.go | 77 ++++++++++++++++++++++ storage/token.go | 48 -------------- storage/user_session.go | 6 -- 26 files changed, 465 insertions(+), 276 deletions(-) create mode 100644 model/session.go delete mode 100644 model/token.go create mode 100644 server/cookie/cookie.go delete mode 100644 server/middleware/token.go create mode 100644 server/middleware/user_session.go create mode 100644 sql/schema_version_10.sql create mode 100644 storage/session.go delete mode 100644 storage/token.go diff --git a/locale/translations.go b/locale/translations.go index 64fb4905..8f5f4eda 100644 --- a/locale/translations.go +++ b/locale/translations.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-15 18:49:24.054372873 -0800 PST m=+0.026968745 +// 2017-12-16 17:48:32.323083386 -0800 PST m=+0.056720065 package locale @@ -169,12 +169,14 @@ var translations = map[string]string{ "Fever Password": "Mot de passe pour l'API de Fever", "Fetch original content": "Récupérer le contenu original", "Scraper Rules": "Règles pour récupérer le contenu original", - "Rewrite Rules": "Règles de réécriture" + "Rewrite Rules": "Règles de réécriture", + "Preferences saved!": "Préférences sauvegardées !", + "Your external account is now linked !": "Votre compte externe est maintenant associé !" } `, } var translationsChecksums = map[string]string{ "en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897", - "fr_FR": "0e14d65f38ca5c5e34f1d84f6837ce8a29a4ae5f8836b384bb098222b724cb5b", + "fr_FR": "f52a6503ee61d1103adb280c242d438a89936b34d147d29c2502cec8b2cc9ff9", } diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index a7c34f93..5015f2ae 100644 --- a/locale/translations/fr_FR.json +++ b/locale/translations/fr_FR.json @@ -153,5 +153,7 @@ "Fever Password": "Mot de passe pour l'API de Fever", "Fetch original content": "Récupérer le contenu original", "Scraper Rules": "Règles pour récupérer le contenu original", - "Rewrite Rules": "Règles de réécriture" + "Rewrite Rules": "Règles de réécriture", + "Preferences saved!": "Préférences sauvegardées !", + "Your external account is now linked !": "Votre compte externe est maintenant associé !" } diff --git a/model/session.go b/model/session.go new file mode 100644 index 00000000..2a7430d1 --- /dev/null +++ b/model/session.go @@ -0,0 +1,56 @@ +// 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 model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +// SessionData represents the data attached to the session. +type SessionData struct { + CSRF string `json:"csrf"` + OAuth2State string `json:"oauth2_state"` + FlashMessage string `json:"flash_message"` + FlashErrorMessage string `json:"flash_error_message"` +} + +func (s SessionData) String() string { + return fmt.Sprintf(`CSRF="%s", "OAuth2State="%s", FlashMessage="%s", "FlashErrorMessage="%s"`, + s.CSRF, s.OAuth2State, s.FlashMessage, s.FlashErrorMessage) +} + +// Value converts the session data to JSON. +func (s SessionData) Value() (driver.Value, error) { + j, err := json.Marshal(s) + return j, err +} + +// Scan converts raw JSON data. +func (s *SessionData) Scan(src interface{}) error { + source, ok := src.([]byte) + if !ok { + return errors.New("session: unable to assert type of src") + } + + err := json.Unmarshal(source, s) + if err != nil { + return fmt.Errorf("session: %v", err) + } + + return err +} + +// Session represents a session in the system. +type Session struct { + ID string + Data *SessionData +} + +func (s *Session) String() string { + return fmt.Sprintf(`ID="%s", Data="%v"`, s.ID, s.Data) +} diff --git a/model/token.go b/model/token.go deleted file mode 100644 index 3c5c3232..00000000 --- a/model/token.go +++ /dev/null @@ -1,17 +0,0 @@ -// 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 model - -import "fmt" - -// Token represents a CSRF token in the system. -type Token struct { - ID string - Value string -} - -func (t Token) String() string { - return fmt.Sprintf(`ID="%s"`, t.ID) -} diff --git a/server/cookie/cookie.go b/server/cookie/cookie.go new file mode 100644 index 00000000..d028d877 --- /dev/null +++ b/server/cookie/cookie.go @@ -0,0 +1,40 @@ +// 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" +) + +// New create a new cookie. +func New(name, value string, isHTTPS bool) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: value, + Path: "/", + Secure: isHTTPS, + HttpOnly: true, + } +} + +// 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/server/core/context.go b/server/core/context.go index 50496b33..dfd8e5b3 100644 --- a/server/core/context.go +++ b/server/core/context.go @@ -7,6 +7,8 @@ package core import ( "net/http" + "github.com/miniflux/miniflux/helper" + "github.com/miniflux/miniflux/locale" "github.com/miniflux/miniflux/logger" "github.com/miniflux/miniflux/model" "github.com/miniflux/miniflux/server/middleware" @@ -18,11 +20,12 @@ import ( // 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 + 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. @@ -35,10 +38,11 @@ func (c *Context) IsAdminUser() bool { // UserTimezone returns the timezone used by the logged user. func (c *Context) UserTimezone() string { - if v := c.request.Context().Value(middleware.UserTimezoneContextKey); v != nil { - return v.(string) + value := c.getContextStringValue(middleware.UserTimezoneContextKey) + if value == "" { + value = "UTC" } - return "UTC" + return value } // IsAuthenticated returns a boolean if the user is authenticated. @@ -80,13 +84,68 @@ func (c *Context) UserLanguage() string { return user.Language } -// CsrfToken returns the current CSRF token. -func (c *Context) CsrfToken() string { - if v := c.request.Context().Value(middleware.TokenContextKey); v != nil { +// 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 := helper.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.FlashMessageContextKey) + 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("No CSRF token in context!") + logger.Error("[Core:Context] Missing key: %s", key) return "" } @@ -96,6 +155,6 @@ func (c *Context) Route(name string, args ...interface{}) string { } // NewContext creates a new Context. -func NewContext(w http.ResponseWriter, r *http.Request, store *storage.Storage, router *mux.Router) *Context { - return &Context{writer: w, request: r, store: store, router: router} +func NewContext(w http.ResponseWriter, r *http.Request, store *storage.Storage, router *mux.Router, translator *locale.Translator) *Context { + return &Context{writer: w, request: r, store: store, router: router, translator: translator} } diff --git a/server/core/handler.go b/server/core/handler.go index c55cf792..a5d84afa 100644 --- a/server/core/handler.go +++ b/server/core/handler.go @@ -36,7 +36,7 @@ func (h *Handler) Use(f HandlerFunc) http.Handler { defer helper.ExecutionTime(time.Now(), r.URL.Path) logger.Debug("[HTTP] %s %s", r.Method, r.URL.Path) - ctx := NewContext(w, r, h.store, h.router) + ctx := NewContext(w, r, h.store, h.router, h.translator) request := NewRequest(w, r) response := NewResponse(w, r, h.template) diff --git a/server/middleware/context_keys.go b/server/middleware/context_keys.go index 3099322a..31ad286e 100644 --- a/server/middleware/context_keys.go +++ b/server/middleware/context_keys.go @@ -4,23 +4,43 @@ package middleware -type contextKey struct { +// 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"} + UserIDContextKey = &ContextKey{"UserID"} // UserTimezoneContextKey is the context key used to store the user timezone. - UserTimezoneContextKey = &contextKey{"UserTimezone"} + UserTimezoneContextKey = &ContextKey{"UserTimezone"} // IsAdminUserContextKey is the context key used to store the user role. - IsAdminUserContextKey = &contextKey{"IsAdminUser"} + IsAdminUserContextKey = &ContextKey{"IsAdminUser"} // IsAuthenticatedContextKey is the context key used to store the authentication flag. - IsAuthenticatedContextKey = &contextKey{"IsAuthenticated"} + IsAuthenticatedContextKey = &ContextKey{"IsAuthenticated"} - // TokenContextKey is the context key used to store CSRF token. - TokenContextKey = &contextKey{"CSRF"} + // 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/server/middleware/session.go b/server/middleware/session.go index 3759565f..2891b68a 100644 --- a/server/middleware/session.go +++ b/server/middleware/session.go @@ -10,60 +10,66 @@ import ( "github.com/miniflux/miniflux/logger" "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/route" + "github.com/miniflux/miniflux/server/cookie" "github.com/miniflux/miniflux/storage" - - "github.com/gorilla/mux" ) // SessionMiddleware represents a session middleware. type SessionMiddleware struct { - store *storage.Storage - router *mux.Router + store *storage.Storage } // Handler execute the middleware. -func (s *SessionMiddleware) Handler(next http.Handler) http.Handler { +func (t *SessionMiddleware) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := s.getSessionFromCookie(r) + var err error + session := t.getSessionValueFromCookie(r) if session == nil { logger.Debug("[Middleware:Session] Session not found") - if s.isPublicRoute(r) { - next.ServeHTTP(w, r) - } else { - http.Redirect(w, r, route.Path(s.router, "login"), http.StatusFound) + session, err = t.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, r.URL.Scheme == "https")) } else { logger.Debug("[Middleware:Session] %s", session) - ctx := r.Context() - ctx = context.WithValue(ctx, UserIDContextKey, session.UserID) - ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true) - - next.ServeHTTP(w, r.WithContext(ctx)) } + + 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) isPublicRoute(r *http.Request) bool { - route := mux.CurrentRoute(r) - switch route.GetName() { - case "login", "checkLogin", "stylesheet", "javascript", "oauth2Redirect", "oauth2Callback", "appIcon", "favicon": - return true - default: - return false - } -} - -func (s *SessionMiddleware) getSessionFromCookie(r *http.Request) *model.UserSession { - sessionCookie, err := r.Cookie("sessionID") +func (t *SessionMiddleware) getSessionValueFromCookie(r *http.Request) *model.Session { + sessionCookie, err := r.Cookie(cookie.CookieSessionID) if err == http.ErrNoCookie { return nil } - session, err := s.store.UserSessionByToken(sessionCookie.Value) + session, err := t.store.Session(sessionCookie.Value) if err != nil { - logger.Error("[SessionMiddleware] %v", err) + logger.Error("[Middleware:Session] %v", err) return nil } @@ -71,6 +77,6 @@ func (s *SessionMiddleware) getSessionFromCookie(r *http.Request) *model.UserSes } // NewSessionMiddleware returns a new SessionMiddleware. -func NewSessionMiddleware(s *storage.Storage, r *mux.Router) *SessionMiddleware { - return &SessionMiddleware{store: s, router: r} +func NewSessionMiddleware(s *storage.Storage) *SessionMiddleware { + return &SessionMiddleware{store: s} } diff --git a/server/middleware/token.go b/server/middleware/token.go deleted file mode 100644 index e1666f7f..00000000 --- a/server/middleware/token.go +++ /dev/null @@ -1,81 +0,0 @@ -// 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/model" - "github.com/miniflux/miniflux/storage" -) - -// TokenMiddleware represents a token middleware. -type TokenMiddleware struct { - store *storage.Storage -} - -// Handler execute the middleware. -func (t *TokenMiddleware) Handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var err error - token := t.getTokenValueFromCookie(r) - - if token == nil { - logger.Debug("[Middleware:Token] Token not found") - token, err = t.store.CreateToken() - if err != nil { - logger.Error("[Middleware:Token] %v", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - cookie := &http.Cookie{ - Name: "tokenID", - Value: token.ID, - Path: "/", - Secure: r.URL.Scheme == "https", - HttpOnly: true, - } - - http.SetCookie(w, cookie) - } else { - logger.Info("[Middleware:Token] %s", token) - } - - isTokenValid := token.Value == r.FormValue("csrf") || token.Value == r.Header.Get("X-Csrf-Token") - - if r.Method == "POST" && !isTokenValid { - logger.Error("[Middleware:CSRF] Invalid or missing CSRF token!") - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Invalid or missing CSRF token!")) - } else { - ctx := r.Context() - ctx = context.WithValue(ctx, TokenContextKey, token.Value) - next.ServeHTTP(w, r.WithContext(ctx)) - } - }) -} - -func (t *TokenMiddleware) getTokenValueFromCookie(r *http.Request) *model.Token { - tokenCookie, err := r.Cookie("tokenID") - if err == http.ErrNoCookie { - return nil - } - - token, err := t.store.Token(tokenCookie.Value) - if err != nil { - logger.Error("[Middleware:Token] %v", err) - return nil - } - - return token -} - -// NewTokenMiddleware returns a new TokenMiddleware. -func NewTokenMiddleware(s *storage.Storage) *TokenMiddleware { - return &TokenMiddleware{store: s} -} diff --git a/server/middleware/user_session.go b/server/middleware/user_session.go new file mode 100644 index 00000000..e9bad82d --- /dev/null +++ b/server/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/logger" + "github.com/miniflux/miniflux/model" + "github.com/miniflux/miniflux/server/cookie" + "github.com/miniflux/miniflux/server/route" + "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": + 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/server/routes.go b/server/routes.go index d564925b..8aa849ef 100644 --- a/server/routes.go +++ b/server/routes.go @@ -42,8 +42,8 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han )) uiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain( - middleware.NewSessionMiddleware(store, router).Handler, - middleware.NewTokenMiddleware(store).Handler, + middleware.NewUserSessionMiddleware(store, router).Handler, + middleware.NewSessionMiddleware(store).Handler, )) router.Handle("/fever/", feverHandler.Use(feverController.Handler)) diff --git a/server/template/common.go b/server/template/common.go index 555db168..6e7b8b9a 100644 --- a/server/template/common.go +++ b/server/template/common.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-15 21:24:38.377969493 -0800 PST m=+0.007061903 +// 2017-12-16 17:48:32.321995978 -0800 PST m=+0.055632657 package template @@ -88,6 +88,12 @@ var templateCommonMap = map[string]string{ {{ end }} + {{ if .flashMessage }} +
{{ .flashMessage }}
+ {{ end }} + {{ if .flashErrorMessage }} +
{{ .flashErrorMessage }}
+ {{ end }}
{{template "content" .}}
@@ -118,6 +124,6 @@ var templateCommonMap = map[string]string{ var templateCommonMapChecksums = map[string]string{ "entry_pagination": "f1465fa70f585ae8043b200ec9de5bf437ffbb0c19fb7aefc015c3555614ee27", - "layout": "100d1ffff506b9cdd4c28233ff883c323452ea01fa224ff891d4ad69997b62b1", + "layout": "ff5e3d87a48e4d3aeceda4aabe6c2c2f607006c6b6e83dfcab6c5eb255a1e6f2", "pagination": "6ff462c2b2a53bc5448b651da017f40a39f1d4f16cef4b2f09784f0797286924", } diff --git a/server/template/html/common/layout.html b/server/template/html/common/layout.html index 1675c6c9..bd7836d8 100644 --- a/server/template/html/common/layout.html +++ b/server/template/html/common/layout.html @@ -63,6 +63,12 @@ {{ end }} + {{ if .flashMessage }} +
{{ .flashMessage }}
+ {{ end }} + {{ if .flashErrorMessage }} +
{{ .flashErrorMessage }}
+ {{ end }}
{{template "content" .}}
diff --git a/server/ui/controller/controller.go b/server/ui/controller/controller.go index cfecf42d..8f1f4154 100644 --- a/server/ui/controller/controller.go +++ b/server/ui/controller/controller.go @@ -44,10 +44,11 @@ func (c *Controller) getCommonTemplateArgs(ctx *core.Context) (tplParams, error) } params := tplParams{ - "menu": "", - "user": user, - "countUnread": countUnread, - "csrf": ctx.CsrfToken(), + "menu": "", + "user": user, + "countUnread": countUnread, + "csrf": ctx.CSRF(), + "flashMessage": ctx.FlashMessage(), } return params, nil } diff --git a/server/ui/controller/login.go b/server/ui/controller/login.go index d130f4c8..87b8a4ed 100644 --- a/server/ui/controller/login.go +++ b/server/ui/controller/login.go @@ -5,10 +5,8 @@ package controller import ( - "net/http" - "time" - "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/server/cookie" "github.com/miniflux/miniflux/server/core" "github.com/miniflux/miniflux/server/ui/form" @@ -23,7 +21,7 @@ func (c *Controller) ShowLoginPage(ctx *core.Context, request *core.Request, res } response.HTML().Render("login", tplParams{ - "csrf": ctx.CsrfToken(), + "csrf": ctx.CSRF(), }) } @@ -32,7 +30,7 @@ func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, respon authForm := form.NewAuthForm(request.Request()) tplParams := tplParams{ "errorMessage": "Invalid username or password.", - "csrf": ctx.CsrfToken(), + "csrf": ctx.CSRF(), } if err := authForm.Validate(); err != nil { @@ -60,15 +58,7 @@ func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, respon logger.Info("[Controller:CheckLogin] username=%s just logged in", authForm.Username) - cookie := &http.Cookie{ - Name: "sessionID", - Value: sessionToken, - Path: "/", - Secure: request.IsHTTPS(), - HttpOnly: true, - } - - response.SetCookie(cookie) + response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, request.IsHTTPS())) response.Redirect(ctx.Route("unread")) } @@ -76,21 +66,10 @@ func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, respon func (c *Controller) Logout(ctx *core.Context, request *core.Request, response *core.Response) { user := ctx.LoggedUser() - sessionCookie := request.Cookie("sessionID") - if err := c.store.RemoveUserSessionByToken(user.ID, sessionCookie); err != nil { + if err := c.store.RemoveUserSessionByToken(user.ID, ctx.UserSessionToken()); err != nil { logger.Error("[Controller:Logout] %v", err) } - cookie := &http.Cookie{ - Name: "sessionID", - Value: "", - Path: "/", - Secure: request.IsHTTPS(), - HttpOnly: true, - MaxAge: -1, - Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), - } - - response.SetCookie(cookie) + response.SetCookie(cookie.Expired(cookie.CookieUserSessionID, request.IsHTTPS())) response.Redirect(ctx.Route("login")) } diff --git a/server/ui/controller/oauth2.go b/server/ui/controller/oauth2.go index 56ed53c5..5011b1a6 100644 --- a/server/ui/controller/oauth2.go +++ b/server/ui/controller/oauth2.go @@ -5,11 +5,10 @@ package controller import ( - "net/http" - "github.com/miniflux/miniflux/config" "github.com/miniflux/miniflux/logger" "github.com/miniflux/miniflux/model" + "github.com/miniflux/miniflux/server/cookie" "github.com/miniflux/miniflux/server/core" "github.com/miniflux/miniflux/server/oauth2" "github.com/tomasen/realip" @@ -19,7 +18,7 @@ import ( func (c *Controller) OAuth2Redirect(ctx *core.Context, request *core.Request, response *core.Response) { provider := request.StringParam("provider", "") if provider == "" { - logger.Error("[OAuth2] Invalid or missing provider") + logger.Error("[OAuth2] Invalid or missing provider: %s", provider) response.Redirect(ctx.Route("login")) return } @@ -31,7 +30,7 @@ func (c *Controller) OAuth2Redirect(ctx *core.Context, request *core.Request, re return } - response.Redirect(authProvider.GetRedirectURL(ctx.CsrfToken())) + response.Redirect(authProvider.GetRedirectURL(ctx.GenerateOAuth2State())) } // OAuth2Callback receives the authorization code and create a new session. @@ -51,8 +50,8 @@ func (c *Controller) OAuth2Callback(ctx *core.Context, request *core.Request, re } state := request.QueryStringParam("state", "") - if state != ctx.CsrfToken() { - logger.Error("[OAuth2] Invalid state value") + if state == "" || state != ctx.OAuth2State() { + logger.Error(`[OAuth2] Invalid state value: got "%s" instead of "%s"`, state, ctx.OAuth2State()) response.Redirect(ctx.Route("login")) return } @@ -78,6 +77,7 @@ func (c *Controller) OAuth2Callback(ctx *core.Context, request *core.Request, re return } + ctx.SetFlashMessage(ctx.Translate("Your external account is now linked !")) response.Redirect(ctx.Route("settings")) return } @@ -118,15 +118,7 @@ func (c *Controller) OAuth2Callback(ctx *core.Context, request *core.Request, re logger.Info("[Controller:OAuth2Callback] username=%s just logged in", user.Username) - cookie := &http.Cookie{ - Name: "sessionID", - Value: sessionToken, - Path: "/", - Secure: request.IsHTTPS(), - HttpOnly: true, - } - - response.SetCookie(cookie) + response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, request.IsHTTPS())) response.Redirect(ctx.Route("unread")) } diff --git a/server/ui/controller/session.go b/server/ui/controller/session.go index a020b165..05cb29ed 100644 --- a/server/ui/controller/session.go +++ b/server/ui/controller/session.go @@ -9,7 +9,7 @@ import ( "github.com/miniflux/miniflux/server/core" ) -// ShowSessions shows the list of active sessions. +// ShowSessions shows the list of active user sessions. func (c *Controller) ShowSessions(ctx *core.Context, request *core.Request, response *core.Response) { user := ctx.LoggedUser() args, err := c.getCommonTemplateArgs(ctx) @@ -24,15 +24,14 @@ func (c *Controller) ShowSessions(ctx *core.Context, request *core.Request, resp return } - sessionCookie := request.Cookie("sessionID") response.HTML().Render("sessions", args.Merge(tplParams{ "sessions": sessions, - "currentSessionToken": sessionCookie, + "currentSessionToken": ctx.UserSessionToken(), "menu": "settings", })) } -// RemoveSession remove a session. +// RemoveSession remove a user session. func (c *Controller) RemoveSession(ctx *core.Context, request *core.Request, response *core.Response) { user := ctx.LoggedUser() diff --git a/server/ui/controller/settings.go b/server/ui/controller/settings.go index af7558a2..feba8936 100644 --- a/server/ui/controller/settings.go +++ b/server/ui/controller/settings.go @@ -62,6 +62,7 @@ func (c *Controller) UpdateSettings(ctx *core.Context, request *core.Request, re return } + ctx.SetFlashMessage(ctx.Translate("Preferences saved!")) response.Redirect(ctx.Route("settings")) } diff --git a/server/ui/controller/unread.go b/server/ui/controller/unread.go index 87faafc1..8cf8a38d 100644 --- a/server/ui/controller/unread.go +++ b/server/ui/controller/unread.go @@ -44,6 +44,6 @@ func (c *Controller) ShowUnreadPage(ctx *core.Context, request *core.Request, re "entries": entries, "pagination": c.getPagination(ctx.Route("unread"), countUnread, offset), "menu": "unread", - "csrf": ctx.CsrfToken(), + "csrf": ctx.CSRF(), }) } diff --git a/sql/schema_version_10.sql b/sql/schema_version_10.sql new file mode 100644 index 00000000..abd26d87 --- /dev/null +++ b/sql/schema_version_10.sql @@ -0,0 +1,8 @@ +drop table tokens; + +create table sessions ( + id text not null, + data jsonb not null, + created_at timestamp with time zone not null default now(), + primary key(id) +); \ No newline at end of file diff --git a/sql/sql.go b/sql/sql.go index a7974e21..b0262438 100644 --- a/sql/sql.go +++ b/sql/sql.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-16 12:08:03.005451004 -0800 PST m=+0.002264796 +// 2017-12-16 17:48:32.268871258 -0800 PST m=+0.002507937 package sql @@ -108,6 +108,14 @@ create table feed_icons ( foreign key (icon_id) references icons(id) on delete cascade ); `, + "schema_version_10": `drop table tokens; + +create table sessions ( + id text not null, + data jsonb not null, + created_at timestamp with time zone not null default now(), + primary key(id) +);`, "schema_version_2": `create extension if not exists hstore; alter table users add column extra hstore; create index users_extra_idx on users using gin(extra); @@ -147,13 +155,14 @@ alter table users add column entry_direction entry_sorting_direction default 'as } var SqlMapChecksums = map[string]string{ - "schema_version_1": "7be580fc8a93db5da54b2f6e87019803c33b0b0c28482c7af80cef873bdac4e2", - "schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4", - "schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12", - "schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9", - "schema_version_5": "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c", - "schema_version_6": "9d05b4fb223f0e60efc716add5048b0ca9c37511cf2041721e20505d6d798ce4", - "schema_version_7": "33f298c9aa30d6de3ca28e1270df51c2884d7596f1283a75716e2aeb634cd05c", - "schema_version_8": "9922073fc4032d8922617ec6a6a07ae8d4817846c138760fb96cb5608ab83bfc", - "schema_version_9": "de5ba954752fe808a993feef5bf0c6f808e0a4ced5379de8bec8342678150892", + "schema_version_1": "7be580fc8a93db5da54b2f6e87019803c33b0b0c28482c7af80cef873bdac4e2", + "schema_version_10": "8faf15ddeff7c8cc305e66218face11ed92b97df2bdc2d0d7944d61441656795", + "schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4", + "schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12", + "schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9", + "schema_version_5": "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c", + "schema_version_6": "9d05b4fb223f0e60efc716add5048b0ca9c37511cf2041721e20505d6d798ce4", + "schema_version_7": "33f298c9aa30d6de3ca28e1270df51c2884d7596f1283a75716e2aeb634cd05c", + "schema_version_8": "9922073fc4032d8922617ec6a6a07ae8d4817846c138760fb96cb5608ab83bfc", + "schema_version_9": "de5ba954752fe808a993feef5bf0c6f808e0a4ced5379de8bec8342678150892", } diff --git a/storage/migration.go b/storage/migration.go index 368c567c..d29c76db 100644 --- a/storage/migration.go +++ b/storage/migration.go @@ -12,7 +12,7 @@ import ( "github.com/miniflux/miniflux/sql" ) -const schemaVersion = 9 +const schemaVersion = 10 // Migrate run database migrations. func (s *Storage) Migrate() { diff --git a/storage/session.go b/storage/session.go new file mode 100644 index 00000000..17a63e2b --- /dev/null +++ b/storage/session.go @@ -0,0 +1,77 @@ +// 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 storage + +import ( + "database/sql" + "fmt" + + "github.com/miniflux/miniflux/helper" + "github.com/miniflux/miniflux/model" +) + +// CreateSession creates a new session. +func (s *Storage) CreateSession() (*model.Session, error) { + session := model.Session{ + ID: helper.GenerateRandomString(32), + Data: &model.SessionData{CSRF: helper.GenerateRandomString(64)}, + } + + query := "INSERT INTO sessions (id, data) VALUES ($1, $2)" + _, err := s.db.Exec(query, session.ID, session.Data) + if err != nil { + return nil, fmt.Errorf("unable to create session: %v", err) + } + + return &session, nil +} + +// UpdateSessionField updates only one session field. +func (s *Storage) UpdateSessionField(sessionID, field string, value interface{}) error { + query := `UPDATE sessions + SET data = jsonb_set(data, '{%s}', to_jsonb($1::text), true) + WHERE id=$2` + + _, err := s.db.Exec(fmt.Sprintf(query, field), value, sessionID) + if err != nil { + return fmt.Errorf("unable to update session field: %v", err) + } + + return nil +} + +// Session returns the given session. +func (s *Storage) Session(id string) (*model.Session, error) { + var session model.Session + + query := "SELECT id, data FROM sessions WHERE id=$1" + err := s.db.QueryRow(query, id).Scan( + &session.ID, + &session.Data, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("session not found: %s", id) + } else if err != nil { + return nil, fmt.Errorf("unable to fetch session: %v", err) + } + + return &session, nil +} + +// FlushAllSessions removes all sessions from the database. +func (s *Storage) FlushAllSessions() (err error) { + _, err = s.db.Exec(`DELETE FROM user_sessions`) + if err != nil { + return err + } + + _, err = s.db.Exec(`DELETE FROM sessions`) + if err != nil { + return err + } + + return nil +} diff --git a/storage/token.go b/storage/token.go deleted file mode 100644 index c5a614ab..00000000 --- a/storage/token.go +++ /dev/null @@ -1,48 +0,0 @@ -// 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 storage - -import ( - "database/sql" - "fmt" - - "github.com/miniflux/miniflux/helper" - "github.com/miniflux/miniflux/model" -) - -// CreateToken creates a new token. -func (s *Storage) CreateToken() (*model.Token, error) { - token := model.Token{ - ID: helper.GenerateRandomString(32), - Value: helper.GenerateRandomString(64), - } - - query := "INSERT INTO tokens (id, value) VALUES ($1, $2)" - _, err := s.db.Exec(query, token.ID, token.Value) - if err != nil { - return nil, fmt.Errorf("unable to create token: %v", err) - } - - return &token, nil -} - -// Token returns a Token. -func (s *Storage) Token(id string) (*model.Token, error) { - var token model.Token - - query := "SELECT id, value FROM tokens WHERE id=$1" - err := s.db.QueryRow(query, id).Scan( - &token.ID, - &token.Value, - ) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("token not found: %s", id) - } else if err != nil { - return nil, fmt.Errorf("unable to fetch token: %v", err) - } - - return &token, nil -} diff --git a/storage/user_session.go b/storage/user_session.go index da9cebad..ffb82fcd 100644 --- a/storage/user_session.go +++ b/storage/user_session.go @@ -127,9 +127,3 @@ func (s *Storage) RemoveUserSessionByID(userID, sessionID int64) error { return nil } - -// FlushAllSessions removes all user sessions from the database. -func (s *Storage) FlushAllSessions() (err error) { - _, err = s.db.Exec(`DELETE FROM user_sessions`) - return -}