fradrive/src/Web/ServerSession/Frontend/Yesod/Jwt.hs
Gregor Kleen ead6015dfe feat(system-messages): refactor cookies & improve system messages
BREAKING CHANGE: names of cookies & configuration changed
2020-04-15 10:39:26 +02:00

198 lines
6.7 KiB
Haskell

{-# 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.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)
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
}
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. <http://www.acrossecurity.com/papers/session_fixation.pdf>).
--
-- Note that the invalidate /does not/ occur when the call to
-- this action is made! The sessions will be invalidated on the
-- end of the handler processing. This means that later calls to
-- 'forceInvalidate' on the same handler will override earlier
-- calls.
--
-- 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 _ = (==)