Do not save empty sessions if possible.

This commit is contained in:
Felipe Lessa 2015-05-28 16:39:13 -03:00
parent 3757b4dad9
commit b12b3e7cd1
5 changed files with 113 additions and 25 deletions

View File

@ -24,6 +24,7 @@ import qualified Crypto.Nonce as N
import qualified Data.ByteString.Char8 as B8 import qualified Data.ByteString.Char8 as B8
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import qualified Data.Time as TI
import qualified Data.Map as M import qualified Data.Map as M
import qualified Snap.Core as S import qualified Snap.Core as S
import qualified Snap.Snaplet as S import qualified Snap.Snaplet as S
@ -88,8 +89,12 @@ instance Storage s => S.ISessionManager (ServerSessionManager s) where
commit ssm = do commit ssm = do
-- Save session data to storage backend and set the cookie. -- Save session data to storage backend and set the cookie.
let Just (sessionMap, saveSessionToken) = currentSession ssm let Just (sessionMap, saveSessionToken) = currentSession ssm
session <- liftIO $ saveSession (state ssm) saveSessionToken sessionMap msession <- liftIO $ saveSession (state ssm) saveSessionToken sessionMap
S.modifyResponse $ S.addResponseCookie $ createCookie (state ssm) (cookieName ssm) session S.modifyResponse $ S.addResponseCookie $
maybe
(deleteCookie (state ssm) (cookieName ssm))
(createCookie (state ssm) (cookieName ssm))
msession
reset ssm = do reset ssm = do
-- Reset has no defined semantics. We invalidate the session -- Reset has no defined semantics. We invalidate the session
@ -162,6 +167,29 @@ createCookie st cookieNameBS session =
} }
-- | Remove the session cookie from the client. This is used
-- when 'saveSession' returns @Nothing@:
--
-- * If the user didn't have a session cookie, this cookie
-- deletion will be harmless.
--
-- * If the user had a session cookie that was invalidated,
-- this will remove the invalid cookie from the client.
-- the server-side as well.
deleteCookie :: State s -> ByteString -> S.Cookie
deleteCookie st cookieNameBS =
S.Cookie
{ S.cookieName = cookieNameBS
, S.cookieValue = ""
, S.cookiePath = Just "/"
, S.cookieExpires = Just aLongTimeAgo
, S.cookieDomain = Nothing
, S.cookieHttpOnly = getHttpOnlyCookies st
, S.cookieSecure = getSecureCookies st
}
where aLongTimeAgo = read "1970-01-01 00:00:01 UTC" :: TI.UTCTime
-- | The CSRF key is kept as a session variable like any other -- | The CSRF key is kept as a session variable like any other
-- under this key. -- under this key.
csrfKey :: Text csrfKey :: Text

View File

@ -51,6 +51,10 @@ withServerSession key opts storage = liftIO $ do
-- | Construct the @wai-session@ session store using the given -- | Construct the @wai-session@ session store using the given
-- state. Note that keys and values types are fixed. -- state. Note that keys and values types are fixed.
--
-- As @wai-session@ always requires a value to be provided, we
-- return an empty @ByteString@ when the empty session was not
-- saved.
sessionStore sessionStore
:: (MonadIO m, Storage s) :: (MonadIO m, Storage s)
=> State s -- ^ @serversession@ state, incl. storage backend. => State s -- ^ @serversession@ state, incl. storage backend.
@ -61,8 +65,8 @@ sessionStore state =
sessionRef <- I.newIORef sessionMap sessionRef <- I.newIORef sessionMap
let save = do let save = do
sessionMap' <- I.atomicModifyIORef' sessionRef $ \a -> (a, a) sessionMap' <- I.atomicModifyIORef' sessionRef $ \a -> (a, a)
session <- saveSession state saveSessionToken sessionMap' msession <- saveSession state saveSessionToken sessionMap'
return $ TE.encodeUtf8 $ toPathPiece $ sessionKey session return $ maybe "" (TE.encodeUtf8 . toPathPiece . sessionKey) msession
return (mkSession sessionRef, save) return (mkSession sessionRef, save)

View File

@ -20,6 +20,7 @@ import Yesod.Core.Types (Header(AddCookie), SessionBackend(..))
import qualified Data.ByteString.Char8 as B8 import qualified Data.ByteString.Char8 as B8
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import qualified Data.Time as TI
import qualified Network.Wai as W import qualified Network.Wai as W
import qualified Web.Cookie as C import qualified Web.Cookie as C
@ -73,7 +74,8 @@ backend state =
let rawSessionId = findSessionId cookieNameBS req let rawSessionId = findSessionId cookieNameBS req
(sessionMap, saveSessionToken) <- loadSession state rawSessionId (sessionMap, saveSessionToken) <- loadSession state rawSessionId
let save = let save =
fmap ((:[]) . createCookie state cookieNameBS) . fmap ((:[]) . maybe (deleteCookie state cookieNameBS)
(createCookie state cookieNameBS)) .
saveSession state saveSessionToken saveSession state saveSessionToken
return (sessionMap, save) return (sessionMap, save)
} }
@ -100,6 +102,29 @@ createCookie state cookieNameBS session =
} }
-- | Remove the session cookie from the client. This is used
-- when 'saveSession' returns @Nothing@:
--
-- * If the user didn't have a session cookie, this cookie
-- deletion will be harmless.
--
-- * If the user had a session cookie that was invalidated,
-- this will remove the invalid cookie from the client.
deleteCookie :: State s -> ByteString -> Header
deleteCookie state cookieNameBS =
AddCookie def
{ C.setCookieName = cookieNameBS
, C.setCookieValue = ""
, C.setCookiePath = Just "/"
, C.setCookieExpires = Just aLongTimeAgo
, C.setCookieMaxAge = Just 0
, C.setCookieDomain = Nothing
, C.setCookieHttpOnly = getHttpOnlyCookies state
, C.setCookieSecure = getSecureCookies state
}
where aLongTimeAgo = read "1970-01-01 00:00:01 UTC" :: TI.UTCTime
-- | Fetch the 'SessionId' from the cookie with the given name. -- | Fetch the 'SessionId' from the cookie with the given name.
-- Returns @Nothing@ if: -- Returns @Nothing@ if:
-- --

View File

@ -456,7 +456,14 @@ data SaveSessionToken =
-- | Save the session on the storage backend. A -- | Save the session on the storage backend. A
-- 'SaveSessionToken' given by 'loadSession' is expected besides -- 'SaveSessionToken' given by 'loadSession' is expected besides
-- the new contents of the session. -- the new contents of the session.
saveSession :: Storage s => State s -> SaveSessionToken -> SessionMap -> IO Session --
-- Returns @Nothing@ if the session was empty and didn't need to
-- be saved. Note that this does /not/ necessarily means that
-- nothing was done. If you ask for a session to be invalidated
-- and clear every other sesssion variable, then 'saveSession'
-- will invalidate the older session but will avoid creating a
-- new, empty one.
saveSession :: Storage s => State s -> SaveSessionToken -> SessionMap -> IO (Maybe Session)
saveSession state (SaveSessionToken maybeInput now) wholeOutputSessionMap = saveSession state (SaveSessionToken maybeInput now) wholeOutputSessionMap =
runTransactionM (storage state) $ do runTransactionM (storage state) $ do
let decomposedSessionMap = decomposeSession state wholeOutputSessionMap let decomposedSessionMap = decomposeSession state wholeOutputSessionMap
@ -513,14 +520,19 @@ decomposeSession state sm1 =
-- | Save a session on the database. If an old session is -- | Save a session on the database. If an old session is
-- supplied, it is replaced, otherwise a new session is -- supplied, it is replaced, otherwise a new session is
-- generated. -- generated. If the session is empty, it is not saved and
-- @Nothing@ is returned.
saveSessionOnDb saveSessionOnDb
:: Storage s :: Storage s
=> State s => State s
-> UTCTime -- ^ Now. -> UTCTime -- ^ Now.
-> Maybe Session -- ^ The old session, if any. -> Maybe Session -- ^ The old session, if any.
-> DecomposedSession -- ^ The session data to be saved. -> DecomposedSession -- ^ The session data to be saved.
-> TransactionM s Session -- ^ Copy of saved session. -> TransactionM s (Maybe Session) -- ^ Copy of saved session.
saveSessionOnDb _ _ Nothing (DecomposedSession Nothing _ m)
-- Return Nothing without doing anything whenever the session
-- is empty (including auth ID) and there was no prior session.
| M.null m = return Nothing
saveSessionOnDb state now maybeInput DecomposedSession {..} = do saveSessionOnDb state now maybeInput DecomposedSession {..} = do
-- Generate properties if needed or take them from previous -- Generate properties if needed or take them from previous
-- saved session. -- saved session.
@ -543,7 +555,7 @@ saveSessionOnDb state now maybeInput DecomposedSession {..} = do
, sessionAccessedAt = now , sessionAccessedAt = now
} }
saveToDb session saveToDb session
return session return (Just session)
-- | Create a 'SessionMap' from a 'Session'. -- | Create a 'SessionMap' from a 'Session'.

View File

@ -216,13 +216,25 @@ main = hspec $ parallel $ do
it "should have more tests" pending it "should have more tests" pending
describe "saveSessionOnDb" $ do describe "saveSessionOnDb" $ do
let prepareSaveSessionOnDb = do
let oldSession = Session
{ sessionKey = S "123456789-123456789-1234"
, sessionAuthId = Just "auth"
, sessionData = M.fromList [("a", "b"), ("c", "d")]
, sessionCreatedAt = TI.addUTCTime (-10) fakenow
, sessionAccessedAt = TI.addUTCTime (-5) fakenow }
sto <- prepareMockStorage [oldSession]
st <- createState sto
return (oldSession, sto, st)
emptyDecomp = DecomposedSession Nothing DoNotForceInvalidate M.empty
it "inserts new sessions when there wasn't an old one" $ do it "inserts new sessions when there wasn't an old one" $ do
sto <- emptyMockStorage sto <- emptyMockStorage
st <- createState sto st <- createState sto
let d = DecomposedSession a DoNotForceInvalidate m let d = DecomposedSession a DoNotForceInvalidate m
m = M.fromList [("a", "b"), ("c", "d")] m = M.fromList [("a", "b"), ("c", "d")]
a = Just "auth" a = Just "auth"
session <- saveSessionOnDb st fakenow Nothing d Just session <- saveSessionOnDb st fakenow Nothing d
getMockOperations sto `shouldReturn` [InsertSession session] getMockOperations sto `shouldReturn` [InsertSession session]
sessionAuthId session `shouldBe` a sessionAuthId session `shouldBe` a
sessionData session `shouldBe` m sessionData session `shouldBe` m
@ -230,25 +242,29 @@ main = hspec $ parallel $ do
sessionAccessedAt session `shouldBe` fakenow sessionAccessedAt session `shouldBe` fakenow
it "replaces sesssions when there was an old one" $ do it "replaces sesssions when there was an old one" $ do
let oldSession = Session (oldSession, sto, st) <- prepareSaveSessionOnDb
{ sessionKey = S "123456789-123456789-1234"
, sessionAuthId = Just "auth"
, sessionData = M.fromList [("a", "b"), ("c", "d")]
, sessionCreatedAt = TI.addUTCTime (-10) fakenow
, sessionAccessedAt = TI.addUTCTime (-5) fakenow }
sto <- prepareMockStorage [oldSession]
st <- createState sto
let d = DecomposedSession Nothing DoNotForceInvalidate m let d = DecomposedSession Nothing DoNotForceInvalidate m
m = M.fromList [("a", "b"), ("x", "y")] m = M.fromList [("a", "b"), ("x", "y")]
session <- saveSessionOnDb st fakenow (Just oldSession) d Just session <- saveSessionOnDb st fakenow (Just oldSession) d
getMockOperations sto `shouldReturn` [ReplaceSession session] getMockOperations sto `shouldReturn` [ReplaceSession session]
session `shouldBe` oldSession session `shouldBe` oldSession
{ sessionData = m { sessionData = m
, sessionAuthId = Nothing , sessionAuthId = Nothing
, sessionAccessedAt = fakenow } , sessionAccessedAt = fakenow }
it "does not save session if it's empty and there wasn't an old one" $ it "does not save session if it's empty and there wasn't an old one" $ do
pendingWith "wishlist" sto <- emptyMockStorage
st <- createState sto
saveSessionOnDb st fakenow Nothing emptyDecomp `shouldReturn` Nothing
getMockOperations sto `shouldReturn` []
it "saves session if it's empty but there was an old one" $ do
(oldSession, sto, st) <- prepareSaveSessionOnDb
let newSession = oldSession { sessionData = M.empty
, sessionAuthId = Nothing
, sessionAccessedAt = fakenow }
saveSessionOnDb st fakenow (Just oldSession) emptyDecomp `shouldReturn` Just newSession
getMockOperations sto `shouldReturn` [ReplaceSession newSession]
it "does not save session if only difference was accessedAt, and it was less than threshold" $ it "does not save session if only difference was accessedAt, and it was less than threshold" $
pendingWith "wishlist" pendingWith "wishlist"
@ -268,7 +284,10 @@ main = hspec $ parallel $ do
test v = dsForceInvalidate (decomposeSession stnull $ sessionMap v) Q.=== v test v = dsForceInvalidate (decomposeSession stnull $ sessionMap v) Q.=== v
in Q.conjoin (test <$> allForces) in Q.conjoin (test <$> allForces)
it "should have more tests" pending it "removes the auth key" $ do
let m = M.singleton "a" "b"; m' = M.insert (authKey stnull) "x" m
decomposeSession stnull m' `shouldBe`
DecomposedSession (Just "x") DoNotForceInvalidate m
describe "toSessionMap" $ do describe "toSessionMap" $ do
let mkSession authId data_ = Session let mkSession authId data_ = Session