Code from branch that lived on yesod's repo.
This commit is contained in:
commit
916de034ad
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
*~
|
||||
*.o
|
||||
*.o_p
|
||||
*.hi
|
||||
dist
|
||||
*.swp
|
||||
cabal-dev/
|
||||
.hsenv/
|
||||
.cabal-sandbox/
|
||||
cabal.sandbox.config
|
||||
/vendor/
|
||||
.shelly/
|
||||
tarballs/
|
||||
20
yesod-persistent-session/LICENSE
Normal file
20
yesod-persistent-session/LICENSE
Normal file
@ -0,0 +1,20 @@
|
||||
Copyright (c) 2015 Felipe Lessa
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
131
yesod-persistent-session/README.md
Normal file
131
yesod-persistent-session/README.md
Normal file
@ -0,0 +1,131 @@
|
||||
# yesod-persistent-session
|
||||
|
||||
Server-side session backend using persistent.
|
||||
|
||||
This package implement traditional server-side sessions. Users
|
||||
who don't have a session yet are assigned a random 144-bit
|
||||
session ID that is the key on a database table kept by
|
||||
persistent. All session data is saved on the database.
|
||||
|
||||
The session ID stays fixed most of the time. Anonymous users
|
||||
receive session IDs unless their session remains empty (as an
|
||||
optimization). The session ID can be invalidated in order to
|
||||
prevent
|
||||
[session fixation attacks](http://www.acrossecurity.com/papers/session_fixation.pdf),
|
||||
either automatically (see below) or manually (via
|
||||
`forceInvalidate`).
|
||||
|
||||
|
||||
## Authentication integration
|
||||
|
||||
We have special support for `yesod-auth`:
|
||||
|
||||
* The `_ID` session key used by `yesod-auth` is recognized and
|
||||
saved separately on the database. This allows you to quickly
|
||||
identify all sessions of a given user. For example, you're
|
||||
able to implement a "log out everywhere" button.
|
||||
|
||||
* Whenever the `_ID` changes, the backend will also invalidate
|
||||
the current session ID and migrate the session data to a new
|
||||
ID. This prevents session fixation attacks while still
|
||||
allowing you to maintain session state accross login/logout
|
||||
boundaries.
|
||||
|
||||
If you wish to use a different authentication mechanism and still
|
||||
enjoy the advantages above, just use the same `_ID` session key.
|
||||
|
||||
|
||||
## Current limitations
|
||||
|
||||
* All sessions use persistent cookies.
|
||||
|
||||
* We support SQL backends only, such as
|
||||
`persistent-postgresql`. The code has to fix upfront which
|
||||
persistent backend is used.
|
||||
|
||||
|
||||
## Background
|
||||
|
||||
Yesod has always support client-side sessions via the
|
||||
[`clientsession`](http://hackage.haskell.org/package/clientsession)
|
||||
package: the session data is encrypted, signed, encoded and sent
|
||||
to the client inside a cookie. When receiving a request, the
|
||||
cookie is decoded, verified and decrypted. The server does not
|
||||
have to maintain any state, so the client-side session backend is
|
||||
as fast as the cryptographic primitives.
|
||||
|
||||
However, there are some disadvantages to client-side sessions:
|
||||
|
||||
* _Replay attacks_. It's not possible to invalidate a session,
|
||||
for example. When logging out, a new cookie is sent with
|
||||
logged out session data. However, as the server doesn't
|
||||
maintain state about sessions, it will still accept the old,
|
||||
logged in cookie until it expires. One could set very small
|
||||
expiration times to mitigate this, but this would force users
|
||||
to relogin frequently. This server-side backend allows you
|
||||
to maintain long expiration times while still having secure
|
||||
logouts.
|
||||
|
||||
* _Cookie size_. As the cookie contain the whole session data
|
||||
plus some overhead, care must be taken not to create too much
|
||||
session data. Yesod already saves the logged in user ID via
|
||||
`yesod-auth` and a XSRF token via `yesod-form`. This
|
||||
server-side backend uses a cookie of fixed size (24 bytes).
|
||||
|
||||
* _No remote logout_. In many instances it is desirable to
|
||||
invalidate sessions other than the current one. For example,
|
||||
the user may have changed their password, or the the site
|
||||
provides a button to cancel all logged in sessions besides
|
||||
the current one. This server-side backend allows you to
|
||||
invalidate sessions other than the current one via
|
||||
`forceInvalidate`.
|
||||
|
||||
* _Missing key rotation_. Ideally, `clientsession`'s keys
|
||||
should be rotated periodically. In practice, support for key
|
||||
rotation has never been implemented on `clientsession`. This
|
||||
server-side backend does not need to do key rotations, and
|
||||
the session ID CPRNG is automatically reseeded.
|
||||
|
||||
If you're concerned about any of the points above, you've come to
|
||||
the right package!
|
||||
|
||||
|
||||
## Comparision to other packages
|
||||
|
||||
At the time of writing (2015-05-22), these are the session
|
||||
packages that do not use either `clientsession` or
|
||||
`serversession`:
|
||||
|
||||
* `mysnapsession` (via `Memory` module, also supports
|
||||
`clientsession` mode): Server-side sessions. Works for
|
||||
`snap`. Weak session ID generation. Vulnerable to session
|
||||
fixation attacks. Cannot invalidate other sessions.
|
||||
|
||||
* `salvia-sessions`: Server-side sessions. Works only for
|
||||
`salvia`. No built-in support for DB-backed sessions, only
|
||||
memory-backed ones. Weak session ID generation. Vulnerable
|
||||
to session fixation attacks. Cannot invalidate other
|
||||
sessions.
|
||||
|
||||
* `simple-session`: Client-side sessions. Works for `simple`
|
||||
framework. No encryption. Authentication vulnerable to
|
||||
timing attacks.
|
||||
|
||||
* `Spock` (formely `scotty-session`): Server-side sessions.
|
||||
Works for `Spock` (code is not packaged separately). Only
|
||||
supports memory-backed sessions persisted on a file. Weak
|
||||
session ID generation. Vulnerable to session fixation
|
||||
attacks. Cannot invalidate other sessions.
|
||||
|
||||
* `wai-session`: Server-side sessions. Works for `wai`
|
||||
applications. Weak session ID generation. Vulnerable to
|
||||
session fixation. Cannot invalidate other sessions.
|
||||
Out-of-the-box support for TokyoCabinet only.
|
||||
|
||||
* `yesod-session-redis`: Server-side sessions. Works for
|
||||
Yesod and Redis. Weak session ID generation via `random`.
|
||||
Vulnerable to session fixation. Cannot invalidate other
|
||||
sessions.
|
||||
|
||||
We apologize in advance if any information above is incorrect.
|
||||
Please contact us about any errors.
|
||||
18
yesod-persistent-session/src/Yesod/Persist/Session.hs
Normal file
18
yesod-persistent-session/src/Yesod/Persist/Session.hs
Normal file
@ -0,0 +1,18 @@
|
||||
-- | Server-side session backend.
|
||||
--
|
||||
-- This module is meant to be imported qualified:
|
||||
--
|
||||
-- @
|
||||
-- import qualified Yesod.Persist.Session as Session
|
||||
-- @
|
||||
--
|
||||
-- TODO: Usage
|
||||
module Yesod.Persist.Session
|
||||
( backend
|
||||
, createState
|
||||
, State
|
||||
, forceInvalidate
|
||||
, ForceInvalidate(..)
|
||||
) where
|
||||
|
||||
import Yesod.Persist.Session.Internal.Backend
|
||||
@ -0,0 +1,266 @@
|
||||
module Yesod.Persist.Session.Internal.Backend
|
||||
( State(..)
|
||||
, createState
|
||||
, backend
|
||||
, loadSession
|
||||
, invalidateIfNeeded
|
||||
, DecomposedSession
|
||||
, decomposeSession
|
||||
, saveSessionOnDb
|
||||
, createCookie
|
||||
, findSessionId
|
||||
, toSessionMap
|
||||
, authKey
|
||||
, forceInvalidateKey
|
||||
, ForceInvalidate(..)
|
||||
, forceInvalidate
|
||||
) where
|
||||
|
||||
import Control.Monad (guard, when)
|
||||
import Control.Monad.IO.Class (MonadIO(..))
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Default (def)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.Text (Text)
|
||||
import Data.Time (getCurrentTime)
|
||||
import Data.Typeable (Typeable)
|
||||
import Web.Cookie (parseCookies, SetCookie(..))
|
||||
import Web.PathPieces (fromPathPiece)
|
||||
import Yesod.Core (MonadHandler)
|
||||
import Yesod.Core.Handler (setSessionBS)
|
||||
import Yesod.Core.Types (Header(AddCookie), SaveSession, SessionBackend(..), SessionMap)
|
||||
|
||||
import qualified Crypto.Nonce as N
|
||||
import qualified Data.ByteString.Char8 as B8
|
||||
import qualified Data.Map as M
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Network.Wai as W
|
||||
|
||||
import Yesod.Persist.Session.Internal.Types
|
||||
|
||||
-- TODO: expiration
|
||||
|
||||
-- TODO: do not create empty sessions
|
||||
|
||||
-- | The server-side session backend needs to maintain some state
|
||||
-- in order to work:
|
||||
--
|
||||
-- * A nonce generator for the session IDs.
|
||||
--
|
||||
-- * The storage backend.
|
||||
--
|
||||
-- Create a new 'State' using 'createState'.
|
||||
data State s =
|
||||
State
|
||||
{ generator :: !N.Generator
|
||||
, storage :: !s
|
||||
} deriving (Typeable)
|
||||
|
||||
|
||||
-- | Create a new 'State' for the server-side session backend
|
||||
-- using the given storage backend.
|
||||
createState :: MonadIO m => s -> m (State s)
|
||||
createState storage = State <$> N.new <*> return storage
|
||||
|
||||
|
||||
-- | Construct the server-side session backend from the given state.
|
||||
backend :: Storage s => State s -> SessionBackend
|
||||
backend state =
|
||||
SessionBackend {
|
||||
sbLoadSession = loadSession state "JSESSIONID" -- LOL :)
|
||||
}
|
||||
|
||||
|
||||
-- | Load the session map from the DB from the ID on the request.
|
||||
-- Also provides a function to update the session when sending
|
||||
-- the response.
|
||||
loadSession :: forall s. Storage s => State s -> ByteString -> W.Request -> IO (SessionMap, SaveSession)
|
||||
loadSession state cookieName = load
|
||||
where
|
||||
runDB :: TransactionM s a -> IO a
|
||||
runDB = runTransactionM (storage state)
|
||||
|
||||
load :: W.Request -> IO (SessionMap, SaveSession)
|
||||
load req = do
|
||||
-- Find 'SessionId' (if any) and load it from DB (if present).
|
||||
let maybeInputId = findSessionId cookieName req
|
||||
maybeInput <- maybe (return Nothing) (runDB . getSession (storage state)) maybeInputId
|
||||
let inputSessionMap = maybe M.empty toSessionMap maybeInput
|
||||
return (inputSessionMap, save maybeInput)
|
||||
|
||||
save :: Maybe Session -> SaveSession
|
||||
save maybeInput wholeOutputSessionMap =
|
||||
runDB $ do
|
||||
let decomposedSessionMap = decomposeSession wholeOutputSessionMap
|
||||
newMaybeInput <- invalidateIfNeeded state maybeInput decomposedSessionMap
|
||||
key <- saveSessionOnDb state newMaybeInput decomposedSessionMap
|
||||
return [createCookie cookieName key]
|
||||
|
||||
|
||||
-- | Invalidates an old session ID if needed. Returns the
|
||||
-- 'Session' that should be replaced when saving the session, if any.
|
||||
--
|
||||
-- Currently we invalidate whenever the auth ID has changed
|
||||
-- (login, logout, different user) in order to prevent session
|
||||
-- fixation attacks. We also invalidate when asked to via
|
||||
-- 'forceInvalidate'.
|
||||
invalidateIfNeeded
|
||||
:: Storage s
|
||||
=> State s
|
||||
-> Maybe Session
|
||||
-> DecomposedSession
|
||||
-> TransactionM s (Maybe Session)
|
||||
invalidateIfNeeded state maybeInput DecomposedSession {..} = do
|
||||
-- Decide which action to take.
|
||||
-- "invalidateOthers implies invalidateCurrent" should be true below.
|
||||
let inputAuthId = sessionAuthId =<< maybeInput
|
||||
invalidateCurrent = dsForceInvalidate /= DoNotForceInvalidate || inputAuthId /= dsAuthId
|
||||
invalidateOthers = dsForceInvalidate == AllSessionIdsOfLoggedUser && isJust dsAuthId
|
||||
whenMaybe b m f = when b $ maybe (return ()) f m
|
||||
-- Delete current and others, as requested.
|
||||
whenMaybe invalidateCurrent maybeInput $ deleteSession (storage state) . sessionKey
|
||||
whenMaybe invalidateOthers dsAuthId $ deleteAllSessionsOfAuthId (storage state)
|
||||
-- Remember the input only if not invalidated.
|
||||
return $ guard (not invalidateCurrent) >> maybeInput
|
||||
|
||||
|
||||
-- | A 'SessionMap' with its 'authKey' taken apart.
|
||||
data DecomposedSession =
|
||||
DecomposedSession
|
||||
{ dsAuthId :: !(Maybe ByteString)
|
||||
, dsForceInvalidate :: !ForceInvalidate
|
||||
, dsSessionMap :: !SessionMap
|
||||
} deriving (Show, Typeable)
|
||||
|
||||
|
||||
-- | Decompose a session (see 'DecomposedSession').
|
||||
decomposeSession :: SessionMap -> DecomposedSession
|
||||
decomposeSession sm1 =
|
||||
let (authId, sm2) = M.updateLookupWithKey (\_ _ -> Nothing) authKey sm1
|
||||
(force, sm3) = M.updateLookupWithKey (\_ _ -> Nothing) forceInvalidateKey sm2
|
||||
in DecomposedSession
|
||||
{ dsAuthId = authId
|
||||
, dsForceInvalidate = maybe DoNotForceInvalidate (read . B8.unpack) force
|
||||
, dsSessionMap = sm3 }
|
||||
|
||||
|
||||
-- | Save a session on the database. If an old session is
|
||||
-- supplied, it is replaced, otherwise a new session is
|
||||
-- generated.
|
||||
saveSessionOnDb
|
||||
:: Storage s
|
||||
=> State s
|
||||
-> Maybe Session -- ^ The old session, if any.
|
||||
-> DecomposedSession -- ^ The session data to be saved.
|
||||
-> TransactionM s SessionId -- ^ The ID of the saved session.
|
||||
saveSessionOnDb state maybeInput DecomposedSession {..} = do
|
||||
-- Generate properties if needed or take them from previous
|
||||
-- saved session.
|
||||
(saveToDb, key, createdAt) <-
|
||||
case maybeInput of
|
||||
Nothing -> liftIO $
|
||||
(,,) <$> return (insertSession $ storage state)
|
||||
<*> generateSessionId (generator state)
|
||||
<*> getCurrentTime
|
||||
Just Session {..} ->
|
||||
return ( replaceSession (storage state)
|
||||
, sessionKey
|
||||
, sessionCreatedAt)
|
||||
-- Save to the database.
|
||||
saveToDb $ Session key dsAuthId dsSessionMap createdAt
|
||||
return key
|
||||
|
||||
|
||||
-- | Create a cookie for the given session ID.
|
||||
createCookie :: ByteString -> SessionId -> Header
|
||||
createCookie cookieName key =
|
||||
-- Generate a cookie with the final session ID.
|
||||
AddCookie def
|
||||
{ setCookieName = cookieName
|
||||
, setCookieValue = TE.encodeUtf8 $ unS key
|
||||
, setCookiePath = Just "/"
|
||||
, setCookieExpires = Just undefined
|
||||
, setCookieDomain = Nothing
|
||||
, setCookieHttpOnly = True
|
||||
}
|
||||
|
||||
|
||||
-- | Fetch the 'SessionId' from the cookie with the given name.
|
||||
-- Returns @Nothing@ if:
|
||||
--
|
||||
-- * There are zero cookies with the given name.
|
||||
--
|
||||
-- * There is more than one cookie with the given name.
|
||||
--
|
||||
-- * The cookie's value isn't considered a 'SessionId'. We're
|
||||
-- a bit strict here.
|
||||
findSessionId :: ByteString -> W.Request -> Maybe SessionId
|
||||
findSessionId cookieName req = do
|
||||
let matching = do
|
||||
("Cookie", header) <- W.requestHeaders req
|
||||
(k, v) <- parseCookies header
|
||||
guard (k == cookieName)
|
||||
return v
|
||||
[raw] <- return matching
|
||||
fromPathPiece (TE.decodeUtf8 raw)
|
||||
|
||||
|
||||
-- | Create a 'SessionMap' from a 'Session'.
|
||||
toSessionMap :: Session -> SessionMap
|
||||
toSessionMap Session {..} =
|
||||
maybe id (M.insert authKey) sessionAuthId sessionData
|
||||
|
||||
|
||||
-- | The session key used by @yesod-auth@ without depending on it.
|
||||
authKey :: Text
|
||||
authKey = "_ID"
|
||||
|
||||
|
||||
-- | The session key used to signal that the session ID should be
|
||||
-- invalidated.
|
||||
forceInvalidateKey :: Text
|
||||
forceInvalidateKey = "yesod-persistent-session-force-invalidate"
|
||||
|
||||
|
||||
-- | Which session IDs should be invalidated.
|
||||
data ForceInvalidate =
|
||||
CurrentSessionId
|
||||
-- ^ Invalidate the current session ID. The current session
|
||||
-- ID is automatically invalidated on @yesod-auth@ login and
|
||||
-- logout.
|
||||
| AllSessionIdsOfLoggedUser
|
||||
-- ^ Invalidate all session IDs beloging to the currently
|
||||
-- logged in user. Only the current session ID will be
|
||||
-- renewed (the only one for which a cookie can be set).
|
||||
--
|
||||
-- This is useful, for example, if the user asks to change
|
||||
-- their password. It's also useful to provide a button to
|
||||
-- clear all other sessions.
|
||||
--
|
||||
-- If the user is not logged in, this option behaves exactly
|
||||
-- as 'CurrentSessionId' (i.e., it /does not/ invalidate the
|
||||
-- sessions of all logged out users).
|
||||
--
|
||||
-- Note that, for the purposes of
|
||||
-- 'AllSessionIdsOfLoggedUser', we consider \"logged user\"
|
||||
-- the one that is logged in at the *end* of the handler
|
||||
-- processing. For example, if the user was logged in but
|
||||
-- the current handler logged him out, the session IDs of the
|
||||
-- user who was logged in will not be invalidated.
|
||||
| DoNotForceInvalidate
|
||||
-- ^ Do not force invalidate. Invalidate only if
|
||||
-- automatically. This is the default.
|
||||
deriving (Eq, Ord, Show, Read, Enum, Typeable)
|
||||
|
||||
|
||||
-- | Invalidate the current session ID (and possibly more, check
|
||||
-- 'ForceInvalidate'). This is useful to avoid session fixation
|
||||
-- attacks (cf. <http://www.acrossecurity.com/papers/session_fixation.pdf>).
|
||||
--
|
||||
-- Note that the invalidate /does not/ occur when the call to
|
||||
-- this action is made! The sessions will be invalidated on the
|
||||
-- end of the handler processing. This means that later calls to
|
||||
-- 'forceInvalidate' on the same handler will override earlier
|
||||
-- calls.
|
||||
forceInvalidate :: MonadHandler m => ForceInvalidate -> m ()
|
||||
forceInvalidate = setSessionBS forceInvalidateKey . B8.pack . show
|
||||
@ -0,0 +1,86 @@
|
||||
module Yesod.Persist.Session.Internal.Sql
|
||||
( PersistentSession(..)
|
||||
, PersistentSessionId
|
||||
, EntityField(..)
|
||||
, persistentSessionDefs
|
||||
, psKey
|
||||
, toPersistentSession
|
||||
, fromPersistentSession
|
||||
, SqlStorage(..)
|
||||
) where
|
||||
|
||||
import Control.Monad (void)
|
||||
import Data.Pool (Pool)
|
||||
import Data.Time (UTCTime)
|
||||
import Data.Typeable (Typeable)
|
||||
import Database.Persist (PersistEntity(..), toPersistValue)
|
||||
import Database.Persist.TH (mkPersist, mkSave, persistLowerCase, share, sqlSettings)
|
||||
|
||||
import qualified Database.Persist as P
|
||||
import qualified Database.Persist.Sql as P
|
||||
|
||||
import Yesod.Persist.Session.Internal.Types
|
||||
|
||||
|
||||
share
|
||||
[mkPersist sqlSettings, mkSave "persistentSessionDefs"]
|
||||
[persistLowerCase|
|
||||
PersistentSession json
|
||||
key SessionId -- Session ID, primary key.
|
||||
authId ByteStringJ Maybe -- Value of "_ID" session key.
|
||||
session SessionMapJ -- Rest of the session data.
|
||||
createdAt UTCTime -- When this session was created.
|
||||
Primary key
|
||||
deriving Eq Ord Show Typeable
|
||||
|]
|
||||
|
||||
|
||||
-- | Generate a key to the entity from the session ID.
|
||||
psKey :: SessionId -> Key PersistentSession
|
||||
psKey = unwrap . keyFromValues . return . toPersistValue
|
||||
where
|
||||
unwrap (Left e) = error $
|
||||
"Yesod.Persist.Session.Internal.Entities.psKey: " ++
|
||||
"unexpected error from keyFromValues: " ++ show e
|
||||
unwrap (Right k) = k
|
||||
|
||||
|
||||
-- | Convert from 'Session' to 'PersistentSession'.
|
||||
toPersistentSession :: Session -> PersistentSession
|
||||
toPersistentSession Session {..} =
|
||||
PersistentSession
|
||||
{ persistentSessionKey = sessionKey
|
||||
, persistentSessionAuthId = fmap B sessionAuthId
|
||||
, persistentSessionSession = M sessionData
|
||||
, persistentSessionCreatedAt = sessionCreatedAt
|
||||
}
|
||||
|
||||
|
||||
-- | Convert from 'PersistentSession' to 'Session'.
|
||||
fromPersistentSession :: PersistentSession -> Session
|
||||
fromPersistentSession PersistentSession {..} =
|
||||
Session
|
||||
{ sessionKey = persistentSessionKey
|
||||
, sessionAuthId = fmap unB persistentSessionAuthId
|
||||
, sessionData = unM persistentSessionSession
|
||||
, sessionCreatedAt = persistentSessionCreatedAt
|
||||
}
|
||||
|
||||
|
||||
-- | SQL session storage backend using @persistent@.
|
||||
newtype SqlStorage =
|
||||
SqlStorage
|
||||
{ connPool :: Pool P.SqlBackend
|
||||
-- ^ Pool of DB connections. You may use the same pool as
|
||||
-- your application.
|
||||
} deriving (Typeable)
|
||||
|
||||
|
||||
instance Storage SqlStorage where
|
||||
type TransactionM SqlStorage = P.SqlPersistT IO
|
||||
runTransactionM = flip P.runSqlPool . connPool
|
||||
getSession _ = fmap (fmap fromPersistentSession) . P.get . psKey
|
||||
deleteSession _ = P.delete . psKey
|
||||
deleteAllSessionsOfAuthId _ authId = P.deleteWhere [PersistentSessionAuthId P.==. Just (B authId)]
|
||||
insertSession _ = void . P.insert . toPersistentSession
|
||||
replaceSession _ = \session -> P.replace (psKey $ sessionKey session) $ toPersistentSession session
|
||||
@ -0,0 +1,184 @@
|
||||
module Yesod.Persist.Session.Internal.Types
|
||||
( SessionId(..)
|
||||
, generateSessionId
|
||||
, Session(..)
|
||||
, Storage(..)
|
||||
, ByteStringJ(..)
|
||||
, SessionMapJ(..)
|
||||
) where
|
||||
|
||||
import Control.Monad ((>=>), guard, mzero)
|
||||
import Control.Monad.IO.Class (MonadIO)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Text (Text)
|
||||
import Data.Time (UTCTime)
|
||||
import Data.Typeable (Typeable)
|
||||
import Database.Persist (PersistField(..))
|
||||
import Database.Persist.Sql (PersistFieldSql(..))
|
||||
import Web.PathPieces (PathPiece(..))
|
||||
import Yesod.Core (SessionMap)
|
||||
|
||||
import qualified Crypto.Nonce as N
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.ByteString as B
|
||||
import qualified Data.ByteString.Base64.URL as B64URL
|
||||
import qualified Data.ByteString.Lazy as L
|
||||
import qualified Data.Map as M
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- | The ID of a session. Always 18 bytes base64url-encoded as
|
||||
-- 24 characters.
|
||||
--
|
||||
-- Implementation notes:
|
||||
--
|
||||
-- * Use 'fromPathPiece' for parsing untrusted input.
|
||||
--
|
||||
-- * Use 'generateSessionId' for securely generating new
|
||||
-- session IDs.
|
||||
newtype SessionId = S { unS :: Text }
|
||||
deriving (Eq, Ord, Show, Read, Typeable)
|
||||
|
||||
-- | Sanity checks input on 'fromPathPiece' (untrusted input).
|
||||
instance PathPiece SessionId where
|
||||
toPathPiece = unS
|
||||
fromPathPiece = checkSessionId
|
||||
|
||||
-- | Does not do sanity checks (DB is trusted).
|
||||
instance PersistField SessionId where
|
||||
toPersistValue = toPersistValue . unS
|
||||
fromPersistValue = fmap S . fromPersistValue
|
||||
|
||||
instance PersistFieldSql SessionId where
|
||||
sqlType p = sqlType (fmap unS p)
|
||||
|
||||
instance A.FromJSON SessionId where
|
||||
parseJSON = fmap S . A.parseJSON
|
||||
|
||||
instance A.ToJSON SessionId where
|
||||
toJSON = A.toJSON . unS
|
||||
|
||||
|
||||
-- | (Internal) Check that the given text is a base64url-encoded
|
||||
-- representation of 18 bytes.
|
||||
checkSessionId :: Text -> Maybe SessionId
|
||||
checkSessionId text = do
|
||||
guard (T.length text == 24)
|
||||
let bs = TE.encodeUtf8 text
|
||||
decoded <- either (const Nothing) Just $ B64URL.decode bs
|
||||
guard (B.length decoded == 18)
|
||||
return $ S $ T.toLower text
|
||||
|
||||
|
||||
-- | Securely generate a new SessionId.
|
||||
generateSessionId :: N.Generator -> IO SessionId
|
||||
generateSessionId = fmap S . N.nonce128urlT
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- | Representation of a saved session.
|
||||
data Session =
|
||||
Session
|
||||
{ sessionKey :: SessionId
|
||||
-- ^ Session ID, primary key.
|
||||
, sessionAuthId :: Maybe ByteString
|
||||
-- ^ Value of "_ID" session key, separate from the rest.
|
||||
, sessionData :: SessionMap
|
||||
-- ^ Rest of the session data.
|
||||
, sessionCreatedAt :: UTCTime
|
||||
-- ^ When this session was created.
|
||||
} deriving (Eq, Ord, Show, Typeable)
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- | A storage backend for server-side sessions.
|
||||
class MonadIO (TransactionM s) => Storage s where
|
||||
-- | Monad where transactions happen for this backend.
|
||||
-- We do not require transactions to be ACID.
|
||||
type TransactionM s :: * -> *
|
||||
|
||||
-- | Run a transaction on the IO monad.
|
||||
runTransactionM :: s -> TransactionM s a -> IO a
|
||||
|
||||
-- | Get the session for the given session ID.
|
||||
getSession :: s -> SessionId -> TransactionM s (Maybe Session)
|
||||
|
||||
-- | Delete the session with given session ID.
|
||||
deleteSession :: s -> SessionId -> TransactionM s ()
|
||||
|
||||
-- | Delete all sessions of the given auth ID.
|
||||
deleteAllSessionsOfAuthId :: s -> ByteString -> TransactionM s ()
|
||||
|
||||
-- | Insert a new session.
|
||||
insertSession :: s -> Session -> TransactionM s ()
|
||||
|
||||
-- | Replace the contents of a session.
|
||||
replaceSession :: s -> Session -> TransactionM s ()
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- | Newtype of a 'ByteString' with JSON support via base64url.
|
||||
newtype ByteStringJ = B { unB :: ByteString }
|
||||
deriving (Eq, Ord, Show, Read, Typeable)
|
||||
|
||||
instance PersistField ByteStringJ where
|
||||
toPersistValue = toPersistValue . unB
|
||||
fromPersistValue = fmap B . fromPersistValue
|
||||
|
||||
instance PersistFieldSql ByteStringJ where
|
||||
sqlType p = sqlType (fmap unB p)
|
||||
|
||||
instance A.FromJSON ByteStringJ where
|
||||
parseJSON (A.String t) =
|
||||
either (const mzero) (return . B) $
|
||||
B64URL.decode $
|
||||
TE.encodeUtf8 t
|
||||
parseJSON _ = mzero
|
||||
|
||||
instance A.ToJSON ByteStringJ where
|
||||
toJSON = A.String . TE.decodeUtf8 . B64URL.encode . unB
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- | Newtype of a 'SessionMap' that serializes as a JSON on
|
||||
-- the database. We use JSON because it's easy to inspect for a
|
||||
-- human.
|
||||
newtype SessionMapJ = M { unM :: SessionMap }
|
||||
deriving (Eq, Ord, Show, Read, Typeable)
|
||||
|
||||
encodeT :: A.ToJSON a => a -> Text
|
||||
encodeT = TE.decodeUtf8 . L.toStrict . A.encode
|
||||
|
||||
decodeT :: A.FromJSON a => Text -> Either Text a
|
||||
decodeT = either (Left . T.pack) Right . A.eitherDecode . L.fromStrict . TE.encodeUtf8
|
||||
|
||||
instance PersistField SessionMapJ where
|
||||
toPersistValue = toPersistValue . encodeT
|
||||
fromPersistValue = fromPersistValue >=> decodeT
|
||||
|
||||
instance PersistFieldSql SessionMapJ where
|
||||
sqlType p = sqlType (fmap encodeT p)
|
||||
|
||||
instance A.FromJSON SessionMapJ where
|
||||
parseJSON = fmap fixup . A.parseJSON
|
||||
where
|
||||
fixup :: M.Map Text ByteStringJ -> SessionMapJ
|
||||
fixup = M . fmap unB
|
||||
|
||||
instance A.ToJSON SessionMapJ where
|
||||
toJSON = A.toJSON . mangle
|
||||
where
|
||||
mangle :: SessionMapJ -> M.Map Text ByteStringJ
|
||||
mangle = fmap B . unM
|
||||
59
yesod-persistent-session/yesod-persistent-session.cabal
Normal file
59
yesod-persistent-session/yesod-persistent-session.cabal
Normal file
@ -0,0 +1,59 @@
|
||||
name: yesod-persistent-session
|
||||
version: 1.0
|
||||
license: MIT
|
||||
license-file: LICENSE
|
||||
author: Felipe Lessa <felipe.lessa@gmail.com>
|
||||
maintainer: Felipe Lessa <felipe.lessa@gmail.com>
|
||||
synopsis: Server-side session backend using persistent.
|
||||
category: Web, Yesod, Database
|
||||
stability: Stable
|
||||
cabal-version: >= 1.8
|
||||
build-type: Simple
|
||||
homepage: http://www.yesodweb.com/
|
||||
description: API docs and the README are available at <http://www.stackage.org/package/yesod-persistent-session>
|
||||
extra-source-files: README.md ChangeLog.md
|
||||
|
||||
library
|
||||
hs-source-dirs: src
|
||||
build-depends:
|
||||
base == 4.*
|
||||
, aeson
|
||||
, base64-bytestring == 1.0.*
|
||||
, bytestring
|
||||
, containers
|
||||
, cookie >= 0.4
|
||||
, data-default
|
||||
, nonce == 1.0.*
|
||||
, path-pieces
|
||||
, persistent == 2.1.*
|
||||
, persistent-template == 2.1.*
|
||||
, resource-pool
|
||||
, text
|
||||
, time
|
||||
, transformers
|
||||
, wai
|
||||
, yesod-core == 1.4.*
|
||||
exposed-modules:
|
||||
Yesod.Persist.Session
|
||||
Yesod.Persist.Session.Internal.Backend
|
||||
Yesod.Persist.Session.Internal.Sql
|
||||
Yesod.Persist.Session.Internal.Types
|
||||
extensions:
|
||||
DeriveDataTypeable
|
||||
EmptyDataDecls
|
||||
FlexibleContexts
|
||||
FlexibleInstances
|
||||
GADTs
|
||||
GeneralizedNewtypeDeriving
|
||||
OverloadedStrings
|
||||
QuasiQuotes
|
||||
RecordWildCards
|
||||
ScopedTypeVariables
|
||||
TemplateHaskell
|
||||
TypeFamilies
|
||||
ghc-options: -Wall
|
||||
|
||||
|
||||
source-repository head
|
||||
type: git
|
||||
location: https://github.com/yesodweb/yesod
|
||||
Loading…
Reference in New Issue
Block a user