diff --git a/config/settings.yml b/config/settings.yml
index d305be6a2..e9d4787ed 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -31,8 +31,8 @@ notification-rate-limit: 3600
notification-collate-delay: 7200
notification-expiration: 259200
session-timeout: 7200
-jwt-expiration: 604800
-jwt-encoding: HS256
+bearer-expiration: 604800
+bearer-encoding: HS256
maximum-content-length: "_env:MAX_UPLOAD_SIZE:134217728"
session-files-expire: 3600
prune-unreferenced-files: 86400
@@ -67,6 +67,7 @@ ip-retention-time: 1209600
# Debugging
auth-dummy-login: "_env:DUMMY_LOGIN:false"
allow-deprecated: "_env:ALLOW_DEPRECATED:false"
+server-session-acid-fallback: "_env:SERVER_SESSION_ACID_FALLBACK:false"
auth-pw-hash:
algorithm: "pbkdf2"
@@ -119,13 +120,32 @@ smtp:
limit: "_env:SMTPLIMIT:10"
widget-memcached:
- host: "_env:MEMCACHEDHOST:"
- port: "_env:MEMCACHEDPORT:11211"
+ host: "_env:WIDGET_MEMCACHED_HOST:"
+ port: "_env:WIDGET_MEMCACHED_PORT:11211"
auth: []
- limit: "_env:MEMCACHEDLIMIT:10"
- timeout: "_env:MEMCACHEDTIMEOUT:20"
- base-url: "_env:MEMCACHEDROOT:"
- expiration: "_env:MEMCACHEDEXPIRATION:3600"
+ limit: "_env:WIDGET_MEMCACHED_LIMIT:10"
+ timeout: "_env:WIDGET_MEMCACHED_TIMEOUT:20"
+ base-url: "_env:WIDGET_MEMCACHED_ROOT:"
+ expiration: "_env:WIDGET_MEMCACHED_EXPIRATION:3600"
+
+session-memcached:
+ host: "_env:SESSION_MEMCACHED_HOST:"
+ port: "_env:SESSION_MEMCACHED_PORT:11211"
+ auth: []
+ limit: "_env:SESSION_MEMCACHED_LIMIT:10"
+ timeout: "_env:SESSION_MEMCACHED_TIMEOUT:20"
+ expiration: "_env:SESSION_MEMCACHED_EXPIRATION:28807"
+
+server-sessions:
+ cookie-name: "_SESSION"
+ idle-timeout: 28807
+ absolute-timeout: 604801
+ timeout-resolution: 601
+ persistent-cookies: true
+ http-only-cookies: true
+ secure-cookies: "_env:SERVER_SESSION_COOKIES_SECURE:true"
+session-token-expiration: 28807
+session-token-encoding: HS256
user-defaults:
max-favourites: 12
diff --git a/ghci.sh b/ghci.sh
index 441f9f649..ab5479c78 100755
--- a/ghci.sh
+++ b/ghci.sh
@@ -4,10 +4,16 @@ set -e
[ "${FLOCKER}" != "$0" ] && exec env FLOCKER="$0" flock -en .stack-work.lock "$0" "$@" || :
+export DETAILED_LOGGING=${DETAILED_LOGGING:-true}
+export LOG_ALL=${LOG_ALL:-false}
+export LOGLEVEL=${LOGLEVEL:-info}
+export DUMMY_LOGIN=${DUMMY_LOGIN:-true}
+export SERVER_SESSION_ACID_FALLBACK=${SERVER_SESSION_ACID_FALLBACK:-true}
+export SERVER_SESSION_COOKIES_SECURE=${SERVER_SESSION_COOKIES_SECURE:-false}
+export ALLOW_DEPRECATED=${ALLOW_DEPRECATED:-true}
+export RIBBON=${RIBBON:-${__HOST:-localhost}}
unset HOST
-export DETAILED_LOGGING=true
-export LOG_ALL=true
-export DUMMY_LOGIN=true
+
move-back() {
mv -v .stack-work .stack-work-ghci
diff --git a/package.yaml b/package.yaml
index c4616ebab..713a25260 100644
--- a/package.yaml
+++ b/package.yaml
@@ -61,6 +61,7 @@ dependencies:
- cryptoids
- cryptoids-class
- binary
+ - binary-instances
- cereal
- mtl
- esqueleto >=3.1.0
@@ -103,7 +104,9 @@ dependencies:
- postgresql-simple
- word24
- mmorph
- - clientsession
+ - serversession
+ - serversession-backend-acid-state
+ - acid-state
- monad-memo
- xss-sanitize
- text-metrics
diff --git a/src/Application.hs b/src/Application.hs
index 59007c31e..918495cc6 100644
--- a/src/Application.hs
+++ b/src/Application.hs
@@ -18,7 +18,7 @@ module Application
import Control.Monad.Logger (liftLoc, LoggingT(..), MonadLoggerIO(..))
import Database.Persist.Postgresql (createPostgresqlPool, pgConnStr,
- pgPoolSize, runSqlPool)
+ pgPoolSize, runSqlPool, ConnectionPool)
import Import hiding (cancel)
import Language.Haskell.TH.Syntax (qLocation)
import Network.Wai (Middleware)
@@ -88,6 +88,9 @@ import qualified Data.Set as Set
import Handler.Utils.Routes (classifyHandler)
+import qualified Data.Acid.Memory as Acid
+import qualified Web.ServerSession.Backend.Acid as Acid
+
-- Import all relevant handler modules here.
-- (HPack takes care to add new modules to our cabal file nowadays.)
import Handler.News
@@ -125,7 +128,7 @@ mkYesodDispatch "UniWorX" resourcesUniWorX
-- performs initialization and returns a foundation datatype value. This is also
-- the place to put your migrate statements to have automatic database
-- migrations handled by Yesod.
-makeFoundation :: (MonadResource m, MonadUnliftIO m) => AppSettings -> m UniWorX
+makeFoundation :: (MonadResource m, MonadUnliftIO m, MonadThrow m) => AppSettings -> m UniWorX
makeFoundation appSettings'@AppSettings{..} = do
registerGHCMetrics
@@ -168,7 +171,7 @@ makeFoundation appSettings'@AppSettings{..} = do
-- logging function. To get out of this loop, we initially create a
-- temporary foundation without a real connection pool, get a log function
-- from there, and then create the real foundation.
- let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID = UniWorX {..}
+ let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID = UniWorX {..}
-- The UniWorX {..} syntax is an example of record wild cards. For more
-- information, see:
-- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html
@@ -177,7 +180,7 @@ makeFoundation appSettings'@AppSettings{..} = do
(error "smtpPool forced in tempFoundation")
(error "ldapPool forced in tempFoundation")
(error "cryptoIDKey forced in tempFoundation")
- (error "sessionKey forced in tempFoundation")
+ (error "sessionStore forced in tempFoundation")
(error "secretBoxKey forced in tempFoundation")
(error "widgetMemcached forced in tempFoundation")
(error "JSONWebKeySet forced in tempFoundation")
@@ -191,9 +194,9 @@ makeFoundation appSettings'@AppSettings{..} = do
$logDebugS "setup" "SMTP-Pool"
createSmtpPool c
- appWidgetMemcached <- for appWidgetMemcachedConf $ \c -> do
+ appWidgetMemcached <- for appWidgetMemcachedConf $ \WidgetMemcachedConf{ widgetMemcachedConf } -> do
$logDebugS "setup" "Widget-Memcached"
- createWidgetMemcached c
+ createMemcached widgetMemcachedConf
-- Create the database connection pool
$logDebugS "setup" "PostgreSQL-Pool"
@@ -215,17 +218,42 @@ makeFoundation appSettings'@AppSettings{..} = do
liftIO . exitWith $ ExitFailure 2
$logDebugS "setup" "Cluster-Config"
appCryptoIDKey <- clusterSetting (Proxy :: Proxy 'ClusterCryptoIDKey) `runSqlPool` sqlPool
- appSessionKey <- clusterSetting (Proxy :: Proxy 'ClusterClientSessionKey) `runSqlPool` sqlPool
appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `runSqlPool` sqlPool
appJSONWebKeySet <- clusterSetting (Proxy :: Proxy 'ClusterJSONWebKeySet) `runSqlPool` sqlPool
appClusterID <- clusterSetting (Proxy :: Proxy 'ClusterId) `runSqlPool` sqlPool
- let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID
+ appSessionStore <- mkSessionStore appSettings' sqlPool `runSqlPool` sqlPool
+
+ let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID
-- Return the foundation
$logDebugS "setup" "Done"
return foundation
+data SessionStoreException
+ = SessionStoreNotAvailable
+ deriving (Eq, Ord, Read, Show, Generic, Typeable)
+instance Exception SessionStoreException
+
+mkSessionStore :: forall m.
+ ( MonadIO m
+ , MonadLogger m
+ , MonadThrow m
+ , MonadResource m
+ )
+ => AppSettings -> ConnectionPool -> ReaderT SqlBackend m SomeSessionStorage
+mkSessionStore AppSettings{..} mcdSqlConnPool
+ | Just mcdConf@MemcachedConf{..} <- appSessionMemcachedConf = do
+ mcdSqlMemcachedKey <- clusterSetting (Proxy :: Proxy 'ClusterServerSessionKey)
+ $logDebugS "setup" "Session-Memcached"
+ mcdSqlMemcached <- createMemcached mcdConf
+ let mcdSqlMemcachedExpiration = memcachedExpiry
+ return $ _SessionStorageMemcachedSql # MemcachedSqlStorage{..}
+ | appServerSessionAcidFallback = liftIO $
+ review _SessionStorageAcid . Acid.AcidStorage <$> Acid.openMemoryState Acid.emptyState
+ | otherwise = throwM SessionStoreNotAvailable
+
+
clusterSetting :: forall key m p.
( MonadIO m
, ClusterSetting key
@@ -285,8 +313,8 @@ createSmtpPool SmtpConf{ smtpPool = ResourcePoolConf{..}, .. } = do
return conn
liftIO $ createPool (mkConnection >>= maybe return applyAuth smtpAuth) reapConnection poolStripes poolTimeout poolLimit
-createWidgetMemcached :: (MonadLogger m, MonadResource m) => WidgetMemcachedConf -> m Memcached.Connection
-createWidgetMemcached WidgetMemcachedConf{widgetMemcachedConnectInfo} = snd <$> allocate (Memcached.connect widgetMemcachedConnectInfo) Memcached.close
+createMemcached :: (MonadLogger m, MonadResource m) => MemcachedConf -> m Memcached.Connection
+createMemcached MemcachedConf{memcachedConnectInfo} = snd <$> allocate (Memcached.connect memcachedConnectInfo) Memcached.close
-- | Convert our foundation to a WAI Application by calling @toWaiAppPlain@ and
-- applying some additional middlewares.
diff --git a/src/Data/Aeson/Types/Instances.hs b/src/Data/Aeson/Types/Instances.hs
index 10d4c106e..66849e5a5 100644
--- a/src/Data/Aeson/Types/Instances.hs
+++ b/src/Data/Aeson/Types/Instances.hs
@@ -9,11 +9,6 @@ import ClassyPrelude
import Data.Aeson.Types (Parser, Value)
import Control.Monad.Catch
-import Data.Binary (Binary)
-
-import Data.HashMap.Strict.Instances ()
-import Data.Vector.Instances ()
-
import Model.Types.TH.JSON (derivePersistFieldJSON)
import Control.Monad.Fail
@@ -22,7 +17,5 @@ import Control.Monad.Fail
instance MonadThrow Parser where
throwM = fail . show
-instance Binary Value
-
derivePersistFieldJSON ''Value
diff --git a/src/Data/CaseInsensitive/Instances.hs b/src/Data/CaseInsensitive/Instances.hs
index 77f626338..6596fe47e 100644
--- a/src/Data/CaseInsensitive/Instances.hs
+++ b/src/Data/CaseInsensitive/Instances.hs
@@ -26,9 +26,6 @@ import qualified Database.Esqueleto as E
import Web.HttpApiData
-import Data.Binary (Binary)
-import qualified Data.Binary as Binary
-
import qualified Data.Csv as Csv
@@ -99,11 +96,6 @@ instance (CI.FoldCase s, PathMultiPiece s) => PathMultiPiece (CI s) where
fromPathMultiPiece = fmap CI.mk . fromPathMultiPiece
toPathMultiPiece = toPathMultiPiece . CI.original
-instance (CI.FoldCase s, Binary s) => Binary (CI s) where
- get = CI.mk <$> Binary.get
- put = Binary.put . CI.original
- putList = Binary.putList . map CI.original
-
instance Csv.ToField s => Csv.ToField (CI s) where
toField = Csv.toField . CI.original
diff --git a/src/Data/HashMap/Strict/Instances.hs b/src/Data/HashMap/Strict/Instances.hs
deleted file mode 100644
index daa36c68a..000000000
--- a/src/Data/HashMap/Strict/Instances.hs
+++ /dev/null
@@ -1,15 +0,0 @@
-{-# OPTIONS_GHC -fno-warn-orphans #-}
-
-module Data.HashMap.Strict.Instances
- (
- ) where
-
-import ClassyPrelude
-
-import Data.Binary (Binary(..))
-import qualified Data.HashMap.Strict as HashMap
-
-
-instance (Binary k, Binary v, Hashable k, Eq k) => Binary (HashMap k v) where
- put = put . HashMap.toList
- get = HashMap.fromList <$> get
diff --git a/src/Data/HashSet/Instances.hs b/src/Data/HashSet/Instances.hs
deleted file mode 100644
index 320ac8940..000000000
--- a/src/Data/HashSet/Instances.hs
+++ /dev/null
@@ -1,16 +0,0 @@
-{-# OPTIONS_GHC -fno-warn-orphans #-}
-
-module Data.HashSet.Instances
- (
- ) where
-
-import ClassyPrelude
-
-import qualified Data.HashSet as HashSet
-
-import Data.Binary (Binary(..))
-
-
-instance (Binary a, Hashable a, Eq a) => Binary (HashSet a) where
- get = HashSet.fromList <$> get
- put = put . HashSet.toList
diff --git a/src/Data/Time/Calendar/Instances.hs b/src/Data/Time/Calendar/Instances.hs
index d5dd127ed..15c77e94b 100644
--- a/src/Data/Time/Calendar/Instances.hs
+++ b/src/Data/Time/Calendar/Instances.hs
@@ -6,7 +6,6 @@ module Data.Time.Calendar.Instances
) where
import ClassyPrelude
-import Data.Binary (Binary)
import Data.Time.Calendar
@@ -14,7 +13,6 @@ import Data.Universe
deriving newtype instance Hashable Day
-deriving newtype instance Binary Day
deriving instance Ord DayOfWeek
instance Universe DayOfWeek where
diff --git a/src/Data/Time/Clock/Instances.hs b/src/Data/Time/Clock/Instances.hs
index 4b49cfa1f..2e410080e 100644
--- a/src/Data/Time/Clock/Instances.hs
+++ b/src/Data/Time/Clock/Instances.hs
@@ -9,9 +9,6 @@ import Database.Persist.Sql
import Data.Proxy
-import Data.Binary (Binary)
-import qualified Data.Binary as Binary
-
import Data.Time.Clock
import Data.Time.Calendar.Instances ()
import Web.PathPieces
@@ -44,10 +41,3 @@ instance Csv.ToField UTCTime where
instance Csv.FromField UTCTime where
parseField = iso8601ParseM <=< Csv.parseField
-
-
-instance Binary DiffTime where
- get = fromRational <$> Binary.get
- put = Binary.put . toRational
-
-instance Binary UTCTime
diff --git a/src/Data/Time/LocalTime/Instances.hs b/src/Data/Time/LocalTime/Instances.hs
index 39c0d70f0..210bc7f62 100644
--- a/src/Data/Time/LocalTime/Instances.hs
+++ b/src/Data/Time/LocalTime/Instances.hs
@@ -8,8 +8,6 @@ import ClassyPrelude
import Data.Time.LocalTime
-import Data.Binary (Binary)
-
import qualified Language.Haskell.TH.Syntax as TH
@@ -17,7 +15,6 @@ deriving instance Generic TimeOfDay
deriving instance Typeable TimeOfDay
instance Hashable TimeOfDay
-instance Binary TimeOfDay
deriving instance TH.Lift TimeZone
diff --git a/src/Data/Vector/Instances.hs b/src/Data/Vector/Instances.hs
deleted file mode 100644
index ecb64bd69..000000000
--- a/src/Data/Vector/Instances.hs
+++ /dev/null
@@ -1,17 +0,0 @@
-{-# OPTIONS_GHC -fno-warn-orphans #-}
-
-module Data.Vector.Instances
- (
- ) where
-
-import ClassyPrelude
-
-import qualified Data.Vector as Vector
-
-import Data.Binary (Binary)
-import qualified Data.Binary as Binary
-
-
-instance Binary a => Binary (Vector a) where
- get = Vector.fromList <$> Binary.get
- put = Binary.put . Vector.toList
diff --git a/src/Database/Persist/Class/Instances.hs b/src/Database/Persist/Class/Instances.hs
index a44e6071b..4a3a7208c 100644
--- a/src/Database/Persist/Class/Instances.hs
+++ b/src/Database/Persist/Class/Instances.hs
@@ -13,6 +13,7 @@ import Database.Persist.Sql
import Data.Binary (Binary)
import qualified Data.Binary as Binary
+import Data.Binary.Instances ()
import qualified Data.Map as Map
diff --git a/src/Database/Persist/Types/Instances.hs b/src/Database/Persist/Types/Instances.hs
index 0929a2886..e7309b6cc 100644
--- a/src/Database/Persist/Types/Instances.hs
+++ b/src/Database/Persist/Types/Instances.hs
@@ -11,6 +11,7 @@ import Database.Persist.Types
import Data.Time.Calendar.Instances ()
import Data.Time.LocalTime.Instances ()
import Data.Time.Clock.Instances ()
+import Data.Binary.Instances ()
import Data.Binary (Binary)
diff --git a/src/Foundation.hs b/src/Foundation.hs
index ff2328d83..41fa7917b 100644
--- a/src/Foundation.hs
+++ b/src/Foundation.hs
@@ -97,6 +97,9 @@ import qualified Ldap.Client as Ldap
import UnliftIO.Pool
+import qualified Web.ServerSession.Core as ServerSession
+import qualified Web.ServerSession.Frontend.Yesod.Jwt as JwtSession
+
-- | Convenient Type Synonyms:
type DB = YesodDB UniWorX
type Form x = Html -> MForm (HandlerFor UniWorX) (FormResult x, Widget)
@@ -298,7 +301,7 @@ trueAP = APPure . const . const . const $ trueAR <$> ask
falseAP = APPure . const . const . const $ falseAR <$> ask -- included for completeness
-askTokenUnsafe :: forall m.
+askBearerUnsafe :: forall m.
( MonadHandler m
, HandlerSite m ~ UniWorX
, MonadCatch m
@@ -307,23 +310,23 @@ askTokenUnsafe :: forall m.
-- | This performs /no/ meaningful validation of the `BearerToken`
--
-- Use `Handler.Utils.Tokens.requireBearerToken` or `Handler.Utils.Tokens.maybeBearerToken` instead
-askTokenUnsafe = $cachedHere $ do
- jwt <- maybeMExceptT (unauthorizedI MsgUnauthorizedNoToken) askJwt
- catch (decodeToken jwt) $ \case
+askBearerUnsafe = $cachedHere $ do
+ bearer <- maybeMExceptT (unauthorizedI MsgUnauthorizedNoToken) askBearer
+ catch (decodeBearer bearer) $ \case
BearerTokenExpired -> throwError =<< unauthorizedI MsgUnauthorizedTokenExpired
BearerTokenNotStarted -> throwError =<< unauthorizedI MsgUnauthorizedTokenNotStarted
other -> do
$logWarnS "AuthToken" $ tshow other
throwError =<< unauthorizedI MsgUnauthorizedTokenInvalid
-validateToken :: Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> BearerToken UniWorX -> DB AuthResult
-validateToken mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo validateToken' mAuthId' route' isWrite' token'
+validateBearer :: Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> BearerToken UniWorX -> DB AuthResult
+validateBearer mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo validateBearer' mAuthId' route' isWrite' token'
where
- validateToken' :: _ -> _ -> _ -> _ -> CachedMemoT (Maybe (AuthId UniWorX), Route UniWorX, Bool, BearerToken UniWorX) AuthResult DB AuthResult
- validateToken' mAuthId route isWrite BearerToken{..} = lift . exceptT return return $ do
- guardMExceptT (maybe True (HashSet.member route) tokenRoutes) (unauthorizedI MsgUnauthorizedTokenInvalidRoute)
+ validateBearer' :: _ -> _ -> _ -> _ -> CachedMemoT (Maybe (AuthId UniWorX), Route UniWorX, Bool, BearerToken UniWorX) AuthResult DB AuthResult
+ validateBearer' mAuthId route isWrite BearerToken{..} = lift . exceptT return return $ do
+ guardMExceptT (maybe True (HashSet.member route) bearerRoutes) (unauthorizedI MsgUnauthorizedTokenInvalidRoute)
- tokenAuthority' <- case tokenAuthority of
+ bearerAuthority' <- case bearerAuthority of
Left tVal
| JSON.Success groupName <- JSON.fromJSON tVal -> maybeT (throwError =<< unauthorizedI MsgUnauthorizedTokenInvalidAuthorityGroup) . hoist lift $ do
Entity _ UserGroupMember{..} <- MaybeT . getBy $ UniquePrimaryUserGroupMember groupName Active
@@ -331,8 +334,8 @@ validateToken mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo vali
| otherwise -> throwError =<< unauthorizedI MsgUnauthorizedTokenInvalidAuthorityValue
Right uid -> return uid
- User{userTokensIssuedAfter} <- maybeMExceptT (unauthorizedI MsgUnauthorizedTokenInvalidAuthority) $ get tokenAuthority'
- guardMExceptT (Just tokenIssuedAt >= userTokensIssuedAfter) (unauthorizedI MsgUnauthorizedTokenExpired)
+ User{userTokensIssuedAfter} <- maybeMExceptT (unauthorizedI MsgUnauthorizedTokenInvalidAuthority) $ get bearerAuthority'
+ guardMExceptT (Just bearerIssuedAt >= userTokensIssuedAfter) (unauthorizedI MsgUnauthorizedTokenExpired)
let
-- Prevent infinite loops
@@ -341,10 +344,10 @@ validateToken mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo vali
authorityVal <- do
dnf <- either throwM return $ routeAuthTags route
- fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) (noTokenAuth dnf) (Just tokenAuthority') route isWrite
+ fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) (noTokenAuth dnf) (Just bearerAuthority') route isWrite
guardExceptT (is _Authorized authorityVal) authorityVal
- whenIsJust tokenAddAuth $ \addDNF -> do
+ whenIsJust bearerAddAuth $ \addDNF -> do
$logDebugS "validateToken" $ tshow addDNF
additionalVal <- fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) (noTokenAuth addDNF) mAuthId route isWrite
guardExceptT (is _Authorized additionalVal) additionalVal
@@ -447,7 +450,7 @@ tagAccessPredicate AuthAllocationAdmin = APDB $ \mAuthId route _ -> case route o
guardMExceptT isEvaluation $ unauthorizedI MsgUnauthorizedAllocationAdmin
return Authorized
tagAccessPredicate AuthToken = APDB $ \mAuthId route isWrite -> exceptT return return $
- lift . validateToken mAuthId route isWrite =<< askTokenUnsafe
+ lift . validateBearer mAuthId route isWrite =<< askBearerUnsafe
tagAccessPredicate AuthNoEscalation = APDB $ \mAuthId route _ -> case route of
AdminHijackUserR cID -> $cachedHereBinary (mAuthId, cID) . exceptT return return $ do
myUid <- maybeExceptT AuthenticationRequired $ return mAuthId
@@ -1466,11 +1469,31 @@ instance Yesod UniWorX where
Nothing -> getApprootText guessApproot app req
Just root -> root
- -- Store session data on the client in encrypted cookies,
- -- default session idle timeout is 120 minutes
- makeSessionBackend app = do
- (getCachedDate, _) <- clientSessionDateCacher (app ^. _appSessionTimeout)
- return . Just $ clientSessionBackend (app ^. _appSessionKey) getCachedDate
+ makeSessionBackend app@UniWorX{ appSettings' = AppSettings{..}, ..} = case appSessionStore of
+ SessionStorageMemcachedSql sqlStore
+ -> mkBackend =<< stateSettings <$> ServerSession.createState sqlStore
+ SessionStorageAcid acidStore
+ | appServerSessionAcidFallback
+ -> mkBackend =<< stateSettings <$> ServerSession.createState acidStore
+ _other
+ -> return Nothing
+ where
+ cfg = JwtSession.ServerSessionJwtConfig
+ { sJwtJwkSet = appJSONWebKeySet
+ , sJwtStart = Nothing
+ , sJwtExpiration = appSessionTokenExpiration
+ , sJwtEncoding = appSessionTokenEncoding
+ , sJwtIssueBy = appInstanceID
+ , sJwtIssueFor = appClusterID
+ }
+ mkBackend :: forall sto.
+ ( ServerSession.SessionData sto ~ Map Text ByteString
+ , ServerSession.Storage sto
+ )
+ => ServerSession.State sto -> IO (Maybe SessionBackend)
+ mkBackend = JwtSession.backend cfg (JwtSession.siteApproot app)
+ stateSettings :: forall sto. ServerSession.State sto -> ServerSession.State sto
+ stateSettings = applyServerSessionSettings appServerSessionConfig
maximumContentLength app _ = app ^. _appMaximumContentLength
@@ -1567,8 +1590,8 @@ instance Yesod UniWorX where
addStaticContent ext _mime content = do
UniWorX{appWidgetMemcached, appSettings'} <- getYesod
- for ((,) <$> appWidgetMemcached <*> appWidgetMemcachedConf appSettings') $ \(mConn, WidgetMemcachedConf{ widgetMemcachedConnectInfo = _, .. }) -> do
- let expiry = (maybe 0 ceiling widgetMemcachedExpiry)
+ for ((,) <$> appWidgetMemcached <*> appWidgetMemcachedConf appSettings') $ \(mConn, WidgetMemcachedConf{ widgetMemcachedConf = MemcachedConf { memcachedExpiry }, widgetMemcachedBaseUrl }) -> do
+ let expiry = maybe 0 ceiling memcachedExpiry
touch = liftIO $ Memcached.touch expiry (encodeUtf8 $ pack fileName) mConn
add = liftIO $ Memcached.add zeroBits expiry (encodeUtf8 $ pack fileName) content mConn
absoluteLink = unpack widgetMemcachedBaseUrl > fileName
diff --git a/src/Foundation/Type.hs b/src/Foundation/Type.hs
index f6292c4f9..fc8c802ae 100644
--- a/src/Foundation/Type.hs
+++ b/src/Foundation/Type.hs
@@ -1,14 +1,14 @@
module Foundation.Type
( UniWorX(..)
+ , SomeSessionStorage(..)
+ , _SessionStorageMemcachedSql, _SessionStorageAcid
, SMTPPool
- , _appSettings', _appStatic, _appConnPool, _appSmtpPool, _appLdapPool, _appWidgetMemcached, _appHttpManager, _appLogger, _appLogSettings, _appCryptoIDKey, _appClusterID, _appInstanceID, _appJobState, _appSessionKey, _appSecretBoxKey, _appJSONWebKeySet, _appHealthReport
+ , _appSettings', _appStatic, _appConnPool, _appSmtpPool, _appLdapPool, _appWidgetMemcached, _appHttpManager, _appLogger, _appLogSettings, _appCryptoIDKey, _appClusterID, _appInstanceID, _appJobState, _appSessionStore, _appSecretBoxKey, _appJSONWebKeySet, _appHealthReport
) where
import Import.NoFoundation
import Database.Persist.Sql (ConnectionPool)
-import qualified Web.ClientSession as ClientSession
-
import Jobs.Types
import Yesod.Core.Types (Logger)
@@ -21,33 +21,41 @@ import qualified Database.Memcached.Binary.IO as Memcached
type SMTPPool = Pool SMTPConnection
+data SomeSessionStorage
+ = SessionStorageMemcachedSql { sessionStorageMemcachedSql :: MemcachedSqlStorage SessionMap }
+ | SessionStorageAcid { sessionStorageAcid :: AcidStorage SessionMap }
+
+makePrisms ''SomeSessionStorage
+
-- | The foundation datatype for your application. This can be a good place to
-- keep settings and values requiring initialization before your application
-- starts running, such as database connections. Every handler will have
-- access to the data present here.
data UniWorX = UniWorX
- { appSettings' :: AppSettings
- , appStatic :: EmbeddedStatic -- ^ Settings for static file serving.
- , appConnPool :: ConnectionPool -- ^ Database connection pool.
- , appSmtpPool :: Maybe SMTPPool
- , appLdapPool :: Maybe LdapPool
- , appWidgetMemcached :: Maybe Memcached.Connection -- ^ Actually a proper pool
- , appHttpManager :: Manager
- , appLogger :: (ReleaseKey, TVar Logger)
- , appLogSettings :: TVar LogSettings
- , appCryptoIDKey :: CryptoIDKey
- , appClusterID :: ClusterId
- , appInstanceID :: InstanceId
- , appJobState :: TMVar JobState
- , appSessionKey :: ClientSession.Key
- , appSecretBoxKey :: SecretBox.Key
- , appJSONWebKeySet :: Jose.JwkSet
- , appHealthReport :: TVar (Set (UTCTime, HealthReport))
+ { appSettings' :: AppSettings
+ , appStatic :: EmbeddedStatic -- ^ Settings for static file serving.
+ , appConnPool :: ConnectionPool -- ^ Database connection pool.
+ , appSmtpPool :: Maybe SMTPPool
+ , appLdapPool :: Maybe LdapPool
+ , appWidgetMemcached :: Maybe Memcached.Connection -- ^ Actually a proper pool
+ , appHttpManager :: Manager
+ , appLogger :: (ReleaseKey, TVar Logger)
+ , appLogSettings :: TVar LogSettings
+ , appCryptoIDKey :: CryptoIDKey
+ , appClusterID :: ClusterId
+ , appInstanceID :: InstanceId
+ , appJobState :: TMVar JobState
+ , appSessionStore :: SomeSessionStorage
+ , appSecretBoxKey :: SecretBox.Key
+ , appJSONWebKeySet :: Jose.JwkSet
+ , appHealthReport :: TVar (Set (UTCTime, HealthReport))
}
makeLenses_ ''UniWorX
instance HasInstanceID UniWorX InstanceId where
instanceID = _appInstanceID
+instance HasClusterID UniWorX ClusterId where
+ clusterID = _appClusterID
instance HasJSONWebKeySet UniWorX Jose.JwkSet where
jsonWebKeySet = _appJSONWebKeySet
instance HasHttpManager UniWorX Manager where
diff --git a/src/Handler/Metrics.hs b/src/Handler/Metrics.hs
index b9a7ceb7f..e49a57210 100644
--- a/src/Handler/Metrics.hs
+++ b/src/Handler/Metrics.hs
@@ -23,11 +23,11 @@ getMetricsR = selectRep $ do
metricsHtml = do
samples <- collectMetrics
- metricsToken <- runMaybeT . hoist runDB $ do
+ metricsBearer <- runMaybeT . hoist runDB $ do
uid <- MaybeT maybeAuthId
guardM . lift . existsBy $ UniqueUserGroupMember UserGroupMetrics uid
- encodeToken =<< bearerToken (Left $ toJSON UserGroupMetrics) (Just $ HashSet.singleton MetricsR) Nothing (Just Nothing) Nothing
+ encodeBearer =<< bearerToken (Left $ toJSON UserGroupMetrics) (Just $ HashSet.singleton MetricsR) Nothing (Just Nothing) Nothing
defaultLayout $ do
setTitleI MsgTitleMetrics
diff --git a/src/Handler/Profile.hs b/src/Handler/Profile.hs
index b6cce8cf6..2b605d3db 100644
--- a/src/Handler/Profile.hs
+++ b/src/Handler/Profile.hs
@@ -766,8 +766,8 @@ postUserNotificationR cID = do
uid <- decrypt cID
User{userNotificationSettings, userDisplayName} <- runDB $ get404 uid
- ((nsRes, nsInnerWdgt), nsEnc) <- runFormPost . formEmbedJwtPost . renderAForm FormStandard . notificationForm $ Just userNotificationSettings
- mJwt <- askJwt
+ ((nsRes, nsInnerWdgt), nsEnc) <- runFormPost . formEmbedBearerPost . renderAForm FormStandard . notificationForm $ Just userNotificationSettings
+ mBearer <- askBearer
isModal <- hasCustomHeader HeaderIsModal
let formWidget = wrapForm nsInnerWdgt def
{ formAction = Just . SomeRoute $ UserNotificationR cID
@@ -775,7 +775,7 @@ postUserNotificationR cID = do
, formAttrs = [ asyncSubmitAttr | isModal ]
}
- formResultModal nsRes (UserNotificationR cID, [ (toPathPiece GetBearer, toPathPiece jwt) | Just jwt <- pure mJwt ]) $ \ns -> do
+ formResultModal nsRes (UserNotificationR cID, [ (toPathPiece GetBearer, toPathPiece bearer) | Just bearer <- pure mBearer ]) $ \ns -> do
lift . runDB $ update uid [ UserNotificationSettings =. ns ]
tell . pure =<< messageI Success MsgNotificationSettingsUpdate
@@ -800,12 +800,12 @@ getSetDisplayEmailR, postSetDisplayEmailR :: Handler Html
getSetDisplayEmailR = postSetDisplayEmailR
postSetDisplayEmailR = do
uid <- requireAuthId
- mDisplayEmail <- requireCurrentTokenRestrictions
+ mDisplayEmail <- requireCurrentBearerRestrictions
case mDisplayEmail of
Nothing -> invalidArgs ["Bearer token required"]
Just displayEmail -> do
- ((btnRes, btnView), btnEnc) <- runFormPost $ formEmbedJwtPost buttonForm
+ ((btnRes, btnView), btnEnc) <- runFormPost $ formEmbedBearerPost buttonForm
let btnView' = wrapForm btnView def
{ formSubmit = FormNoSubmit
, formAction = Just $ SomeRoute SetDisplayEmailR
diff --git a/src/Handler/Users.hs b/src/Handler/Users.hs
index 11b6483f7..cec5533b6 100644
--- a/src/Handler/Users.hs
+++ b/src/Handler/Users.hs
@@ -476,13 +476,13 @@ postUserPasswordR cID = do
[ False <$ guard (isn't _AuthPWHash userAuthentication)
, False <$ guard isAdmin
, do
- authMode <- Base64.decodeLenient . encodeUtf8 <$> MaybeT maybeCurrentTokenRestrictions
+ authMode <- Base64.decodeLenient . encodeUtf8 <$> MaybeT maybeCurrentBearerRestrictions
unless (authMode `constEq` computeUserAuthenticationDigest userAuthentication) . lift $
invalidArgsI [MsgUnauthorizedPasswordResetToken]
return False
]
- ((passResult, passFormWidget), passEnctype) <- runFormPost . formEmbedJwtPost . renderAForm FormStandard . wFormToAForm $ do
+ ((passResult, passFormWidget), passEnctype) <- runFormPost . formEmbedBearerPost . renderAForm FormStandard . wFormToAForm $ do
currentResult <- if
| AuthPWHash (encodeUtf8 -> pwHash) <- userAuthentication
, requireCurrent
diff --git a/src/Handler/Utils/Invitations.hs b/src/Handler/Utils/Invitations.hs
index 2da19bd90..637aeb25a 100644
--- a/src/Handler/Utils/Invitations.hs
+++ b/src/Handler/Utils/Invitations.hs
@@ -218,9 +218,9 @@ sinkInvitations InvitationConfig{..} = determineExists .| sinkInvitations'
route <- mapReaderT liftHandler $ invitationRoute fEnt dat
InvitationTokenConfig{..} <- mapReaderT liftHandler $ invitationTokenConfig fEnt dat
protoToken <- bearerToken itAuthority (Just . HashSet.singleton $ urlRoute route) itAddAuth itExpiresAt itStartsAt
- let token = protoToken & tokenRestrict (urlRoute route) (InvitationTokenRestriction jInvitee $ dat ^. _invitationTokenData)
- jwt <- encodeToken token
- jInvitationUrl <- toTextUrl (route, [(toPathPiece GetBearer, toPathPiece jwt)])
+ let token = protoToken & bearerRestrict (urlRoute route) (InvitationTokenRestriction jInvitee $ dat ^. _invitationTokenData)
+ bearer <- encodeBearer token
+ jInvitationUrl <- toTextUrl (route, [(toPathPiece GetBearer, toPathPiece bearer)])
jInvitationSubject <- fmap mr . mapReaderT liftHandler $ invitationSubject fEnt dat
jInvitationExplanation <- (\ihtml -> ihtml (toHtml . mr) ur) <$> mapReaderT liftHandler (invitationExplanation fEnt dat)
@@ -228,7 +228,7 @@ sinkInvitations InvitationConfig{..} = determineExists .| sinkInvitations'
{ invitationEmail = jInvitee
, invitationFor = invRef @junction fid
, invitationData = toJSON $ dat ^. _invitationDBData
- , invitationExpiresAt = tokenExpiresAt token
+ , invitationExpiresAt = bearerExpiresAt token
}
queueDBJob JobInvitation{..}
@@ -362,7 +362,7 @@ invitationR' :: forall junction m.
-> m Html
-- | Generic handler for incoming invitations
invitationR' InvitationConfig{..} = liftHandler $ do
- InvitationTokenRestriction{..} <- maybeM (invalidArgsI [MsgInvitationMissingRestrictions]) return requireCurrentTokenRestrictions :: Handler (InvitationTokenRestriction junction)
+ InvitationTokenRestriction{..} <- maybeM (invalidArgsI [MsgInvitationMissingRestrictions]) return requireCurrentBearerRestrictions :: Handler (InvitationTokenRestriction junction)
invitee <- requireAuthId
cRoute <- fromMaybe (error "invitationR' called from 404-handler") <$> getCurrentRoute
@@ -379,7 +379,7 @@ invitationR' InvitationConfig{..} = liftHandler $ do
iData :: InvitationData junction
iData = review _InvitationData (dbData, itData)
guardAuthResult =<< hoist lift (invitationRestriction fEnt iData)
- ((dataRes, dataWidget), dataEnctype) <- hoist lift . runFormPost . formEmbedJwtPost . renderAForm FormStandard . wFormToAForm $ do
+ ((dataRes, dataWidget), dataEnctype) <- hoist lift . runFormPost . formEmbedBearerPost . renderAForm FormStandard . wFormToAForm $ do
dataRes <- aFormToWForm $ invitationForm fEnt iData invitee
btnRes <- aFormToWForm . disambiguateButtons $ combinedButtonField (BtnInviteAccept : [ BtnInviteDecline | is _Nothing $ ephemeralInvitation @junction ]) (fslI MsgInvitationAction & bool id (setTooltip MsgInvitationActionTip) (is _Nothing $ ephemeralInvitation @junction))
case btnRes of
diff --git a/src/Handler/Utils/Tokens.hs b/src/Handler/Utils/Tokens.hs
index e7d831cba..83266119f 100644
--- a/src/Handler/Utils/Tokens.hs
+++ b/src/Handler/Utils/Tokens.hs
@@ -1,6 +1,6 @@
module Handler.Utils.Tokens
( maybeBearerToken, requireBearerToken
- , maybeCurrentTokenRestrictions, requireCurrentTokenRestrictions
+ , maybeCurrentBearerRestrictions, requireCurrentBearerRestrictions
) where
import Import
@@ -16,32 +16,32 @@ maybeBearerToken = runMaybeT $ catchIfMaybeT cPred requireBearerToken
requireBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX) => m (BearerToken UniWorX)
requireBearerToken = liftHandler $ do
- token <- exceptT (guardAuthResult >=> error "askToken should not throw `Authorized`") return askTokenUnsafe
+ bearer <- exceptT (guardAuthResult >=> error "askToken should not throw `Authorized`") return askBearerUnsafe
mAuthId <- maybeAuthId
currentRoute <- maybe (permissionDeniedI MsgUnauthorizedToken404) return =<< getCurrentRoute
isWrite <- isWriteRequest currentRoute
- guardAuthResult <=< runDB $ validateToken mAuthId currentRoute isWrite token
- return token
+ guardAuthResult <=< runDB $ validateBearer mAuthId currentRoute isWrite bearer
+ return bearer
-requireCurrentTokenRestrictions :: ( MonadHandler m
+requireCurrentBearerRestrictions :: ( MonadHandler m
, HandlerSite m ~ UniWorX
, FromJSON a
, ToJSON a
)
=> m (Maybe a)
-requireCurrentTokenRestrictions = runMaybeT $ do
- token <- requireBearerToken
+requireCurrentBearerRestrictions = runMaybeT $ do
+ bearer <- requireBearerToken
route <- MaybeT getCurrentRoute
- hoistMaybe $ token ^? _tokenRestrictionIx route
+ hoistMaybe $ bearer ^? _bearerRestrictionIx route
-maybeCurrentTokenRestrictions :: ( MonadHandler m
+maybeCurrentBearerRestrictions :: ( MonadHandler m
, HandlerSite m ~ UniWorX
, MonadCatch m
, FromJSON a
, ToJSON a
)
=> m (Maybe a)
-maybeCurrentTokenRestrictions = runMaybeT $ do
- token <- MaybeT maybeBearerToken
+maybeCurrentBearerRestrictions = runMaybeT $ do
+ bearer <- MaybeT maybeBearerToken
route <- MaybeT getCurrentRoute
- hoistMaybe $ token ^? _tokenRestrictionIx route
+ hoistMaybe $ bearer ^? _bearerRestrictionIx route
diff --git a/src/Import/NoFoundation.hs b/src/Import/NoFoundation.hs
index c2f083f37..7212ff285 100644
--- a/src/Import/NoFoundation.hs
+++ b/src/Import/NoFoundation.hs
@@ -19,3 +19,6 @@ import Settings.WellKnownFiles as Import
import CryptoID as Import
import Audit as Import
+
+import Web.ServerSession.Backend.Persistent.Memcached as Import
+import Web.ServerSession.Backend.Acid as Import (AcidStorage(..))
diff --git a/src/Import/NoModel.hs b/src/Import/NoModel.hs
index 1a2c5eaff..723c699db 100644
--- a/src/Import/NoModel.hs
+++ b/src/Import/NoModel.hs
@@ -61,6 +61,7 @@ import Data.Text.Encoding.Error as Import(UnicodeException(..))
import Data.Semigroup as Import (Min(..), Max(..))
import Data.Monoid as Import (Last(..), First(..), Any(..), All(..), Sum(..), Endo(..), Alt(..))
import Data.Binary as Import (Binary)
+import Data.Binary.Instances as Import ()
import System.FilePath as Import hiding (joinPath, normalise, isValid, makeValid)
@@ -125,9 +126,6 @@ import Data.Sum.Instances as Import ()
import Data.Fixed.Instances as Import ()
import Data.Scientific.Instances as Import ()
import Data.Set.Instances as Import ()
-import Data.HashMap.Strict.Instances as Import ()
-import Data.HashSet.Instances as Import ()
-import Data.Vector.Instances as Import ()
import Data.Time.Clock.Instances as Import ()
import Data.Time.LocalTime.Instances as Import ()
import Data.Time.Calendar.Instances as Import ()
diff --git a/src/Jobs/Handler/ChangeUserDisplayEmail.hs b/src/Jobs/Handler/ChangeUserDisplayEmail.hs
index 1e2627b84..ff48ed9a2 100644
--- a/src/Jobs/Handler/ChangeUserDisplayEmail.hs
+++ b/src/Jobs/Handler/ChangeUserDisplayEmail.hs
@@ -12,8 +12,8 @@ import Text.Hamlet
dispatchJobChangeUserDisplayEmail :: UserId -> UserEmail -> Handler ()
dispatchJobChangeUserDisplayEmail jUser jDisplayEmail = do
- token <- tokenRestrict SetDisplayEmailR jDisplayEmail <$> bearerToken (Right jUser) (Just $ HashSet.singleton SetDisplayEmailR) Nothing Nothing Nothing
- jwt <- encodeToken token
+ bearer <- bearerRestrict SetDisplayEmailR jDisplayEmail <$> bearerToken (Right jUser) (Just $ HashSet.singleton SetDisplayEmailR) Nothing Nothing Nothing
+ jwt <- encodeBearer bearer
let
setDisplayEmailUrl :: SomeRoute UniWorX
setDisplayEmailUrl = SomeRoute (SetDisplayEmailR, [(toPathPiece GetBearer, toPathPiece jwt)])
diff --git a/src/Jobs/Handler/SendNotification/Utils.hs b/src/Jobs/Handler/SendNotification/Utils.hs
index 111b43382..4b894286e 100644
--- a/src/Jobs/Handler/SendNotification/Utils.hs
+++ b/src/Jobs/Handler/SendNotification/Utils.hs
@@ -16,7 +16,7 @@ ihamletSomeMessage f trans = f $ trans . SomeMessage
mkEditNotifications :: (MonadHandler m, HandlerSite m ~ UniWorX) => UserId -> m (HtmlUrlI18n UniWorXMessage (Route UniWorX))
mkEditNotifications uid = liftHandler $ do
cID <- encrypt uid
- jwt <- encodeToken =<< bearerToken (Right uid) (Just . HashSet.singleton $ UserNotificationR cID) Nothing Nothing Nothing
+ jwt <- encodeBearer =<< bearerToken (Right uid) (Just . HashSet.singleton $ UserNotificationR cID) Nothing Nothing Nothing
let
editNotificationsUrl :: SomeRoute UniWorX
editNotificationsUrl = SomeRoute (UserNotificationR cID, [(toPathPiece GetBearer, toPathPiece jwt)])
diff --git a/src/Jobs/Handler/SendPasswordReset.hs b/src/Jobs/Handler/SendPasswordReset.hs
index ed1f10a6b..d61934db6 100644
--- a/src/Jobs/Handler/SendPasswordReset.hs
+++ b/src/Jobs/Handler/SendPasswordReset.hs
@@ -29,11 +29,11 @@ dispatchJobSendPasswordReset jRecipient = userMailT jRecipient $ do
LTUUnique utc' _ -> utc'
_other -> UTCTime (addDays 2 $ utctDay now) 0
- resetToken' <- bearerToken (Right jRecipient) (Just . HashSet.singleton $ UserPasswordR cID) Nothing (Just $ Just tomorrowEndOfDay) Nothing
- let resetToken = resetToken'
- & tokenRestrict (UserPasswordR cID) (decodeUtf8 . Base64.encode . BA.convert $ computeUserAuthenticationDigest userAuthentication)
- encodedToken <- encodeToken resetToken
+ resetBearer' <- bearerToken (Right jRecipient) (Just . HashSet.singleton $ UserPasswordR cID) Nothing (Just $ Just tomorrowEndOfDay) Nothing
+ let resetBearer = resetBearer'
+ & bearerRestrict (UserPasswordR cID) (decodeUtf8 . Base64.encode . BA.convert $ computeUserAuthenticationDigest userAuthentication)
+ encodedBearer <- encodeBearer resetBearer
- resetUrl <- toTextUrl (UserPasswordR cID, [(toPathPiece GetBearer, toPathPiece encodedToken)])
+ resetUrl <- toTextUrl (UserPasswordR cID, [(toPathPiece GetBearer, toPathPiece encodedBearer)])
addHtmlMarkdownAlternatives ($(ihamletFile "templates/mail/passwordReset.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
diff --git a/src/Jobs/HealthReport.hs b/src/Jobs/HealthReport.hs
index 97fc6e229..8707ea15a 100644
--- a/src/Jobs/HealthReport.hs
+++ b/src/Jobs/HealthReport.hs
@@ -43,10 +43,10 @@ dispatchHealthCheckMatchingClusterConfig
ourSetting <- getsYesod appCryptoIDKey
dbSetting <- clusterSetting @'ClusterCryptoIDKey
return $ ((==) `on` fmap (ByteArray.convert :: CryptoIDKey -> ByteString)) (Just ourSetting) dbSetting
- clusterSettingMatches ClusterClientSessionKey = do
- ourSetting <- getsYesod appSessionKey
- dbSetting <- clusterSetting @'ClusterClientSessionKey
- return $ Just ourSetting == dbSetting
+ clusterSettingMatches ClusterServerSessionKey = do
+ ourSetting <- getsYesod . preview $ _appSessionStore . _SessionStorageMemcachedSql . _mcdSqlMemcachedKey
+ dbSetting <- clusterSetting @'ClusterServerSessionKey
+ return $ maybe True ((== dbSetting) . Just) ourSetting
clusterSettingMatches ClusterSecretBoxKey = do
ourSetting <- getsYesod appSecretBoxKey
dbSetting <- clusterSetting @'ClusterSecretBoxKey
diff --git a/src/Model.hs b/src/Model.hs
index 48c5aa8db..d5a130d34 100644
--- a/src/Model.hs
+++ b/src/Model.hs
@@ -26,7 +26,7 @@ import Text.Blaze (ToMarkup(..))
-- You can find more information on persistent and how to declare entities
-- at:
-- http://www.yesodweb.com/book/persistent/
-share [mkPersist sqlSettings, mkDeleteCascade sqlSettings, mkMigrate "migrateAll'", mkSave "currentModel"]
+share [mkPersist sqlSettings, mkDeleteCascade sqlSettings, mkMigrate "migrateUniWorX", mkSave "currentModel"]
$(persistDirectoryWith lowerCaseSettings "models")
diff --git a/src/Model/Migration.hs b/src/Model/Migration.hs
index cc4a45502..85aca846e 100644
--- a/src/Model/Migration.hs
+++ b/src/Model/Migration.hs
@@ -34,6 +34,8 @@ import qualified Net.IPv6 as IPv6
import qualified Data.Char as Char
import qualified Data.CaseInsensitive as CI
+import Web.ServerSession.Backend.Persistent.Memcached (migrateMemcachedSqlStorage)
+
-- Database versions must follow https://pvp.haskell.org:
-- - Breaking changes are instances where manual migration is necessary (via customMigrations; i.e. changing a columns format)
-- - Non-breaking changes are instances where the automatic migration done by persistent is sufficient (i.e. adding a column or table)
@@ -63,6 +65,12 @@ share [mkPersist sqlSettings, mkMigrate "migrateDBVersioning"]
deriving Show Eq Ord
|]
+migrateAll' :: Migration
+migrateAll' = sequence_
+ [ migrateUniWorX
+ , migrateMemcachedSqlStorage
+ ]
+
migrateAll :: ( MonadLogger m
, MonadResource m
, MonadUnliftIO m
diff --git a/src/Model/Tokens.hs b/src/Model/Tokens.hs
index 11ed99a4b..3486f9eab 100644
--- a/src/Model/Tokens.hs
+++ b/src/Model/Tokens.hs
@@ -1,150 +1,7 @@
-{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-}
-{-# LANGUAGE UndecidableInstances #-}
-
module Model.Tokens
- ( BearerToken(..)
- , _tokenIdentifier, _tokenAuthority, _tokenRoutes, _tokenAddAuth, _tokenRestrictions, _tokenRestrictionIx, _tokenRestrictionAt, _tokenIssuedAt, _tokenIssuedBy, _tokenExpiresAt, _tokenStartsAt
- , tokenRestrict
- , tokenToJSON, tokenParseJSON
+ ( module Model.Tokens
) where
-import ClassyPrelude.Yesod
-import Yesod.Core.Instances ()
-
-import Model
-import Utils (assertM')
-import Utils.Lens hiding ((.=))
-import Data.Aeson.Lens (AsJSON(..))
-
-import Yesod.Auth (AuthId)
-
-import Jose.Jwt (IntDate(..))
-import qualified Jose.Jwt as Jose
-
-import Jose.Jwt.Instances ()
-import Data.Aeson.Types.Instances ()
-
-import qualified Data.HashMap.Strict as HashMap
-import Data.HashMap.Strict.Instances ()
-import Data.HashSet.Instances ()
-import Data.Time.Clock.Instances ()
-
-import Data.Aeson.Types (Parser, (.:?), (.!=))
-import qualified Data.Aeson as JSON
-import qualified Data.Aeson.Types as JSON
-
-import CryptoID
-
-import Data.Time.Clock.POSIX
-
-import Data.Binary (Binary)
-
-import qualified Data.CryptoID.Class.ImplicitNamespace as I
-
-
-
--- | Presenting a `BearerToken` transfers some authorisation from `tokenAuthority` to /whoever/ presents the token
-data BearerToken site = BearerToken
- { tokenIdentifier :: TokenId
- -- ^ Unique identifier for each token; maybe useful for tracing usage of tokens
- , tokenAuthority :: Either Value (AuthId site)
- -- ^ Tokens only grant rights the `tokenAuthority` has (i.e. `AuthTag`s are evaluated with the user set to `tokenAuthority`)
- , tokenRoutes :: Maybe (HashSet (Route site))
- -- ^ Tokens can optionally be restricted to only be usable on a subset of routes
- , tokenAddAuth :: Maybe AuthDNF
- -- ^ Tokens can specify an additional predicate logic formula of `AuthTag`s that needs to evaluate to `Authorized` in order for the token to be valid.
- , tokenRestrictions :: HashMap (Route site) Value
- -- ^ Tokens can be restricted to certain actions within the context of a route (i.e. creating an user only with a certain username, resetting a password only if the old matches a hash, ...)
- --
- -- In general this is not encrypted; some care is required to not expose sensitive information to the bearer of the token
- , tokenIssuedAt :: UTCTime
- , tokenIssuedBy :: InstanceId
- , tokenExpiresAt
- , tokenStartsAt :: Maybe UTCTime
- } deriving (Generic, Typeable)
-
-deriving instance (Eq (AuthId site), Eq (Route site)) => Eq (BearerToken site)
-deriving instance (Read (AuthId site), Eq (Route site), Hashable (Route site), Read (Route site)) => Read (BearerToken site)
-deriving instance (Show (AuthId site), Show (Route site)) => Show (BearerToken site)
-
-instance (Binary (AuthId site), Binary (Route site), Hashable (Route site), Eq (Route site)) => Binary (BearerToken site)
-
-makeLenses_ ''BearerToken
-
-_tokenRestrictionIx :: (FromJSON a, ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> Traversal' (BearerToken site) a
--- ^ Focus a singular restriction (by route) if it exists
---
--- This /cannot/ be used to add restrictions, use `_tokenRestrictionAt` or `tokenRestrict` instead
-_tokenRestrictionIx route = _tokenRestrictions . ix route . _JSON
-
-_tokenRestrictionAt :: (FromJSON a, ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> Traversal' (BearerToken site) (Maybe a)
--- ^ Focus a singular restriction (by route) whether it exists, or not
-_tokenRestrictionAt route = _tokenRestrictions . at route . maybePrism _JSON
-
-tokenRestrict :: (ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> a -> BearerToken site -> BearerToken site
--- ^ Add a restriction to a `BearerToken`
---
--- If a restriction already exists for the targeted route, it's silently overwritten
-tokenRestrict route (toJSON -> resVal) = over _tokenRestrictions $ HashMap.insert route resVal
-
-
-
-tokenToJSON :: forall m.
- ( MonadHandler m
- , HasCryptoUUID (AuthId (HandlerSite m)) m
- , RenderRoute (HandlerSite m)
- ) => BearerToken (HandlerSite m) -> m Value
--- ^ Encode a `BearerToken` analogously to `toJSON`
---
--- Monadic context is needed because `AuthId`s are encrypted during encoding
-tokenToJSON BearerToken{..} = do
- cID <- either (return . Left) (fmap Right . I.encrypt) tokenAuthority :: m (Either Value (CryptoUUID (AuthId (HandlerSite m))))
- let stdPayload = Jose.JwtClaims
- { jwtIss = Just $ toPathPiece tokenIssuedBy
- , jwtSub = Nothing
- , jwtAud = Nothing
- , jwtExp = IntDate . utcTimeToPOSIXSeconds <$> tokenExpiresAt
- , jwtNbf = IntDate . utcTimeToPOSIXSeconds <$> tokenStartsAt
- , jwtIat = Just . IntDate $ utcTimeToPOSIXSeconds tokenIssuedAt
- , jwtJti = Just $ toPathPiece tokenIdentifier
- }
- return . JSON.object $
- catMaybes [ Just $ "authority" .= either id toJSON cID
- , ("routes" .=) <$> tokenRoutes
- , ("add-auth" .=) <$> tokenAddAuth
- , ("restrictions" .=) <$> assertM' (not . HashMap.null) tokenRestrictions
- ]
- ++ let JSON.Object hm = toJSON stdPayload in HashMap.toList hm
-
-tokenParseJSON :: forall site.
- ( HasCryptoUUID (AuthId site) (ReaderT CryptoIDKey Parser)
- , ParseRoute site
- , Hashable (Route site)
- )
- => Value
- -> ReaderT CryptoIDKey Parser (BearerToken site)
--- ^ Decode a `Value` to a `BearerToken` analogously to `parseJSON`
---
--- Monadic context is needed because `AuthId`s are encrypted during encoding
---
--- It's usually easier to use `Utils.Tokens.tokenParseJSON'`
-tokenParseJSON v@(Object o) = do
- tokenAuthority' <- lift $ (Right <$> o .: "authority") <|> (Left <$> o .: "authority") :: ReaderT CryptoIDKey Parser (Either Value (CryptoUUID (AuthId site)))
- tokenAuthority <- either (return . Left) (fmap Right . I.decrypt) tokenAuthority'
-
- tokenRoutes <- lift $ o .:? "routes"
- tokenAddAuth <- lift $ o .:? "add-auth"
- tokenRestrictions <- lift $ o .:? "restrictions" .!= HashMap.empty
- Jose.JwtClaims{..} <- lift $ parseJSON v
-
- let unIntDate (IntDate posix) = posixSecondsToUTCTime posix
-
- Just tokenIssuedBy <- return $ jwtIss >>= fromPathPiece
- Just tokenIdentifier <- return $ jwtJti >>= fromPathPiece
- Just tokenIssuedAt <- return $ unIntDate <$> jwtIat
- let tokenExpiresAt = unIntDate <$> jwtExp
- tokenStartsAt = unIntDate <$> jwtNbf
-
- return BearerToken{..}
-tokenParseJSON v = lift $ JSON.typeMismatch "BearerToken" v
-
+import Model.Tokens.Lens as Model.Tokens
+import Model.Tokens.Bearer as Model.Tokens
+import Model.Tokens.Session as Model.Tokens
diff --git a/src/Model/Tokens/Bearer.hs b/src/Model/Tokens/Bearer.hs
new file mode 100644
index 000000000..c1c4578fb
--- /dev/null
+++ b/src/Model/Tokens/Bearer.hs
@@ -0,0 +1,164 @@
+{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Model.Tokens.Bearer
+ ( BearerToken(..)
+ , _bearerIdentifier, _bearerAuthority, _bearerRoutes, _bearerAddAuth, _bearerRestrictions, _bearerRestrictionIx, _bearerRestrictionAt, _bearerIssuedAt, _bearerIssuedBy, _bearerExpiresAt, _bearerStartsAt
+ , bearerRestrict
+ , bearerToJSON, bearerParseJSON
+ ) where
+
+import ClassyPrelude.Yesod
+import Yesod.Core.Instances ()
+
+import Model
+import Model.Tokens.Lens
+import Utils (assertM')
+import Utils.Lens hiding ((.=))
+import Data.Aeson.Lens (AsJSON(..))
+
+import Yesod.Auth (AuthId)
+
+import Jose.Jwt (IntDate(..))
+import qualified Jose.Jwt as Jose
+
+import Jose.Jwt.Instances ()
+
+import qualified Data.HashMap.Strict as HashMap
+import Data.Time.Clock.Instances ()
+
+import Data.Aeson.Types (Parser, (.:?), (.!=))
+import qualified Data.Aeson as JSON
+import qualified Data.Aeson.Types as JSON
+
+import CryptoID
+
+import Data.Time.Clock.POSIX
+
+import Data.Binary (Binary)
+
+import qualified Data.CryptoID.Class.ImplicitNamespace as I
+
+
+
+-- | Presenting a `BearerToken` transfers some authorisation from `tokenAuthority` to /whoever/ presents the token
+data BearerToken site = BearerToken
+ { bearerIdentifier :: TokenId
+ -- ^ Unique identifier for each token; maybe useful for tracing usage of tokens
+ , bearerAuthority :: Either Value (AuthId site)
+ -- ^ Tokens only grant rights the `bearerAuthority` has (i.e. `AuthTag`s are evaluated with the user set to `bearerAuthority`)
+ , bearerRoutes :: Maybe (HashSet (Route site))
+ -- ^ Tokens can optionally be restricted to only be usable on a subset of routes
+ , bearerAddAuth :: Maybe AuthDNF
+ -- ^ Tokens can specify an additional predicate logic formula of `AuthTag`s that needs to evaluate to `Authorized` in order for the token to be valid.
+ , bearerRestrictions :: HashMap (Route site) Value
+ -- ^ Tokens can be restricted to certain actions within the context of a route (i.e. creating an user only with a certain username, resetting a password only if the old matches a hash, ...)
+ --
+ -- In general this is not encrypted; some care is required to not expose sensitive information to the bearer of the token
+ , bearerIssuedAt :: UTCTime
+ , bearerIssuedBy :: InstanceId
+ , bearerIssuedFor :: ClusterId
+ , bearerExpiresAt
+ , bearerStartsAt :: Maybe UTCTime
+ } deriving (Generic, Typeable)
+
+deriving instance (Eq (AuthId site), Eq (Route site)) => Eq (BearerToken site)
+deriving instance (Read (AuthId site), Eq (Route site), Hashable (Route site), Read (Route site)) => Read (BearerToken site)
+deriving instance (Show (AuthId site), Show (Route site)) => Show (BearerToken site)
+
+instance (Binary (AuthId site), Binary (Route site), Hashable (Route site), Eq (Route site)) => Binary (BearerToken site)
+
+makeLenses_ ''BearerToken
+instance HasTokenIdentifier (BearerToken site) TokenId where
+ _tokenIdentifier = _bearerIdentifier
+instance HasTokenIssuedBy (BearerToken site) InstanceId where
+ _tokenIssuedBy = _bearerIssuedBy
+instance HasTokenIssuedFor (BearerToken site) ClusterId where
+ _tokenIssuedFor = _bearerIssuedFor
+instance HasTokenIssuedAt (BearerToken site) UTCTime where
+ _tokenIssuedAt = _bearerIssuedAt
+instance HasTokenExpiresAt (BearerToken site) (Maybe UTCTime) where
+ _tokenExpiresAt = _bearerExpiresAt
+instance HasTokenStartsAt (BearerToken site) (Maybe UTCTime) where
+ _tokenStartsAt = _bearerStartsAt
+
+_bearerRestrictionIx :: (FromJSON a, ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> Traversal' (BearerToken site) a
+-- ^ Focus a singular restriction (by route) if it exists
+--
+-- This /cannot/ be used to add restrictions, use `_bearerRestrictionAt` or `bearerRestrict` instead
+_bearerRestrictionIx route = _bearerRestrictions . ix route . _JSON
+
+_bearerRestrictionAt :: (FromJSON a, ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> Traversal' (BearerToken site) (Maybe a)
+-- ^ Focus a singular restriction (by route) whether it exists, or not
+_bearerRestrictionAt route = _bearerRestrictions . at route . maybePrism _JSON
+
+bearerRestrict :: (ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> a -> BearerToken site -> BearerToken site
+-- ^ Add a restriction to a `BearerToken`
+--
+-- If a restriction already exists for the targeted route, it's silently overwritten
+bearerRestrict route (toJSON -> resVal) = over _bearerRestrictions $ HashMap.insert route resVal
+
+
+
+bearerToJSON :: forall m.
+ ( MonadHandler m
+ , HasCryptoUUID (AuthId (HandlerSite m)) m
+ , RenderRoute (HandlerSite m)
+ ) => BearerToken (HandlerSite m) -> m Value
+-- ^ Encode a `BearerToken` analogously to `toJSON`
+--
+-- Monadic context is needed because `AuthId`s are encrypted during encoding
+bearerToJSON BearerToken{..} = do
+ cID <- either (return . Left) (fmap Right . I.encrypt) bearerAuthority :: m (Either Value (CryptoUUID (AuthId (HandlerSite m))))
+ let stdPayload = Jose.JwtClaims
+ { jwtIss = Just $ toPathPiece bearerIssuedBy
+ , jwtSub = Nothing
+ , jwtAud = Just . pure $ toPathPiece bearerIssuedFor
+ , jwtExp = IntDate . utcTimeToPOSIXSeconds <$> bearerExpiresAt
+ , jwtNbf = IntDate . utcTimeToPOSIXSeconds <$> bearerStartsAt
+ , jwtIat = Just . IntDate $ utcTimeToPOSIXSeconds bearerIssuedAt
+ , jwtJti = Just $ toPathPiece bearerIdentifier
+ }
+ return . JSON.object $
+ catMaybes [ Just $ "authority" .= either id toJSON cID
+ , ("routes" .=) <$> bearerRoutes
+ , ("add-auth" .=) <$> bearerAddAuth
+ , ("restrictions" .=) <$> assertM' (not . HashMap.null) bearerRestrictions
+ ]
+ ++ let JSON.Object hm = toJSON stdPayload in HashMap.toList hm
+
+bearerParseJSON :: forall site.
+ ( HasCryptoUUID (AuthId site) (ReaderT CryptoIDKey Parser)
+ , ParseRoute site
+ , Hashable (Route site)
+ )
+ => Value
+ -> ReaderT CryptoIDKey Parser (BearerToken site)
+-- ^ Decode a `Value` to a `BearerToken` analogously to `parseJSON`
+--
+-- Monadic context is needed because `AuthId`s are encrypted during encoding
+--
+-- It's usually easier to use `Utils.Tokens.bearerParseJSON'`
+bearerParseJSON v@(Object o) = do
+ bearerAuthority' <- lift $ (Right <$> o .: "authority") <|> (Left <$> o .: "authority") :: ReaderT CryptoIDKey Parser (Either Value (CryptoUUID (AuthId site)))
+ bearerAuthority <- either (return . Left) (fmap Right . I.decrypt) bearerAuthority'
+
+ bearerRoutes <- lift $ o .:? "routes"
+ bearerAddAuth <- lift $ o .:? "add-auth"
+ bearerRestrictions <- lift $ o .:? "restrictions" .!= HashMap.empty
+ Jose.JwtClaims{..} <- lift $ parseJSON v
+
+ let unIntDate (IntDate posix) = posixSecondsToUTCTime posix
+
+ Just bearerIssuedBy <- return $ jwtIss >>= fromPathPiece
+ Just bearerIssuedFor <- return $ do
+ [aud] <- jwtAud
+ fromPathPiece aud
+ Just bearerIdentifier <- return $ jwtJti >>= fromPathPiece
+ Just bearerIssuedAt <- return $ unIntDate <$> jwtIat
+ let bearerExpiresAt = unIntDate <$> jwtExp
+ bearerStartsAt = unIntDate <$> jwtNbf
+
+ return BearerToken{..}
+bearerParseJSON v = lift $ JSON.typeMismatch "BearerToken" v
+
diff --git a/src/Model/Tokens/Lens.hs b/src/Model/Tokens/Lens.hs
new file mode 100644
index 000000000..2f2a95571
--- /dev/null
+++ b/src/Model/Tokens/Lens.hs
@@ -0,0 +1,24 @@
+module Model.Tokens.Lens
+ ( module Model.Tokens.Lens
+ ) where
+
+import Control.Lens
+
+
+class HasTokenIdentifier s a | s -> a where
+ _tokenIdentifier :: Lens' s a
+
+class HasTokenIssuedBy s a | s -> a where
+ _tokenIssuedBy :: Lens' s a
+
+class HasTokenIssuedFor s a | s -> a where
+ _tokenIssuedFor :: Lens' s a
+
+class HasTokenIssuedAt s a | s -> a where
+ _tokenIssuedAt :: Lens' s a
+
+class HasTokenExpiresAt s a | s -> a where
+ _tokenExpiresAt :: Lens' s a
+
+class HasTokenStartsAt s a | s -> a where
+ _tokenStartsAt :: Lens' s a
diff --git a/src/Model/Tokens/Session.hs b/src/Model/Tokens/Session.hs
new file mode 100644
index 000000000..4f0180491
--- /dev/null
+++ b/src/Model/Tokens/Session.hs
@@ -0,0 +1,77 @@
+module Model.Tokens.Session
+ ( SessionToken(..)
+ , _sessionIdentifier, _sessionId, _sessionIssuedBy, _sessionIssuedAt, _sessionExpiresAt, _sessionStartsAt
+ ) where
+
+import ClassyPrelude.Yesod
+
+import Model.Tokens.Lens
+import Model
+import Utils.Lens
+
+import Web.ServerSession.Core
+
+import Jose.Jwt (IntDate(..))
+import qualified Jose.Jwt as Jose
+
+import Data.Time.Clock.POSIX
+
+import Control.Monad.Fail
+
+
+data SessionToken sess = SessionToken
+ { sessionIdentifier :: TokenId
+ , sessionId :: SessionId sess
+ , sessionIssuedAt :: UTCTime
+ , sessionIssuedBy :: InstanceId
+ , sessionIssuedFor :: ClusterId
+ , sessionExpiresAt
+ , sessionStartsAt :: Maybe UTCTime
+ } deriving (Eq, Ord, Read, Show, Generic, Typeable)
+
+makeLenses_ ''SessionToken
+instance HasTokenIdentifier (SessionToken sess) TokenId where
+ _tokenIdentifier = _sessionIdentifier
+instance HasTokenIssuedBy (SessionToken sess) InstanceId where
+ _tokenIssuedBy = _sessionIssuedBy
+instance HasTokenIssuedFor (SessionToken sess) ClusterId where
+ _tokenIssuedFor = _sessionIssuedFor
+instance HasTokenIssuedAt (SessionToken sess) UTCTime where
+ _tokenIssuedAt = _sessionIssuedAt
+instance HasTokenExpiresAt (SessionToken sess) (Maybe UTCTime) where
+ _tokenExpiresAt = _sessionExpiresAt
+instance HasTokenStartsAt (SessionToken sess) (Maybe UTCTime) where
+ _tokenStartsAt = _sessionStartsAt
+
+instance ToJSON (SessionToken sess) where
+ toJSON SessionToken{..} = toJSON Jose.JwtClaims{..}
+ where jwtIss = Just $ toPathPiece sessionIssuedBy
+ jwtSub = Just $ toPathPiece sessionId
+ jwtAud = Just . pure $ toPathPiece sessionIssuedFor
+ jwtExp = IntDate . utcTimeToPOSIXSeconds <$> sessionExpiresAt
+ jwtNbf = IntDate . utcTimeToPOSIXSeconds <$> sessionStartsAt
+ jwtIat = Just . IntDate $ utcTimeToPOSIXSeconds sessionIssuedAt
+ jwtJti = Just $ toPathPiece sessionIdentifier
+
+instance FromJSON (SessionToken sess) where
+ parseJSON val = do
+ Jose.JwtClaims{..} <- parseJSON val
+
+ sessionIdentifier <- parseMaybe "sessionIdentfier" $
+ fromPathPiece =<< jwtIss
+ sessionId <- parseMaybe "sessionId" $
+ fromPathPiece =<< jwtSub
+ sessionIssuedAt <- parseMaybe "sessionIssuedAt" $
+ unIntDate <$> jwtIat
+ sessionIssuedBy <- parseMaybe "sessionIssuedBy" $
+ fromPathPiece =<< jwtIss
+ sessionIssuedFor <- parseMaybe "sessionIssuedFor" $ do
+ [aud] <- jwtAud
+ fromPathPiece aud
+ let sessionExpiresAt = unIntDate <$> jwtExp
+ sessionStartsAt = unIntDate <$> jwtNbf
+
+ return SessionToken{..}
+ where
+ parseMaybe errId = maybe (fail $ "Could not parse " <> errId) return
+ unIntDate (IntDate posix) = posixSecondsToUTCTime posix
diff --git a/src/Settings.hs b/src/Settings.hs
index 63238163b..db800ddd9 100644
--- a/src/Settings.hs
+++ b/src/Settings.hs
@@ -59,6 +59,10 @@ import Jose.Jwt (JwtEncoding(..))
import System.FilePath.Glob
import Handler.Utils.Submission.TH
+import qualified Web.ServerSession.Core as ServerSession
+
+import Text.Show (showParen, showString)
+
-- | Runtime settings to configure this application. These settings can be
-- loaded from various sources: defaults, environment variables, config files,
@@ -88,6 +92,13 @@ data AppSettings = AppSettings
, appIpFromHeader :: Bool
-- ^ Get the IP address from the header when logging. Useful when sitting
-- behind a reverse proxy.
+
+ , appServerSessionConfig :: ServerSessionSettings
+ , appServerSessionAcidFallback :: Bool
+ , appSessionMemcachedConf :: Maybe MemcachedConf
+ , appSessionTokenExpiration :: Maybe NominalDiffTime
+ , appSessionTokenEncoding :: JwtEncoding
+
, appMailFrom :: Address
, appMailObjectDomain :: Text
, appMailVerp :: VerpMode
@@ -101,8 +112,8 @@ data AppSettings = AppSettings
, appNotificationExpiration :: NominalDiffTime
, appSessionTimeout :: NominalDiffTime
, appMaximumContentLength :: Maybe Word64
- , appJwtExpiration :: Maybe NominalDiffTime
- , appJwtEncoding :: JwtEncoding
+ , appBearerExpiration :: Maybe NominalDiffTime
+ , appBearerEncoding :: JwtEncoding
, appHealthCheckInterval :: HealthCheck -> Maybe NominalDiffTime
, appHealthCheckDelayNotify :: Bool
@@ -145,7 +156,13 @@ data AppSettings = AppSettings
, appInitialInstanceID :: Maybe (Either FilePath UUID)
, appRibbon :: Maybe Text
- } deriving (Show)
+ } deriving Show
+
+newtype ServerSessionSettings
+ = ServerSessionSettings { applyServerSessionSettings :: forall a. ServerSession.State a -> ServerSession.State a }
+
+instance Show ServerSessionSettings where
+ showsPrec d _ = showParen (d > 10) $ showString "ServerSessionSettings _"
data LogSettings = LogSettings
{ logAll, logDetailed :: Bool
@@ -211,25 +228,34 @@ data SmtpConf = SmtpConf
} deriving (Show)
data WidgetMemcachedConf = WidgetMemcachedConf
- { widgetMemcachedConnectInfo :: Memcached.ConnectInfo
- , widgetMemcachedBaseUrl :: Text
- , widgetMemcachedExpiry :: Maybe NominalDiffTime
+ { widgetMemcachedConf :: MemcachedConf
+ , widgetMemcachedBaseUrl :: Text
+ } deriving (Show)
+
+data MemcachedConf = MemcachedConf
+ { memcachedConnectInfo :: Memcached.ConnectInfo
+ , memcachedExpiry :: Maybe NominalDiffTime
} deriving (Show)
instance FromJSON Memcached.Auth where
parseJSON = Aeson.withText "Auth" $ \(Text.breakOn "@" -> (encodeUtf8 -> user, encodeUtf8 -> pw)) -> return $ Memcached.Plain user pw
-instance FromJSON WidgetMemcachedConf where
- parseJSON = withObject "WidgetMemcachedConf" $ \o -> do
+instance FromJSON MemcachedConf where
+ parseJSON = withObject "MemcachedConf" $ \o -> do
connectHost <- o .:? "host" .!= ""
connectPort <- o .: "port"
connectAuth <- o .: "auth"
numConnection <- o .: "limit"
connectionIdleTime <- o .: "timeout"
- widgetMemcachedBaseUrl <- o .:? "base-url" .!= ""
- widgetMemcachedExpiry <- assertM (maybe True $ \t -> 0 < t && t <= 30 * nominalDay) $ o .:? "expiration"
+ memcachedExpiry <- assertM (maybe True $ \t -> 0 < t && t <= 30 * nominalDay) $ o .:? "expiration"
- return WidgetMemcachedConf{ widgetMemcachedConnectInfo = Memcached.ConnectInfo{..}, .. }
+ return MemcachedConf{ memcachedConnectInfo = Memcached.ConnectInfo{..}, .. }
+
+instance FromJSON WidgetMemcachedConf where
+ parseJSON v = flip (withObject "WidgetMemcachedConf") v $ \o -> do
+ widgetMemcachedConf <- parseJSON v
+ widgetMemcachedBaseUrl <- o .:? "base-url" .!= ""
+ return WidgetMemcachedConf{..}
data ResourcePoolConf = ResourcePoolConf
{ poolStripes :: Int
@@ -343,6 +369,25 @@ instance FromJSON JwtEncoding where
return $ JweEncoding alg enc
]
+instance FromJSON ServerSessionSettings where
+ parseJSON = withObject "ServerSession.State" $ \o -> do
+ cookieName <- o .:? "cookie-name"
+ idleTimeout <- o .:? "idle-timeout"
+ absoluteTimeout <- o .:? "absolute-timeout"
+ timeoutResolution <- o .:? "timeout-resolution"
+ persistentCookies <- o .:? "persistent-cookies"
+ httpOnlyCookies <- o .:? "http-only-cookies"
+ secureCookies <- o .:? "secure-cookies"
+ return $ ServerSessionSettings (appEndo . foldMap Endo $ catMaybes
+ [ ServerSession.setCookieName <$> cookieName
+ , pure $ ServerSession.setIdleTimeout idleTimeout
+ , pure $ ServerSession.setAbsoluteTimeout absoluteTimeout
+ , pure $ ServerSession.setTimeoutResolution timeoutResolution
+ , ServerSession.setPersistentCookies <$> persistentCookies
+ , ServerSession.setHttpOnlyCookies <$> httpOnlyCookies
+ , ServerSession.setSecureCookies <$> secureCookies
+ ])
+
instance FromJSON AppSettings where
parseJSON = withObject "AppSettings" $ \o -> do
@@ -363,13 +408,17 @@ instance FromJSON AppSettings where
Ldap.Plain host -> not $ null host
appLdapConf <- assertM nonEmptyHost <$> o .:? "ldap"
appSmtpConf <- assertM (not . null . smtpHost) <$> o .:? "smtp"
- let validWidgetMemcachedConf WidgetMemcachedConf{ widgetMemcachedConnectInfo = Memcached.ConnectInfo{..}, ..} = and
+ let validMemcachedConf MemcachedConf{memcachedConnectInfo = Memcached.ConnectInfo{..}, ..} = and
[ not $ null connectHost
- , not $ null widgetMemcachedBaseUrl
, numConnection > 0
, connectionIdleTime >= 0
]
+ validWidgetMemcachedConf WidgetMemcachedConf{..} = and
+ [ not $ null widgetMemcachedBaseUrl
+ , validMemcachedConf widgetMemcachedConf
+ ]
appWidgetMemcachedConf <- assertM validWidgetMemcachedConf <$> o .:? "widget-memcached"
+ appSessionMemcachedConf <- assertM validMemcachedConf <$> o .:? "session-memcached"
appRoot <- o .:? "approot"
appHost <- fromString <$> o .: "host"
appPort <- o .: "port"
@@ -387,8 +436,8 @@ instance FromJSON AppSettings where
appNotificationRateLimit <- o .: "notification-rate-limit"
appNotificationCollateDelay <- o .: "notification-collate-delay"
appNotificationExpiration <- o .: "notification-expiration"
- appJwtExpiration <- o .:? "jwt-expiration"
- appJwtEncoding <- o .: "jwt-encoding"
+ appBearerExpiration <- o .:? "bearer-expiration"
+ appBearerEncoding <- o .: "bearer-encoding"
appHealthCheckInterval <- (assertM' (> 0) . ) <$> o .: "health-check-interval"
appHealthCheckDelayNotify <- o .: "health-check-delay-notify"
@@ -417,6 +466,7 @@ instance FromJSON AppSettings where
appAuthDummyLogin <- o .:? "auth-dummy-login" .!= defaultDev
appAllowDeprecated <- o .:? "allow-deprecated" .!= defaultDev
appEncryptErrors <- o .:? "encrypt-errors" .!= not defaultDev
+ appServerSessionAcidFallback <- o .:? "server-session-acid-fallback" .!= defaultDev
appInitialLogSettings <- o .: "log-settings"
@@ -438,7 +488,11 @@ instance FromJSON AppSettings where
appRibbon <- assertM (not . Text.null) . fmap Text.strip <$> o.:? "ribbon"
- return AppSettings {..}
+ appServerSessionConfig <- o .: "server-sessions"
+ appSessionTokenExpiration <- o .:? "session-token-expiration"
+ appSessionTokenEncoding <- o .: "session-token-encoding"
+
+ return AppSettings{..}
makeClassy_ ''AppSettings
diff --git a/src/Settings/Cluster.hs b/src/Settings/Cluster.hs
index 6b4d0e836..25cf564bc 100644
--- a/src/Settings/Cluster.hs
+++ b/src/Settings/Cluster.hs
@@ -9,20 +9,18 @@ import ClassyPrelude.Yesod
import Web.HttpApiData
import Utils
-import Control.Lens
import Data.Universe
import qualified Data.Aeson as Aeson
-import qualified Web.ClientSession as ClientSession
import qualified Crypto.Saltine.Core.SecretBox as SecretBox
+import qualified Crypto.Saltine.Core.AEAD as AEAD
import qualified Crypto.Saltine.Class as Saltine
import Data.CryptoID.ByteString (CryptoIDKey)
import qualified Data.CryptoID.ByteString as CryptoID
import qualified Data.Binary as Binary
-import qualified Data.Serialize as Serialize
import qualified Data.ByteString.Base64.URL as Base64
import qualified Jose.Jwa as Jose
@@ -39,7 +37,7 @@ import Model.Types.TH.PathPiece
data ClusterSettingsKey
= ClusterCryptoIDKey
- | ClusterClientSessionKey
+ | ClusterServerSessionKey
| ClusterSecretBoxKey
| ClusterJSONWebKeySet
| ClusterId
@@ -83,18 +81,18 @@ instance FromJSON CryptoIDKey where
| otherwise -> fail $ show (length bs) ++ " extra bytes"
-instance ClusterSetting 'ClusterClientSessionKey where
- type ClusterSettingValue 'ClusterClientSessionKey = ClientSession.Key
- initClusterSetting _ = liftIO $ view _2 <$> ClientSession.randomKey
- knownClusterSetting _ = ClusterClientSessionKey
+instance ClusterSetting 'ClusterServerSessionKey where
+ type ClusterSettingValue 'ClusterServerSessionKey = AEAD.Key
+ initClusterSetting _ = liftIO AEAD.newKey
+ knownClusterSetting _ = ClusterServerSessionKey
+
+instance ToJSON AEAD.Key where
+ toJSON = Aeson.String . decodeUtf8 . Base64.encode . Saltine.encode
-instance ToJSON ClientSession.Key where
- toJSON = Aeson.String . decodeUtf8 . Base64.encode . Serialize.encode
-
-instance FromJSON ClientSession.Key where
+instance FromJSON AEAD.Key where
parseJSON = Aeson.withText "Key" $ \t -> do
bytes <- either fail return . Base64.decode $ encodeUtf8 t
- either fail return $ Serialize.decode bytes
+ maybe (fail "Could not parse key") return $ Saltine.decode bytes
instance ClusterSetting 'ClusterSecretBoxKey where
diff --git a/src/Utils.hs b/src/Utils.hs
index eae6e88fa..f8879cf98 100644
--- a/src/Utils.hs
+++ b/src/Utils.hs
@@ -613,6 +613,9 @@ guardMExceptT b err = unless b $ lift err >>= throwE
exceptT :: Monad m => (e -> m b) -> (a -> m b) -> ExceptT e m a -> m b
exceptT f g = either f g <=< runExceptT
+catchIfExceptT :: (MonadCatch m, Exception e) => (e -> e') -> (e -> Bool) -> m a -> ExceptT e' m a
+catchIfExceptT err p act = catchIf p (lift act) (throwE . err)
+
catchIfMExceptT :: (MonadCatch m, Exception e) => (e -> m e') -> (e -> Bool) -> m a -> ExceptT e' m a
catchIfMExceptT err p act = catchIf p (lift act) (throwE <=< lift . err)
diff --git a/src/Utils/Lens.hs b/src/Utils/Lens.hs
index db0e024d0..8f02a65fb 100644
--- a/src/Utils/Lens.hs
+++ b/src/Utils/Lens.hs
@@ -240,6 +240,9 @@ makeLenses_ ''ExternalExamResult
class HasInstanceID s a | s -> a where
instanceID :: Lens' s a
+class HasClusterID s a | s -> a where
+ clusterID :: Lens' s a
+
class HasHttpManager s a | s -> a where
httpManager :: Lens' s a
diff --git a/src/Utils/Tokens.hs b/src/Utils/Tokens.hs
index 5ae042c82..e7853f525 100644
--- a/src/Utils/Tokens.hs
+++ b/src/Utils/Tokens.hs
@@ -1,9 +1,9 @@
module Utils.Tokens
( bearerToken
- , encodeToken, BearerTokenException(..), decodeToken
- , tokenParseJSON'
- , askJwt
- , formEmbedJwtPost, formEmbedJwtGet
+ , encodeBearer, BearerTokenException(..), decodeBearer
+ , bearerParseJSON'
+ , askBearer
+ , formEmbedBearerPost, formEmbedBearerGet
) where
import Import.NoModel
@@ -32,7 +32,7 @@ import CryptoID
import Text.Blaze (Markup)
-tokenParseJSON' :: forall m.
+bearerParseJSON' :: forall m.
( HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser)
, ParseRoute (HandlerSite m)
, Hashable (Route (HandlerSite m))
@@ -42,14 +42,15 @@ tokenParseJSON' :: forall m.
)
=> m (Value -> Parser (BearerToken (HandlerSite m)))
-- ^ Read `CryptoIDKey` from monadic context and construct a `Parser` for `BearerToken`s
-tokenParseJSON' = do
+bearerParseJSON' = do
cidKey <- cryptoIDKey return
- return $ flip runReaderT cidKey . tokenParseJSON
+ return $ flip runReaderT cidKey . bearerParseJSON
bearerToken :: forall m.
( MonadHandler m
, HasInstanceID (HandlerSite m) InstanceId
+ , HasClusterID (HandlerSite m) ClusterId
, HasAppSettings (HandlerSite m)
)
=> Either Value (AuthId (HandlerSite m))
@@ -59,26 +60,27 @@ bearerToken :: forall m.
-> Maybe UTCTime -- ^ @Nothing@ means token starts to be valid immediately
-> m (BearerToken (HandlerSite m))
-- ^ Smart constructor for `BearerToken`, does not set route restrictions (due to polymorphism), use `tokenRestrict`
-bearerToken tokenAuthority tokenRoutes tokenAddAuth mTokenExpiresAt tokenStartsAt = do
- tokenIdentifier <- liftIO getRandom
- tokenIssuedAt <- liftIO getCurrentTime
- tokenIssuedBy <- getsYesod $ view instanceID
+bearerToken bearerAuthority bearerRoutes bearerAddAuth mBearerExpiresAt bearerStartsAt = do
+ bearerIdentifier <- liftIO getRandom
+ bearerIssuedAt <- liftIO getCurrentTime
+ bearerIssuedBy <- getsYesod $ view instanceID
+ bearerIssuedFor <- getsYesod $ view clusterID
- defaultExpiration <- getsYesod $ view _appJwtExpiration
+ defaultExpiration <- getsYesod $ view _appBearerExpiration
- let tokenExpiresAt
- | Just t <- mTokenExpiresAt
+ let bearerExpiresAt
+ | Just t <- mBearerExpiresAt
= t
| Just tDiff <- defaultExpiration
- = Just $ tDiff `addUTCTime` fromMaybe tokenIssuedAt tokenStartsAt
+ = Just $ tDiff `addUTCTime` fromMaybe bearerIssuedAt bearerStartsAt
| otherwise
= Nothing
- tokenRestrictions = HashMap.empty
+ bearerRestrictions = HashMap.empty
return BearerToken{..}
-encodeToken :: forall m.
+encodeBearer :: forall m.
( MonadHandler m
, HasJSONWebKeySet (HandlerSite m) JwkSet
, HasAppSettings (HandlerSite m)
@@ -86,11 +88,11 @@ encodeToken :: forall m.
, RenderRoute (HandlerSite m)
)
=> BearerToken (HandlerSite m) -> m Jwt
--- ^ Call `tokenToJSON` and encode the result as a `Jwt` according to `appJwtEncoding`
-encodeToken token = do
- payload <- Jose.Claims . toStrict . JSON.encode <$> tokenToJSON token
+-- ^ Call `bearerToJSON` and encode the result as a `Jwt` according to `appJwtEncoding`
+encodeBearer token = do
+ payload <- Jose.Claims . toStrict . JSON.encode <$> bearerToJSON token
JwkSet jwks <- getsYesod $ view jsonWebKeySet
- jwtEncoding <- getsYesod $ view _appJwtEncoding
+ jwtEncoding <- getsYesod $ view _appBearerEncoding
either throwM return =<< liftIO (Jose.encode jwks jwtEncoding payload)
@@ -103,7 +105,7 @@ data BearerTokenException
instance Exception BearerTokenException
-decodeToken :: forall m.
+decodeBearer :: forall m.
( MonadHandler m
, HasJSONWebKeySet (HandlerSite m) JwkSet
, HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser)
@@ -113,50 +115,50 @@ decodeToken :: forall m.
, Hashable (Route (HandlerSite m))
)
=> Jwt -> m (BearerToken (HandlerSite m))
--- ^ Decode a `Jwt` and call `tokenParseJSON`
+-- ^ Decode a `Jwt` and call `bearerParseJSON`
--
-- Throws `bearerTokenException`s
-decodeToken (Jwt bs) = do
+decodeBearer (Jwt bs) = do
JwkSet jwks <- getsYesod $ view jsonWebKeySet
content <- either (throwM . BearerTokenJwtError) return =<< liftIO (Jose.decode jwks Nothing bs)
content' <- case content of
Jose.Unsecured _ -> throwM BearerTokenUnsecured
Jose.Jws (_header, payload) -> return payload
Jose.Jwe (_header, payload) -> return payload
- parser <- tokenParseJSON'
- token@BearerToken{..} <- either (throwM . BearerTokenInvalidFormat . uncurry JSON.formatError) return $ JSON.eitherDecodeStrictWith JSON.jsonEOF' (JSON.iparse parser) content'
+ parser <- bearerParseJSON'
+ bearer@BearerToken{..} <- either (throwM . BearerTokenInvalidFormat . uncurry JSON.formatError) return $ JSON.eitherDecodeStrictWith JSON.jsonEOF' (JSON.iparse parser) content'
now <- liftIO getCurrentTime
- unless (NTop tokenExpiresAt > NTop (Just now)) $
+ unless (NTop bearerExpiresAt > NTop (Just now)) $
throwM BearerTokenExpired
- unless (tokenStartsAt <= Just now) $
+ unless (bearerStartsAt <= Just now) $
throwM BearerTokenNotStarted
- return token
+ return bearer
-askJwt :: forall m. ( MonadHandler m )
- => m (Maybe Jwt)
+askBearer :: forall m. ( MonadHandler m )
+ => m (Maybe Jwt)
-- ^ Retrieve current `Jwt` from HTTP-Header, POST-Parameter, or GET-Parameter
-askJwt = runMaybeT $ asum
+askBearer = runMaybeT $ asum
[ MaybeT lookupBearerAuth >>= hoistMaybe . fromPathPiece
, MaybeT $ lookupGlobalPostParam PostBearer
, MaybeT $ lookupGlobalGetParam GetBearer
, fmap Jwt . MaybeT $ lookupSessionBS (toPathPiece SessionBearer)
]
-formEmbedJwtPost, formEmbedJwtGet :: MonadHandler m => (Markup -> m a) -> (Markup -> m a)
-formEmbedJwtPost f fragment = do
- mJwt <- askJwt
+formEmbedBearerPost, formEmbedBearerGet :: MonadHandler m => (Markup -> m a) -> (Markup -> m a)
+formEmbedBearerPost f fragment = do
+ mBearer <- askBearer
f [shamlet|
$newline never
- $maybe jwt <- mJwt
-
+ $maybe bearer <- mBearer
+
#{fragment}
|]
-formEmbedJwtGet f fragment = do
- mJwt <- askJwt
+formEmbedBearerGet f fragment = do
+ mBearer <- askBearer
f [shamlet|
$newline never
- $maybe jwt <- mJwt
-
+ $maybe bearer <- mBearer
+
#{fragment}
|]
diff --git a/src/Web/ServerSession/Backend/Persistent/Memcached.hs b/src/Web/ServerSession/Backend/Persistent/Memcached.hs
new file mode 100644
index 000000000..3945fcd52
--- /dev/null
+++ b/src/Web/ServerSession/Backend/Persistent/Memcached.hs
@@ -0,0 +1,175 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Web.ServerSession.Backend.Persistent.Memcached
+ ( migrateMemcachedSqlStorage
+ , MemcachedSessionExpirationId, MemcachedSessionExpiration(..)
+ , MemcachedSqlStorage(..)
+ , _mcdSqlConnPool, _mcdSqlMemcached, _mcdSqlMemcachedKey, _mcdSqlMemcachedExpiration
+ ) where
+
+import Import.NoModel hiding (AuthId, SessionMap, getSession)
+
+import Utils.Lens
+
+import Web.ServerSession.Core
+
+import Database.Persist.Sql (ConnectionPool, runSqlPool)
+
+import qualified Data.Binary as Binary
+
+import qualified Database.Memcached.Binary.IO as Memcached
+
+import qualified Crypto.Saltine.Class as Saltine
+import qualified Crypto.Saltine.Internal.ByteSizes as Saltine
+import qualified Crypto.Saltine.Core.AEAD as AEAD
+
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as LBS
+
+import qualified Data.ByteString.Base64.URL as Base64
+
+
+import Data.Text.Encoding (decodeUtf8')
+
+import Data.Bits (Bits(zeroBits))
+
+
+share [mkPersist sqlSettings, mkMigrate "migrateMemcachedSqlStorage"]
+ [persistLowerCase|
+ MemcachedSessionExpiration
+ authId ByteString
+ time UTCTime
+ UniqueMemcachedSessionExpiration authId
+ deriving Show Eq Ord
+ |]
+
+
+data MemcachedSqlStorage sess = MemcachedSqlStorage
+ { mcdSqlConnPool :: ConnectionPool
+ , mcdSqlMemcached :: Memcached.Connection
+ , mcdSqlMemcachedKey :: AEAD.Key
+ , mcdSqlMemcachedExpiration :: Maybe NominalDiffTime
+ }
+makeLenses_ ''MemcachedSqlStorage
+
+data MemcachedSqlStorageException
+ = MemcachedSqlStorageKeyCollision
+ | MemcachedSqlStorageAEADCiphertextTooShort
+ | MemcachedSqlStorageAEADCouldNotDecodeNonce
+ | MemcachedSqlStorageAEADCouldNotOpenAEAD
+ | MemcachedSqlStorageAEADCouldDecodeMemcachedSqlSession
+ deriving (Eq, Ord, Read, Show, Generic, Typeable)
+instance Exception MemcachedSqlStorageException
+
+data MemcachedSqlSession sess = MemcachedSqlSession
+ { mcdSqlSessionAuthId :: Maybe AuthId
+ , mcdSqlSessionData :: Decomposed sess
+ , mcdSqlSessionCreatedAt, mcdSqlSessionAccessedAt :: UTCTime
+ } deriving (Generic, Typeable)
+deriving instance Eq (Decomposed sess) => Eq (MemcachedSqlSession sess)
+deriving instance Ord (Decomposed sess) => Ord (MemcachedSqlSession sess)
+deriving instance Read (Decomposed sess) => Read (MemcachedSqlSession sess)
+deriving instance Show (Decomposed sess) => Show (MemcachedSqlSession sess)
+
+instance Binary (Decomposed sess) => Binary (MemcachedSqlSession sess)
+instance Binary (SessionId sess) where
+ get = maybe (fail "Could not decode SessionId fromPathPiece") return . fromPathPiece . decodeUtf8 . Base64.encode . BS.pack =<< replicateM 18 Binary.get
+ put = mapM_ Binary.put . take 18 . BS.unpack . Base64.decodeLenient . encodeUtf8 . toPathPiece
+
+memcachedSqlSession :: Iso' (SessionId sess, MemcachedSqlSession sess) (Session sess)
+memcachedSqlSession = iso toSession fromSession
+ where
+ toSession (mcdSqlSessionKey, MemcachedSqlSession{..}) = Session
+ { sessionKey = mcdSqlSessionKey
+ , sessionAuthId = mcdSqlSessionAuthId
+ , sessionData = mcdSqlSessionData
+ , sessionCreatedAt = mcdSqlSessionCreatedAt
+ , sessionAccessedAt = mcdSqlSessionAccessedAt
+ }
+ fromSession Session{..}
+ = ( sessionKey
+ , MemcachedSqlSession
+ { mcdSqlSessionAuthId = sessionAuthId
+ , mcdSqlSessionData = sessionData
+ , mcdSqlSessionCreatedAt = sessionCreatedAt
+ , mcdSqlSessionAccessedAt = sessionAccessedAt
+ }
+ )
+
+deriving newtype instance Binary SessionMap
+
+
+memcachedSqlSessionId :: Prism' ByteString (SessionId dat)
+memcachedSqlSessionId = prism' (encodeUtf8 . toPathPiece) (fromPathPiece <=< either (const Nothing) Just . decodeUtf8')
+
+
+instance (IsSessionData sess, Binary (Decomposed sess)) => Storage (MemcachedSqlStorage sess) where
+ type SessionData (MemcachedSqlStorage sess) = sess
+ type TransactionM (MemcachedSqlStorage sess) = SqlPersistT IO
+
+ runTransactionM MemcachedSqlStorage{..} = flip runSqlPool mcdSqlConnPool
+
+ getSession MemcachedSqlStorage{..} sessId = exceptT (maybe (return Nothing) throwM) (return . Just) $ do
+ encSession <- catchIfExceptT (\_ -> Nothing) Memcached.isKeyNotFound . liftIO . fmap LBS.toStrict $ Memcached.getAndTouch_ expiry (memcachedSqlSessionId # sessId) mcdSqlMemcached
+
+ guardExceptT (BS.length encSession >= Saltine.secretBoxNonce + Saltine.secretBoxMac) $
+ Just MemcachedSqlStorageAEADCiphertextTooShort
+ let (nonceBS, encrypted) = BS.splitAt Saltine.secretBoxNonce encSession
+ encSessId = LBS.toStrict $ Binary.encode sessId
+ nonce <- maybeTExceptT (Just MemcachedSqlStorageAEADCouldNotDecodeNonce) . hoistMaybe $ Saltine.decode nonceBS
+ decrypted <- maybeTExceptT (Just MemcachedSqlStorageAEADCouldNotOpenAEAD) . hoistMaybe $ AEAD.aeadOpen mcdSqlMemcachedKey nonce encrypted encSessId
+
+ let binaryDecode bs = do
+ Right (unconsumed, _, res) <- return $ Binary.decodeOrFail bs
+ guard $ LBS.null unconsumed
+ return res
+ decoded@MemcachedSqlSession{..} <- maybeTExceptT (Just MemcachedSqlStorageAEADCouldDecodeMemcachedSqlSession) . hoistMaybe . binaryDecode $ LBS.fromStrict decrypted
+
+ expiration <- runMaybeT $ fmap (memcachedSessionExpirationTime . entityVal) . MaybeT . lift . getBy . UniqueMemcachedSessionExpiration =<< hoistMaybe mcdSqlSessionAuthId
+
+ guardExceptT (maybe True (mcdSqlSessionCreatedAt >) expiration) Nothing
+
+ return $ (sessId, decoded) ^. memcachedSqlSession
+
+ where expiry = maybe 0 ceiling mcdSqlMemcachedExpiration
+
+ deleteSession MemcachedSqlStorage{..} sessId
+ = liftIO $ Memcached.delete (memcachedSqlSessionId # sessId) mcdSqlMemcached
+
+ deleteAllSessionsOfAuthId MemcachedSqlStorage{..} authId = do
+ now <- liftIO getCurrentTime
+ void $ upsert
+ ( MemcachedSessionExpiration authId now )
+ [ MemcachedSessionExpirationTime =. now
+ ]
+
+ insertSession = replaceSession' False
+ replaceSession = replaceSession' True
+
+replaceSession' :: forall sess.
+ ( Storage (MemcachedSqlStorage sess)
+ , Binary (Decomposed sess)
+ )
+ => Bool -- ^ Replace existing?
+ -> MemcachedSqlStorage sess
+ -> Session (SessionData (MemcachedSqlStorage sess))
+ -> SqlPersistT IO ()
+replaceSession' isReplace s@MemcachedSqlStorage{..} seNewSession@(review memcachedSqlSession -> (sessId, decoded)) = do
+ unless isReplace $ do
+ mOld <- getSession @(MemcachedSqlStorage sess) s sessId
+ whenIsJust mOld $ \seExistingSession ->
+ throwM @_ @(StorageException (MemcachedSqlStorage sess)) $ SessionAlreadyExists{..}
+
+ nonce <- liftIO $ AEAD.newNonce
+ let encSession = Saltine.encode nonce <> AEAD.aead mcdSqlMemcachedKey nonce encoded encSessId
+ encSessId = LBS.toStrict $ Binary.encode sessId
+ handleFailure
+ = handleIf Memcached.isKeyExists (\_ -> throwM MemcachedSqlStorageKeyCollision)
+ . handleIf Memcached.isKeyNotFound (\_ -> throwM @_ @(StorageException (MemcachedSqlStorage sess)) SessionDoesNotExist{..})
+ handleFailure . liftIO $
+ bool Memcached.add Memcached.replace isReplace zeroBits expiry (memcachedSqlSessionId # sessId) (LBS.fromStrict encSession) mcdSqlMemcached
+
+ where
+ encoded = LBS.toStrict $ Binary.encode decoded
+ expiry = maybe 0 ceiling mcdSqlMemcachedExpiration
diff --git a/src/Web/ServerSession/Frontend/Yesod/Jwt.hs b/src/Web/ServerSession/Frontend/Yesod/Jwt.hs
new file mode 100644
index 000000000..29c5e081b
--- /dev/null
+++ b/src/Web/ServerSession/Frontend/Yesod/Jwt.hs
@@ -0,0 +1,208 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Web.ServerSession.Frontend.Yesod.Jwt
+ ( backend
+ , siteApproot
+ , ServerSessionJwtConfig(..)
+ , ServerSessionJwtException(..)
+ , forceInvalidate
+ ) where
+
+-- Module heavily inspired by:
+-- serversession-frontend-yesod-1.0@sha256:8ddb112a1ef6ee863f5ea13978dd08e1c39444c1a252f775a780013430bcc884,1230
+
+
+import Import.NoModel hiding (State, state, Header, deleteCookie)
+import Yesod.Core.Types
+import Model.Types.Common
+
+import Model.Tokens.Session
+
+import Jose.Jwk (JwkSet)
+import Jose.Jwt (Jwt(..), JwtEncoding(..))
+import qualified Jose.Jwt as Jose
+import qualified Jose.Jwk as Jose
+
+import qualified Network.Wai as Wai
+
+import Web.Cookie (parseCookies, SetCookie(..))
+
+import Web.ServerSession.Core hiding (SessionMap, setCookieName)
+
+import qualified Data.Map as Map
+
+import qualified Data.Aeson as JSON
+
+
+instance Universe ForceInvalidate
+instance Finite ForceInvalidate
+finitePathPiece ''ForceInvalidate
+ [ "current", "all", "none" ]
+
+
+data ServerSessionJwtConfig = ServerSessionJwtConfig
+ { sJwtJwkSet :: JwkSet
+ , sJwtStart
+ , sJwtExpiration :: Maybe NominalDiffTime
+ , sJwtEncoding :: JwtEncoding
+ , sJwtIssueBy :: InstanceId
+ , sJwtIssueFor :: ClusterId
+ }
+
+
+data ServerSessionJwtException
+ = SessionTokenJwtError Jose.JwtError
+ | SessionTokenUnsecured
+ | SessionTokenInvalidFormat String
+ | SessionTokenExpired | SessionTokenNotStarted
+ deriving (Eq, Show, Generic, Typeable)
+instance Exception ServerSessionJwtException
+
+
+backend :: ( Applicative m
+ , Storage sto
+ , SessionData sto ~ Map Text ByteString
+ )
+ => ServerSessionJwtConfig
+ -> (Wai.Request -> Maybe Text)
+ -> State sto
+ -> m (Maybe SessionBackend)
+backend jwtCfg getApprootText' state = pure $ Just SessionBackend{..}
+ where
+ sbLoadSession :: Wai.Request -> IO (SessionMap, SaveSession)
+ sbLoadSession req = do
+ session <- runMaybeT . catchMPlus (Proxy @ServerSessionJwtException) $
+ decodeSession jwtCfg =<< hoistMaybe (findSession state req)
+ (sessionData, saveSessionToken) <- loadSession state $ encodeUtf8 . toPathPiece . sessionId <$> session
+
+ let save :: SessionMap -> IO [Header]
+ save sessMap = pure <$> do
+ saveRes <- saveSession state saveSessionToken sessMap
+ case saveRes of
+ Nothing ->
+ return $ deleteCookie state approot'
+ Just sess ->
+ fmap (createCookie state approot' sess) . encodeSession jwtCfg =<< mkSessionToken jwtCfg sess
+
+ approot' = getApprootText' req
+
+ return (sessionData, save)
+
+siteApproot :: Yesod site => site -> Wai.Request -> Maybe Text
+siteApproot master req = case approot of
+ ApprootRelative -> Nothing
+ ApprootStatic t -> Just t
+ ApprootMaster f -> Just $ f master
+ ApprootRequest f -> Just $ f master req
+
+findSession :: State sto
+ -> Wai.Request
+ -> Maybe Jwt
+findSession state req = do
+ [raw] <- return $ do
+ ("Cookie", header) <- Wai.requestHeaders req
+ (k, v) <- parseCookies header
+ guard $ k == encodeUtf8 (getCookieName state)
+ return v
+ return $ Jwt raw
+
+
+mkSessionToken :: MonadIO m
+ => ServerSessionJwtConfig
+ -> Session sess
+ -> m (SessionToken sess)
+mkSessionToken ServerSessionJwtConfig{..} Session{..} = liftIO $
+ mkSessionToken' <$> getCurrentTime <*> getRandom
+ where
+ mkSessionToken' now sessionIdentifier
+ = let sessionId = sessionKey
+ sessionIssuedAt = now
+ sessionIssuedBy = sJwtIssueBy
+ sessionIssuedFor = sJwtIssueFor
+ sessionExpiresAt = flip addUTCTime now <$> sJwtExpiration
+ sessionStartsAt = flip addUTCTime now <$> sJwtStart
+ in SessionToken{..}
+
+
+deleteCookie :: State sto -> Maybe Text -> Header
+deleteCookie state approot' = DeleteCookie (encodeUtf8 $ getCookieName state) $ cookiePath approot'
+
+createCookie :: State sto -> Maybe Text -> Session sess -> Jwt -> Header
+createCookie state approot' session (Jwt payload) = AddCookie def
+ { setCookieName = encodeUtf8 $ getCookieName state
+ , setCookieValue = payload
+ , setCookiePath = Just $ cookiePath approot'
+ , setCookieExpires = cookieExpires state session
+ , setCookieDomain = Nothing -- Setting anything here would have browsers include subdomains, which might be wrong
+ , setCookieHttpOnly = getHttpOnlyCookies state
+ , setCookieSecure = getSecureCookies state
+ }
+
+cookiePath :: Maybe Text -> ByteString
+cookiePath = maybe "/" $ extractPath . encodeUtf8
+
+
+decodeSession :: ( MonadThrow m
+ , MonadIO m
+ )
+ => ServerSessionJwtConfig
+ -> Jwt
+ -> m (SessionToken sess)
+decodeSession ServerSessionJwtConfig{..} (Jwt bs) = do
+ content <- either (throwM . SessionTokenJwtError) return =<< liftIO (Jose.decode (Jose.keys sJwtJwkSet) Nothing bs)
+ content' <- case content of
+ Jose.Unsecured _ -> throwM SessionTokenUnsecured
+ Jose.Jws (_header, payload) -> return payload
+ Jose.Jwe (_header, payload) -> return payload
+ session@SessionToken{..} <- either (throwM . SessionTokenInvalidFormat) return $ JSON.eitherDecodeStrict content'
+
+ now <- liftIO getCurrentTime
+ unless (NTop sessionExpiresAt > NTop (Just now)) $
+ throwM SessionTokenExpired
+ unless (sessionStartsAt <= Just now) $
+ throwM SessionTokenNotStarted
+
+ return session
+
+encodeSession :: MonadIO m
+ => ServerSessionJwtConfig
+ -> SessionToken sess
+ -> m Jwt
+encodeSession ServerSessionJwtConfig{..} token = liftIO $
+ either throwM return =<< Jose.encode (Jose.keys sJwtJwkSet) sJwtEncoding payload
+ where payload = Jose.Claims . toStrict $ JSON.encode token
+
+
+-- | Invalidate the current session ID (and possibly more, check
+-- 'ForceInvalidate'). This is useful to avoid session fixation
+-- attacks (cf. ).
+--
+-- 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.
+--
+-- This function works by setting a session variable that is
+-- checked when saving the session. The session variable set by
+-- this function is then discarded and is not persisted across
+-- requests.
+forceInvalidate :: MonadHandler m => ForceInvalidate -> m ()
+forceInvalidate = setSessionBS forceInvalidateKey . encodeUtf8 . toPathPiece
+
+
+instance IsSessionData (Map Text ByteString) where
+ type Decomposed (Map Text ByteString) = Map Text ByteString
+ emptySession = mempty
+ decomposeSession authKey session
+ = let dsAuthId = Map.lookup authKey session
+ dsForceInvalidate = fromMaybe DoNotForceInvalidate
+ $ fromPathPiece . decodeUtf8 =<< Map.lookup forceInvalidateKey session
+ dsDecomposed = session
+ & Map.delete authKey
+ & Map.delete forceInvalidateKey
+ in DecomposedSession{..}
+ recomposeSession authKey mAuthId
+ = maybe id (Map.insert authKey) mAuthId
+ isDecomposedEmpty _ = Map.null
+ isSameDecomposed _ = (==)
diff --git a/stack.yaml b/stack.yaml
index 20c318374..07c492062 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -23,6 +23,12 @@ extra-deps:
commit: 40393c938111ac78232dc2c7eec5edb4a22d03e8
- git: git@gitlab2.rz.ifi.lmu.de:uni2work/ldap-client.git
commit: 01afaf599ba6f8a9d804c269e91d3190b249d3f0
+ - git: git@gitlab2.rz.ifi.lmu.de:uni2work/serversession.git
+ commit: 1c95b0100471279413485411032d639881012a5e
+ subdirs:
+ - serversession
+ - serversession-backend-acid-state
+
- colonnade-1.2.0.2
- hsass-0.8.0
@@ -92,5 +98,9 @@ extra-deps:
# - skylighting-core-0.8.3.2
# - texmath-0.12.0.1
+ - binary-instances-1
+
+ - acid-state-0.16.0
+
resolver: lts-15.0
allow-newer: true
diff --git a/stack.yaml.lock b/stack.yaml.lock
index ecf90088b..f8e0d7b61 100644
--- a/stack.yaml.lock
+++ b/stack.yaml.lock
@@ -88,6 +88,38 @@ packages:
original:
git: git@gitlab2.rz.ifi.lmu.de:uni2work/ldap-client.git
commit: 01afaf599ba6f8a9d804c269e91d3190b249d3f0
+- completed:
+ subdir: serversession
+ cabal-file:
+ size: 2081
+ sha256: a958ff0007e5084e3e4c2a33acc9860c31186105f02f8ab99ecb847a7a8f9497
+ name: serversession
+ version: 1.0.1
+ git: git@gitlab2.rz.ifi.lmu.de:uni2work/serversession.git
+ pantry-tree:
+ size: 544
+ sha256: f825236d72f8eb42bb095c08b63e34d4ea7f83b059983543a725ddb5ef808b25
+ commit: 1c95b0100471279413485411032d639881012a5e
+ original:
+ subdir: serversession
+ git: git@gitlab2.rz.ifi.lmu.de:uni2work/serversession.git
+ commit: 1c95b0100471279413485411032d639881012a5e
+- completed:
+ subdir: serversession-backend-acid-state
+ cabal-file:
+ size: 1875
+ sha256: 6cc9d29e788334670bc102213a8aae73bc1b8b0a00c416f06d232376750443b7
+ name: serversession-backend-acid-state
+ version: 1.0.3
+ git: git@gitlab2.rz.ifi.lmu.de:uni2work/serversession.git
+ pantry-tree:
+ size: 543
+ sha256: 0806157c4bc259f28bcab1cc0dcb58a8becc831e3f4e5e63d28f550acae92842
+ commit: 1c95b0100471279413485411032d639881012a5e
+ original:
+ subdir: serversession-backend-acid-state
+ git: git@gitlab2.rz.ifi.lmu.de:uni2work/serversession.git
+ commit: 1c95b0100471279413485411032d639881012a5e
- completed:
hackage: colonnade-1.2.0.2@sha256:c95c2ecff5cfa28c736d8fa662d28b71129f67457068e3f4467b296a621607ab,2099
pantry-tree:
@@ -228,6 +260,20 @@ packages:
sha256: 9d4d8e7a85166ffd951b02f87be540607b55084c04730932346072329adf4913
original:
hackage: doctemplates-0.8.1
+- completed:
+ hackage: binary-instances-1@sha256:b17565598b8df3241f9b46fa8e3a3368ecc8e3f2eb175d7c28f319042a6f5c79,2613
+ pantry-tree:
+ size: 1035
+ sha256: 938ffc6990cac12681c657f7afa93737eecf335e6f0212d8c0b7c1ea3e0f40f4
+ original:
+ hackage: binary-instances-1
+- completed:
+ hackage: acid-state-0.16.0@sha256:a5640fd8d99bdb5f152476a2ae56cc8eb81864b280c8ec7d1387e81296ed844d,6190
+ pantry-tree:
+ size: 13678
+ sha256: c6e4b7f00d2a500e6286beafe3a2da7ba898a9ea31f5744df57cdce8a8f5890f
+ original:
+ hackage: acid-state-0.16.0
snapshots:
- completed:
size: 488576
diff --git a/start.sh b/start.sh
index 54bfd2d0e..2c3bafbff 100755
--- a/start.sh
+++ b/start.sh
@@ -10,6 +10,8 @@ export DETAILED_LOGGING=${DETAILED_LOGGING:-true}
export LOG_ALL=${LOG_ALL:-false}
export LOGLEVEL=${LOGLEVEL:-info}
export DUMMY_LOGIN=${DUMMY_LOGIN:-true}
+export SERVER_SESSION_ACID_FALLBACK=${SERVER_SESSION_ACID_FALLBACK:-true}
+export SERVER_SESSION_COOKIES_SECURE=${SERVER_SESSION_COOKIES_SECURE:-false}
export ALLOW_DEPRECATED=${ALLOW_DEPRECATED:-true}
export RIBBON=${RIBBON:-${__HOST:-localhost}}
unset HOST
diff --git a/templates/metrics.hamlet b/templates/metrics.hamlet
index 885a79591..df87f79c0 100644
--- a/templates/metrics.hamlet
+++ b/templates/metrics.hamlet
@@ -1,5 +1,5 @@
$newline never
-$maybe t <- metricsToken
+$maybe t <- metricsBearer