Separate into three different packages.
serversession and serversession-backend-persistent build, serversession-frontend-yesod is broken.
This commit is contained in:
parent
e5f2a4d87a
commit
71aff2dd94
163
README.md
Normal file
163
README.md
Normal file
@ -0,0 +1,163 @@
|
||||
# serversession packages
|
||||
|
||||
Secure, modular server-side sessions.
|
||||
|
||||
This repo contains many packages that together 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
|
||||
storage backend. All session data is saved on the storage backend.
|
||||
|
||||
The `serversession` package implements the core logic. It needs
|
||||
to be paired up with two companion packages:
|
||||
|
||||
* /Backend (storage)/, in order to store the session data.
|
||||
Currently we support:
|
||||
|
||||
* `serversession-backend-persistent`: Storage backend using
|
||||
`persistent`'s SQL backend. Works with PostgreSQL, MySQL,
|
||||
SQLite.
|
||||
|
||||
* /Frontend/, bindings for your web framework of choice.
|
||||
Currently we support:
|
||||
|
||||
* `serversession-frontend-yesod`: Support the Yesod
|
||||
framework. Replaces the default `clientsession`.
|
||||
|
||||
If your favorite storage backend or framework is not listed
|
||||
above, please send us a pull request! The `serversession`
|
||||
package should work for any session that may be represented as a
|
||||
mapping of keys to values.
|
||||
|
||||
|
||||
## Security notes
|
||||
|
||||
The session ID is generated via the `nonce` package, which in
|
||||
turn uses a CPRNG created from AES on CTR mode. The CPRNG is
|
||||
reseed automatically from `/dev/urandom` (or equivalent)
|
||||
periodically. We use the base64url variant, thus providing 144
|
||||
bits of entropy, which is more than enough to make guessing
|
||||
session IDs impossible.
|
||||
|
||||
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`).
|
||||
|
||||
We support both idle timeouts and absolute timeouts. Idle
|
||||
timeouts invalidate the session if a given amount of time has
|
||||
passed since the last request was made for a session. Absolute
|
||||
timeouts invalidate the session if a given amount of time has
|
||||
passed since the session was created, no matter the activity.
|
||||
|
||||
|
||||
## Authentication integration
|
||||
|
||||
We have special support for authentication plugins that save
|
||||
information about the logged in user on a session variable:
|
||||
|
||||
* The session key used by authentication plugin (e.g., `_ID`
|
||||
for `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 logged in user 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.
|
||||
|
||||
Any authentication mechanism is supported as long as it uses a
|
||||
session variable.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
The `serversession` package is `clientsession`'s rival, each has
|
||||
their own advantages and disadvantages. However, both of them
|
||||
can be used on different ecosystems and take security from the
|
||||
ground up.
|
||||
|
||||
|
||||
## 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.
|
||||
20
serversession-backend-persistent/LICENSE
Normal file
20
serversession-backend-persistent/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.
|
||||
10
serversession-backend-persistent/README.md
Normal file
10
serversession-backend-persistent/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# serversession-backend-persistent
|
||||
|
||||
This is the storage backend for `serversession` using
|
||||
`persistent` and an RDBMS. Please
|
||||
[read the main README file](https://github.com/yesodweb/serversession/blob/master/README.md)
|
||||
for general information about the serversession packages.
|
||||
|
||||
Unfortunately it is not easy to support all `persistent` backends
|
||||
on a single package, and this is why we currently support the SQL
|
||||
backend only (which is more commonly used).
|
||||
@ -0,0 +1,51 @@
|
||||
name: serversession-backend-persistent
|
||||
version: 1.0
|
||||
license: MIT
|
||||
license-file: LICENSE
|
||||
author: Felipe Lessa <felipe.lessa@gmail.com>
|
||||
maintainer: Felipe Lessa <felipe.lessa@gmail.com>
|
||||
synopsis: Storage backend for serversession using persistent and an RDBMS.
|
||||
category: Web
|
||||
stability: Stable
|
||||
cabal-version: >= 1.8
|
||||
build-type: Simple
|
||||
homepage: https://github.com/yesodweb/serversession
|
||||
description: API docs and the README are available at <http://www.stackage.org/package/serversession-backend-persistent-sql>
|
||||
extra-source-files: README.md
|
||||
|
||||
library
|
||||
hs-source-dirs: src
|
||||
build-depends:
|
||||
base == 4.*
|
||||
, aeson
|
||||
, base64-bytestring == 1.0.*
|
||||
, bytestring
|
||||
, containers
|
||||
, path-pieces
|
||||
, persistent == 2.1.*
|
||||
, persistent-template == 2.1.*
|
||||
, resource-pool
|
||||
, text
|
||||
, time
|
||||
|
||||
, serversession == 1.0.*
|
||||
exposed-modules:
|
||||
Web.ServerSession.Backend.Persistent
|
||||
Web.ServerSession.Backend.Persistent.Internal.Impl
|
||||
Web.ServerSession.Backend.Persistent.Internal.Types
|
||||
extensions:
|
||||
DeriveDataTypeable
|
||||
EmptyDataDecls
|
||||
FlexibleInstances
|
||||
GADTs
|
||||
GeneralizedNewtypeDeriving
|
||||
OverloadedStrings
|
||||
QuasiQuotes
|
||||
RecordWildCards
|
||||
TemplateHaskell
|
||||
TypeFamilies
|
||||
ghc-options: -Wall
|
||||
|
||||
source-repository head
|
||||
type: git
|
||||
location: https://github.com/yesodweb/serversession
|
||||
@ -0,0 +1,40 @@
|
||||
-- | Storage backend for @serversession@ using persistent.
|
||||
--
|
||||
-- In order to use this backend, you have to include
|
||||
-- 'serverSessionDefs' on your migration code. For example,
|
||||
-- the Yesod scaffold usually includes the following code:
|
||||
--
|
||||
-- @
|
||||
-- -- On Model.hs
|
||||
-- share [mkPersist sqlSettings, mkMigrate \"migrateAll\"]
|
||||
--
|
||||
-- -- On Application.hs
|
||||
-- makeFoundation =
|
||||
-- ...
|
||||
-- runLoggingT (runSqlPool (runMigration migrateAll) pool) logFunc
|
||||
-- ...
|
||||
-- @
|
||||
--
|
||||
-- You should changed those lines to:
|
||||
--
|
||||
-- @
|
||||
-- -- On Model.hs
|
||||
-- share [mkPersist sqlSettings, mkSave \"entityDefs\"]
|
||||
--
|
||||
-- -- On Application.hs
|
||||
-- mkMigrate \"migrateAll\" (serverSessionDefs ++ entityDefs)
|
||||
--
|
||||
-- makeFoundation =
|
||||
-- ...
|
||||
-- runLoggingT (runSqlPool (runMigration migrateAll) pool) logFunc
|
||||
-- ...
|
||||
-- @
|
||||
--
|
||||
-- If you forget to setup the migration above, this backend will
|
||||
-- fail at runtime as the required table will not exist.
|
||||
module Web.ServerSession.Backend.Persistent
|
||||
( SqlStorage(..)
|
||||
, serverSessionDefs
|
||||
) where
|
||||
|
||||
import Web.ServerSession.Backend.Persistent.Internal.Impl
|
||||
@ -1,8 +1,10 @@
|
||||
module Yesod.Persist.Session.Internal.Sql
|
||||
-- | Internal module exposing the guts of the package. Use at
|
||||
-- your own risk. No API stability guarantees apply.
|
||||
module Web.ServerSession.Backend.Persistent.Internal.Impl
|
||||
( PersistentSession(..)
|
||||
, PersistentSessionId
|
||||
, EntityField(..)
|
||||
, persistentSessionDefs
|
||||
, serverSessionDefs
|
||||
, psKey
|
||||
, toPersistentSession
|
||||
, fromPersistentSession
|
||||
@ -13,17 +15,18 @@ import Control.Monad (void)
|
||||
import Data.Pool (Pool)
|
||||
import Data.Time (UTCTime)
|
||||
import Data.Typeable (Typeable)
|
||||
import Database.Persist (PersistEntity(..), toPersistValue)
|
||||
import Database.Persist (PersistEntity(..))
|
||||
import Database.Persist.TH (mkPersist, mkSave, persistLowerCase, share, sqlSettings)
|
||||
import Web.ServerSession.Core
|
||||
|
||||
import qualified Database.Persist as P
|
||||
import qualified Database.Persist.Sql as P
|
||||
|
||||
import Yesod.Persist.Session.Internal.Types
|
||||
import Web.ServerSession.Backend.Persistent.Internal.Types
|
||||
|
||||
|
||||
share
|
||||
[mkPersist sqlSettings, mkSave "persistentSessionDefs"]
|
||||
[mkPersist sqlSettings, mkSave "serverSessionDefs"]
|
||||
[persistLowerCase|
|
||||
PersistentSession json
|
||||
key SessionId -- Session ID, primary key.
|
||||
@ -37,12 +40,7 @@ share
|
||||
|
||||
-- | 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
|
||||
psKey = PersistentSessionKey'
|
||||
|
||||
|
||||
-- | Convert from 'Session' to 'PersistentSession'.
|
||||
@ -0,0 +1,97 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
-- | Internal module exposing the guts of the package. Use at
|
||||
-- your own risk. No API stability guarantees apply.
|
||||
--
|
||||
-- Also exports orphan instances of @PersistField{,Sql} SessionId@.
|
||||
module Web.ServerSession.Backend.Persistent.Internal.Types
|
||||
( ByteStringJ(..)
|
||||
, SessionMapJ(..)
|
||||
) where
|
||||
|
||||
import Control.Monad ((>=>), mzero)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Text (Text)
|
||||
import Data.Typeable (Typeable)
|
||||
import Database.Persist (PersistField(..))
|
||||
import Database.Persist.Sql (PersistFieldSql(..))
|
||||
import Web.ServerSession.Core
|
||||
import Web.ServerSession.Core.Internal (SessionId(..))
|
||||
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.ByteString.Lazy as L
|
||||
import qualified Data.ByteString.Base64.URL as B64URL
|
||||
import qualified Data.Map as M
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- | 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)
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- | 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
|
||||
20
serversession-frontend-yesod/LICENSE
Normal file
20
serversession-frontend-yesod/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.
|
||||
6
serversession-frontend-yesod/README.md
Normal file
6
serversession-frontend-yesod/README.md
Normal file
@ -0,0 +1,6 @@
|
||||
# serversession-frontend-yesod
|
||||
|
||||
This package provide Yesod bindings for the `serversession`
|
||||
package. Please
|
||||
[read the main README file](https://github.com/yesodweb/serversession/blob/master/README.md)
|
||||
for general information about the serversession packages.
|
||||
@ -0,0 +1,58 @@
|
||||
name: serversession-frontend-yesod
|
||||
version: 1.0
|
||||
license: MIT
|
||||
license-file: LICENSE
|
||||
author: Felipe Lessa <felipe.lessa@gmail.com>
|
||||
maintainer: Felipe Lessa <felipe.lessa@gmail.com>
|
||||
synopsis: Yesod bindings for serversession.
|
||||
category: Web
|
||||
stability: Stable
|
||||
cabal-version: >= 1.8
|
||||
build-type: Simple
|
||||
homepage: https://github.com/yesodweb/serversession
|
||||
description: API docs and the README are available at <http://www.stackage.org/package/serversession-frontend-yesod>
|
||||
extra-source-files: README.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/serversession
|
||||
@ -0,0 +1,5 @@
|
||||
module Web.ServerSession.Frontend.Yesod
|
||||
(
|
||||
) where
|
||||
|
||||
import Web.ServerSession.Frontend.Yesod.Internal
|
||||
@ -0,0 +1,77 @@
|
||||
module Web.ServerSession.Frontend.Yesod.Internal
|
||||
(
|
||||
) where
|
||||
|
||||
|
||||
-- TODO: I'm in a bad shape :(.
|
||||
|
||||
|
||||
import Data.Default (def)
|
||||
import Web.Cookie (parseCookies, SetCookie(..))
|
||||
import Yesod.Core (MonadHandler)
|
||||
import Yesod.Core.Handler (setSessionBS)
|
||||
import Yesod.Core.Types (Header(AddCookie), SaveSession, SessionBackend(..), SessionMap)
|
||||
import qualified Network.Wai as W
|
||||
|
||||
|
||||
-- | Construct the server-side session backend from the given state.
|
||||
backend :: Storage s => State s -> SessionBackend
|
||||
backend state =
|
||||
SessionBackend {
|
||||
sbLoadSession = loadSession state "JSESSIONID" -- LOL :)
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- | 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)
|
||||
|
||||
|
||||
-- | The session key used by @yesod-auth@ without depending on it.
|
||||
authKey :: Text
|
||||
authKey = "_ID"
|
||||
|
||||
|
||||
|
||||
|
||||
-- | 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
|
||||
@ -1,131 +1,5 @@
|
||||
# yesod-persistent-session
|
||||
# serversession
|
||||
|
||||
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.
|
||||
This is the core package of the serversession family. Please
|
||||
[read the main README file](https://github.com/yesodweb/serversession/blob/master/README.md)
|
||||
for general information about the serversession packages.
|
||||
|
||||
@ -21,35 +21,20 @@ library
|
||||
, 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
|
||||
Web.ServerSession.Core
|
||||
Web.ServerSession.Core.Internal
|
||||
extensions:
|
||||
DeriveDataTypeable
|
||||
EmptyDataDecls
|
||||
FlexibleContexts
|
||||
FlexibleInstances
|
||||
GADTs
|
||||
GeneralizedNewtypeDeriving
|
||||
OverloadedStrings
|
||||
QuasiQuotes
|
||||
RecordWildCards
|
||||
ScopedTypeVariables
|
||||
TemplateHaskell
|
||||
TypeFamilies
|
||||
ghc-options: -Wall
|
||||
|
||||
|
||||
21
serversession/src/Web/ServerSession/Core.hs
Normal file
21
serversession/src/Web/ServerSession/Core.hs
Normal file
@ -0,0 +1,21 @@
|
||||
-- | Core server-side session support.
|
||||
module Web.ServerSession.Core
|
||||
( -- * For serversession storage backends
|
||||
SessionId
|
||||
, Session(..)
|
||||
, Storage(..)
|
||||
|
||||
-- * For serversession frontends
|
||||
, SessionMap
|
||||
, State(..)
|
||||
, createState
|
||||
, setAuthKey
|
||||
, loadSession
|
||||
, saveSession
|
||||
, SaveSessionToken
|
||||
, forceInvalidateKey
|
||||
-- ** To be re-exported by frontends
|
||||
, ForceInvalidate(..)
|
||||
) where
|
||||
|
||||
import Web.ServerSession.Core.Internal
|
||||
336
serversession/src/Web/ServerSession/Core/Internal.hs
Normal file
336
serversession/src/Web/ServerSession/Core/Internal.hs
Normal file
@ -0,0 +1,336 @@
|
||||
-- | Internal module exposing the guts of the package. Use at
|
||||
-- your own risk. No API stability guarantees apply.
|
||||
module Web.ServerSession.Core.Internal
|
||||
( SessionId(..)
|
||||
, checkSessionId
|
||||
, generateSessionId
|
||||
|
||||
, SessionMap
|
||||
, Session(..)
|
||||
, Storage(..)
|
||||
|
||||
, State(..)
|
||||
, createState
|
||||
, setAuthKey
|
||||
, loadSession
|
||||
, saveSession
|
||||
, SaveSessionToken(..)
|
||||
, invalidateIfNeeded
|
||||
, DecomposedSession
|
||||
, decomposeSession
|
||||
, saveSessionOnDb
|
||||
, toSessionMap
|
||||
, forceInvalidateKey
|
||||
, ForceInvalidate(..)
|
||||
) where
|
||||
|
||||
import Control.Monad (guard, when)
|
||||
import Control.Monad.IO.Class (MonadIO(..))
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.Text (Text)
|
||||
import Data.Time (UTCTime, getCurrentTime)
|
||||
import Data.Typeable (Typeable)
|
||||
import Web.PathPieces (PathPiece(..))
|
||||
|
||||
import qualified Crypto.Nonce as N
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.ByteString.Base64.URL as B64URL
|
||||
import qualified Data.ByteString.Char8 as B8
|
||||
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
|
||||
|
||||
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 (B8.length decoded == 18)
|
||||
return $ S $ T.toLower text
|
||||
|
||||
|
||||
-- | Securely generate a new SessionId.
|
||||
generateSessionId :: N.Generator -> IO SessionId
|
||||
generateSessionId = fmap S . N.nonce128urlT
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- | A session map.
|
||||
--
|
||||
-- This is the representation of a session used by the
|
||||
-- @serversession@ family of packages, transferring data between
|
||||
-- this core package and frontend packages. Serversession
|
||||
-- storage backend packages should use 'Session'. End users
|
||||
-- should use their web framework's support for sessions.
|
||||
type SessionMap = M.Map Text ByteString
|
||||
|
||||
|
||||
-- | Representation of a saved session.
|
||||
--
|
||||
-- This representation is used by the @serversession@ family of
|
||||
-- packages, transferring data between this core package and
|
||||
-- storage backend packages. Serversession frontend packages
|
||||
-- should use 'SessionMap'. End users should use their web
|
||||
-- framework's support for sessions.
|
||||
data Session =
|
||||
Session
|
||||
{ sessionKey :: SessionId
|
||||
-- ^ Session ID, primary key.
|
||||
, sessionAuthId :: Maybe ByteString
|
||||
-- ^ Value of 'authKey' 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 ()
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
|
||||
-- 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.
|
||||
--
|
||||
-- * A reference to the storage backend.
|
||||
--
|
||||
-- * Authentication session variable ('setAuthKey').
|
||||
--
|
||||
-- Create a new 'State' using 'createState'.
|
||||
data State s =
|
||||
State
|
||||
{ generator :: !N.Generator
|
||||
, storage :: !s
|
||||
, authKey :: Text
|
||||
} 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
|
||||
<*> return "_ID"
|
||||
|
||||
|
||||
-- | Set the name of the session variable that keeps track of the
|
||||
-- logged user. Defaults to \"_ID\".
|
||||
setAuthKey :: State s -> Text -> State s
|
||||
setAuthKey state val = state { authKey = val }
|
||||
|
||||
|
||||
-- | Load the session map from the storage backend. The value of
|
||||
-- the session cookie should be given as argument if present.
|
||||
--
|
||||
-- Returns:
|
||||
--
|
||||
-- * The 'SessionMap' to be used by the frontend as the current
|
||||
-- session's value.
|
||||
--
|
||||
-- * Information to be passed back to 'saveSession' on the end
|
||||
-- of the request in order to save the session.
|
||||
loadSession :: Storage s => State s -> Maybe ByteString -> IO (SessionMap, SaveSessionToken)
|
||||
loadSession state mcookieVal = do
|
||||
let maybeInputId = mcookieVal >>= fromPathPiece . TE.decodeUtf8
|
||||
get = runTransactionM (storage state) . getSession (storage state)
|
||||
maybeInput <- maybe (return Nothing) get maybeInputId
|
||||
let inputSessionMap = maybe M.empty (toSessionMap state) maybeInput
|
||||
return (inputSessionMap, SaveSessionToken maybeInput)
|
||||
|
||||
|
||||
-- | Opaque token containing the necessary information for
|
||||
-- 'saveSession' to save the session.
|
||||
newtype SaveSessionToken = SaveSessionToken (Maybe Session)
|
||||
|
||||
|
||||
-- | Save the session on the storage backend. A
|
||||
-- 'SaveSessionToken' given by 'loadSession' is expected besides
|
||||
-- the new contents of the session.
|
||||
saveSession :: Storage s => State s -> SaveSessionToken -> SessionMap -> IO SessionId
|
||||
saveSession state (SaveSessionToken maybeInput) wholeOutputSessionMap =
|
||||
runTransactionM (storage state) $ do
|
||||
let decomposedSessionMap = decomposeSession state wholeOutputSessionMap
|
||||
newMaybeInput <- invalidateIfNeeded state maybeInput decomposedSessionMap
|
||||
saveSessionOnDb state newMaybeInput decomposedSessionMap
|
||||
|
||||
|
||||
-- | 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 special variables taken apart.
|
||||
data DecomposedSession =
|
||||
DecomposedSession
|
||||
{ dsAuthId :: !(Maybe ByteString)
|
||||
, dsForceInvalidate :: !ForceInvalidate
|
||||
, dsSessionMap :: !SessionMap
|
||||
} deriving (Show, Typeable)
|
||||
|
||||
|
||||
-- | Decompose a session (see 'DecomposedSession').
|
||||
decomposeSession :: State s -> SessionMap -> DecomposedSession
|
||||
decomposeSession state sm1 =
|
||||
let (authId, sm2) = M.updateLookupWithKey (\_ _ -> Nothing) (authKey state) 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 'SessionMap' from a 'Session'.
|
||||
toSessionMap :: State s -> Session -> SessionMap
|
||||
toSessionMap state Session {..} =
|
||||
maybe id (M.insert $ authKey state) sessionAuthId sessionData
|
||||
|
||||
|
||||
-- | 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)
|
||||
@ -1,18 +0,0 @@
|
||||
-- | 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
|
||||
@ -1,266 +0,0 @@
|
||||
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
|
||||
@ -1,184 +0,0 @@
|
||||
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
|
||||
Loading…
Reference in New Issue
Block a user