From 60e95e8ef7bfe05c4f795e94ad834a5e2e7a3294 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Wed, 17 Apr 2019 11:35:27 +0200 Subject: [PATCH] Cleanup & (maybe|require)BearerToken --- config/settings.yml | 1 + messages/uniworx/de.msg | 2 + package.yaml | 1 + src/Data/Aeson/Types/Instances.hs | 10 +- src/Data/HashMap/Strict/Instances.hs | 16 +++ src/Data/HashSet/Instances.hs | 17 +++ src/Data/NonNull/Instances.hs | 8 ++ src/Data/Time/Clock/Instances.hs | 26 ++++ src/Data/Vector/Instances.hs | 18 +++ src/Foundation.hs | 78 +++++++----- src/Handler/Admin.hs | 7 +- src/Handler/Course.hs | 12 +- src/Handler/Profile.hs | 11 +- src/Handler/Utils/Tokens.hs | 27 +++++ src/Import.hs | 1 - src/Import/NoFoundation.hs | 9 ++ src/Language/Haskell/TH/Instances.hs | 14 +++ src/Mail.hs | 2 + src/Model/{Token.hs => Tokens.hs} | 147 +++------------------- src/Model/Types.hs | 9 +- src/Settings.hs | 21 +++- src/Utils.hs | 21 ++++ src/Utils/Lens.hs | 12 +- src/Utils/Tokens.hs | 174 +++++++++++++++++++++++++++ 24 files changed, 447 insertions(+), 197 deletions(-) create mode 100644 src/Data/HashMap/Strict/Instances.hs create mode 100644 src/Data/HashSet/Instances.hs create mode 100644 src/Data/Time/Clock/Instances.hs create mode 100644 src/Data/Vector/Instances.hs create mode 100644 src/Handler/Utils/Tokens.hs create mode 100644 src/Language/Haskell/TH/Instances.hs rename src/Model/{Token.hs => Tokens.hs} (51%) create mode 100644 src/Utils/Tokens.hs diff --git a/config/settings.yml b/config/settings.yml index 9479d002a..287baf0b3 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -28,6 +28,7 @@ notification-collate-delay: 300 notification-expiration: 259201 session-timeout: 7200 jwt-expiration: 604800 +jwt-encoding: HS256 maximum-content-length: 52428800 log-settings: diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index fb521065d..c42f8cb0c 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -214,6 +214,8 @@ UnauthorizedTokenExpired: Ihr Authorisierungs-Token ist abgelaufen. UnauthorizedTokenNotStarted: Ihr Authorisierungs-Token ist noch nicht gültig. UnauthorizedTokenInvalid: Ihr Authorisierungs-Token konnte nicht verarbeitet werden. UnauthorizedTokenInvalidRoute: Ihr Authorisierungs-Token ist auf dieser Unterseite nicht gültig. +UnauthorizedTokenInvalidAuthority: Ihr Authorisierungs-Token basiert auf den Rechten eines Nutzers, der nicht mehr existiert. +UnauthorizedToken404: Authorisierungs-Tokens können nicht auf Fehlerseiten ausgewertet werden. UnauthorizedSiteAdmin: Sie sind kein System-weiter Administrator. UnauthorizedSchoolAdmin: Sie sind nicht als Administrator für dieses Institut eingetragen. UnauthorizedAdminEscalation: Sie sind nicht Administrator für alle Institute, für die dieser Nutzer Administrator oder Veranstalter ist. diff --git a/package.yaml b/package.yaml index f6d487376..47917503c 100644 --- a/package.yaml +++ b/package.yaml @@ -85,6 +85,7 @@ dependencies: - scientific - tz - system-locale + - th-lift - th-lift-instances - gitrev - Glob diff --git a/src/Data/Aeson/Types/Instances.hs b/src/Data/Aeson/Types/Instances.hs index f785576f2..66ff1df61 100644 --- a/src/Data/Aeson/Types/Instances.hs +++ b/src/Data/Aeson/Types/Instances.hs @@ -6,9 +6,17 @@ module Data.Aeson.Types.Instances import ClassyPrelude -import Data.Aeson.Types (Parser) +import Data.Aeson.Types (Parser, Value) import Control.Monad.Catch +import Data.Binary (Binary) + +import Data.HashMap.Strict.Instances () +import Data.Vector.Instances () + instance MonadThrow Parser where throwM = fail . show + + +instance Binary Value diff --git a/src/Data/HashMap/Strict/Instances.hs b/src/Data/HashMap/Strict/Instances.hs new file mode 100644 index 000000000..7d56f03a8 --- /dev/null +++ b/src/Data/HashMap/Strict/Instances.hs @@ -0,0 +1,16 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.HashMap.Strict.Instances + ( + ) where + +import ClassyPrelude + +import Data.Binary (Binary(..)) +import Data.HashMap.Strict (HashMap) +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 new file mode 100644 index 000000000..3fc16cd43 --- /dev/null +++ b/src/Data/HashSet/Instances.hs @@ -0,0 +1,17 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.HashSet.Instances + ( + ) where + +import ClassyPrelude + +import Data.HashSet (HashSet) +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/NonNull/Instances.hs b/src/Data/NonNull/Instances.hs index 1a11a66d9..55981d6ff 100644 --- a/src/Data/NonNull/Instances.hs +++ b/src/Data/NonNull/Instances.hs @@ -8,6 +8,9 @@ import ClassyPrelude import Data.Aeson +import Data.Binary (Binary) +import qualified Data.Binary as Binary + instance ToJSON a => ToJSON (NonNull a) where toJSON = toJSON . toNullable @@ -18,3 +21,8 @@ instance (FromJSON a, MonoFoldable a) => FromJSON (NonNull a) where instance Hashable a => Hashable (NonNull a) where hashWithSalt s = hashWithSalt s . toNullable + + +instance (Binary a, MonoFoldable a) => Binary (NonNull a) where + get = Binary.get >>= maybe (fail "Expected non-empty structure") return . fromNullable + put = Binary.put . toNullable diff --git a/src/Data/Time/Clock/Instances.hs b/src/Data/Time/Clock/Instances.hs new file mode 100644 index 000000000..1783ac465 --- /dev/null +++ b/src/Data/Time/Clock/Instances.hs @@ -0,0 +1,26 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.Time.Clock.Instances + ( + ) where + +import ClassyPrelude + +import Data.Time.Clock + +import Data.Binary (Binary) +import qualified Data.Binary as Binary + + +deriving instance Generic UTCTime + + +instance Binary Day where + get = ModifiedJulianDay <$> Binary.get + put = Binary.put . toModifiedJulianDay + +instance Binary DiffTime where + get = fromRational <$> Binary.get + put = Binary.put . toRational + +instance Binary UTCTime diff --git a/src/Data/Vector/Instances.hs b/src/Data/Vector/Instances.hs new file mode 100644 index 000000000..953130328 --- /dev/null +++ b/src/Data/Vector/Instances.hs @@ -0,0 +1,18 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.Vector.Instances + ( + ) where + +import ClassyPrelude + +import Data.Vector (Vector) +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/Foundation.hs b/src/Foundation.hs index 40dc1246c..cc919e8b4 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -15,7 +15,6 @@ import Auth.LDAP import Auth.PWHash import Auth.Dummy import Jobs.Types -import Model.Token import qualified Network.Wai as W (pathInfo) @@ -57,7 +56,7 @@ import Data.Conduit.List (sourceList) import qualified Database.Esqueleto as E -import Control.Monad.Except (MonadError(..), runExceptT) +import Control.Monad.Except (MonadError(..), ExceptT, runExceptT) import Control.Monad.Trans.Maybe (MaybeT(..)) import Control.Monad.Trans.Reader (runReader, mapReaderT) import Control.Monad.Trans.Writer (WriterT(..), runWriterT) @@ -152,7 +151,7 @@ mkYesodData "UniWorX" $(parseRoutesFile "routes") deriving instance Generic (Route UniWorX) -- | Convenient Type Synonyms: -type DB a = YesodDB UniWorX a +type DB = YesodDB UniWorX type Form x = Html -> MForm (HandlerT UniWorX IO) (FormResult x, Widget) type MsgRenderer = MsgRendererS UniWorX -- see Utils type MailM a = MailT (HandlerT UniWorX IO) a @@ -403,12 +402,6 @@ newtype InvalidAuthTag = InvalidAuthTag Text deriving (Eq, Ord, Show, Read, Generic, Typeable) instance Exception InvalidAuthTag -data SessionAuthTags = SessionActiveAuthTags | SessionInactiveAuthTags - deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) -instance Universe SessionAuthTags -instance Finite SessionAuthTags -nullaryPathPiece ''SessionAuthTags (camelToPathPiece' 1) - data AccessPredicate = APPure (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Reader MsgRenderer AuthResult) @@ -453,6 +446,47 @@ trueAP = APPure . const . const . const $ trueAR <$> ask falseAP = APPure . const . const . const $ falseAR <$> ask -- included for completeness +askTokenUnsafe :: forall m. + ( MonadHandler m + , HandlerSite m ~ UniWorX + , MonadLogger m + , MonadCatch m + ) + => ExceptT AuthResult m (BearerToken (UniWorX)) +-- | 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 + 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' + 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) + + User{userTokensIssuedAfter} <- maybeMExceptT (unauthorizedI MsgUnauthorizedTokenInvalidAuthority) $ get tokenAuthority + guardMExceptT (Just tokenIssuedAt >= userTokensIssuedAfter) (unauthorizedI MsgUnauthorizedTokenExpired) + + authorityVal <- do + dnf <- either throwM return $ routeAuthTags route + fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ (/=) AuthToken) dnf (Just tokenAuthority) route isWrite + guardExceptT (is _Authorized authorityVal) authorityVal + + whenIsJust tokenAddAuth $ \addDNF -> do + additionalVal <- fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ (/=) AuthToken) addDNF mAuthId route isWrite + guardExceptT (is _Authorized additionalVal) additionalVal + + return Authorized + + tagAccessPredicate :: AuthTag -> AccessPredicate tagAccessPredicate AuthFree = trueAP tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of @@ -474,30 +508,8 @@ tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of adrights <- lift $ selectFirst [UserAdminUser ==. authId] [] guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedSiteAdmin) return Authorized -tagAccessPredicate AuthToken = APDB $ \mAuthId route isWrite -> exceptT return return $ do - jwt <- maybeMExceptT (unauthorizedI MsgUnauthorizedNoToken) askJwt - BearerToken{..} <- catch (decodeToken jwt) $ \case - BearerTokenExpired -> throwError =<< unauthorizedI MsgUnauthorizedTokenExpired - BearerTokenNotStarted -> throwError =<< unauthorizedI MsgUnauthorizedTokenNotStarted - other -> do - $logWarnS "AuthToken" $ tshow other - throwError =<< unauthorizedI MsgUnauthorizedTokenInvalid - - guardMExceptT (maybe True (HashSet.member route) tokenRoutes) (unauthorizedI MsgUnauthorizedTokenInvalidRoute) - - User{userTokensIssuedAfter} <- lift $ get404 tokenAuthority - guardMExceptT (Just tokenIssuedAt >= userTokensIssuedAfter) (unauthorizedI MsgUnauthorizedTokenExpired) - - authorityVal <- do - dnf <- either throwM return $ routeAuthTags route - fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ (/=) AuthToken) dnf (Just tokenAuthority) route isWrite - guardExceptT (is _Authorized authorityVal) authorityVal - - whenIsJust tokenAddAuth $ \addDNF -> do - additionalVal <- fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ (/=) AuthToken) addDNF mAuthId route isWrite - guardExceptT (is _Authorized additionalVal) additionalVal - - return Authorized +tagAccessPredicate AuthToken = APDB $ \mAuthId route isWrite -> exceptT return return $ + lift . validateToken mAuthId route isWrite =<< askTokenUnsafe tagAccessPredicate AuthNoEscalation = APDB $ \mAuthId route _ -> case route of AdminHijackUserR cID -> exceptT return return $ do myUid <- maybeExceptT AuthenticationRequired $ return mAuthId diff --git a/src/Handler/Admin.hs b/src/Handler/Admin.hs index 4e4b07eee..32f8db822 100644 --- a/src/Handler/Admin.hs +++ b/src/Handler/Admin.hs @@ -286,9 +286,6 @@ instance Button UniWorX ButtonAdminStudyTerms where btnClasses BtnCandidatesDeleteAll = [BCIsButton, BCDanger] -- END Button needed only here -sessionKeyNewStudyTerms :: Text -sessionKeyNewStudyTerms = "key-new-study-terms" - getAdminFeaturesR, postAdminFeaturesR :: Handler Html getAdminFeaturesR = postAdminFeaturesR postAdminFeaturesR = do @@ -304,7 +301,7 @@ postAdminFeaturesR = do unless (null infAmbiguous) . addMessageI Info . MsgAmbiguousCandidatesRemoved $ length infAmbiguous unless (null infRedundant) . addMessageI Info . MsgRedundantCandidatesRemoved $ length infRedundant let newKeys = map (StudyTermsKey' . fst) infAccepted - setSessionJson sessionKeyNewStudyTerms newKeys + setSessionJson SessionNewStudyTerms newKeys if | null infAccepted -> addMessageI Info MsgNoCandidatesInferred | otherwise @@ -322,7 +319,7 @@ postAdminFeaturesR = do Candidates.conflicts _other -> runDB Candidates.conflicts - newStudyTermKeys <- fromMaybe [] <$> lookupSessionJson sessionKeyNewStudyTerms + newStudyTermKeys <- fromMaybe [] <$> lookupSessionJson SessionNewStudyTerms ( (degreeResult,degreeTable) , (studyTermsResult,studytermsTable) , ((), candidateTable)) <- runDB $ (,,) diff --git a/src/Handler/Course.hs b/src/Handler/Course.hs index 848faf0e7..c084c139f 100644 --- a/src/Handler/Course.hs +++ b/src/Handler/Course.hs @@ -1104,26 +1104,18 @@ postCLecInviteR tid ssh csh email = do iRes <- getBy404 $ UniqueLecturerInvitation email cid return (cRes, iRes) - ((btnResult, btnInnerWidget), btnEncoding) <- runFormPost $ \csrf -> do + ((btnResult, btnInnerWidget), btnEncoding) <- runFormPost . formEmbedJwtPost $ \csrf -> do (ltRes, ltView) <- case lecturerInvitationType of Nothing -> mreq (selectField optionsFinite) "" Nothing Just lType -> mforced (selectField optionsFinite) "" lType (btnRes, btnWdgt) <- buttonForm mempty return ((,) <$> ltRes <*> btnRes, toWidget csrf <> fvInput ltView <> btnWdgt) - mJwt <- askJwt - let btnWidget = wrapForm btnInnerWidget' def + let btnWidget = wrapForm btnInnerWidget def { formEncoding = btnEncoding , formAction = Just . SomeRoute . CourseR tid ssh csh $ CLecInviteR email , formSubmit = FormNoSubmit } - btnInnerWidget' - = [whamlet| - $newline never - $maybe jwt <- mJwt - - ^{btnInnerWidget} - |] formResult btnResult $ \case (lType, BtnLecInvAccept) -> do diff --git a/src/Handler/Profile.hs b/src/Handler/Profile.hs index 71e718da9..aa1593ea2 100644 --- a/src/Handler/Profile.hs +++ b/src/Handler/Profile.hs @@ -587,21 +587,14 @@ postUserNotificationR cID = do uid <- decrypt cID User{userNotificationSettings, userDisplayName} <- runDB $ get404 uid - ((nsRes, nsInnerWdgt), nsEnc) <- runFormPost . renderAForm FormStandard . notificationForm $ Just userNotificationSettings + ((nsRes, nsInnerWdgt), nsEnc) <- runFormPost . formEmbedJwtPost . renderAForm FormStandard . notificationForm $ Just userNotificationSettings mJwt <- askJwt isModal <- hasCustomHeader HeaderIsModal - let formWidget = wrapForm nsInnerWdgt' def + let formWidget = wrapForm nsInnerWdgt def { formAction = Just . SomeRoute $ UserNotificationR cID , formEncoding = nsEnc , formAttrs = [ ("data-ajax-submit", "") | isModal ] } - nsInnerWdgt' - = [whamlet| - $newline never - $maybe jwt <- mJwt - - ^{nsInnerWdgt} - |] formResultModal nsRes (UserNotificationR cID, [ (toPathPiece GetBearer, toPathPiece jwt) | Just jwt <- pure mJwt ]) $ \ns -> do lift . runDB $ update uid [ UserNotificationSettings =. ns ] diff --git a/src/Handler/Utils/Tokens.hs b/src/Handler/Utils/Tokens.hs new file mode 100644 index 000000000..00a0cdbe7 --- /dev/null +++ b/src/Handler/Utils/Tokens.hs @@ -0,0 +1,27 @@ +module Handler.Utils.Tokens + ( maybeBearerToken, requireBearerToken + ) where + +import Import + +import Utils.Lens + +import Control.Monad.Trans.Maybe (runMaybeT) + + +maybeBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m) => m (Maybe (BearerToken UniWorX)) +maybeBearerToken = runMaybeT $ catchIfMaybeT cPred requireBearerToken + where + cPred err = any ($ err) + [ is $ _HCError . _PermissionDenied + , is $ _HCError . _NotAuthenticated + ] + +requireBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX) => m (BearerToken UniWorX) +requireBearerToken = liftHandlerT $ do + token <- exceptT (guardAuthResult >=> error "askToken should not throw `Authorized`") return $ askTokenUnsafe + mAuthId <- maybeAuthId + currentRoute <- maybe (permissionDeniedI MsgUnauthorizedToken404) return =<< getCurrentRoute + isWrite <- isWriteRequest currentRoute + guardAuthResult <=< runDB $ validateToken mAuthId currentRoute isWrite token + return token diff --git a/src/Import.hs b/src/Import.hs index 9743e86ac..27dc6e5df 100644 --- a/src/Import.hs +++ b/src/Import.hs @@ -6,4 +6,3 @@ import Foundation as Import import Import.NoFoundation as Import import Utils.SystemMessage as Import -import Model.Token as Import diff --git a/src/Import/NoFoundation.hs b/src/Import/NoFoundation.hs index 2c89df2a1..9b7114837 100644 --- a/src/Import/NoFoundation.hs +++ b/src/Import/NoFoundation.hs @@ -9,6 +9,7 @@ import Model.Types.JSON as Import import Model.Migration as Import import Model.Rating as Import import Model.Submission as Import +import Model.Tokens as Import import Settings as Import import Settings.StaticFiles as Import import Yesod.Auth as Import @@ -20,6 +21,10 @@ import Utils.Frontend.I18n as Import import Yesod.Core.Json as Import (provideJson) import Yesod.Core.Types.Instances as Import (CachedMemoT(..)) +import Language.Haskell.TH.Instances as Import () + +import Utils.Tokens as Import + import Data.Fixed as Import @@ -49,6 +54,10 @@ import Data.Text.Encoding.Error as Import(UnicodeException(..)) import Data.Semigroup as Import (Semigroup) import Data.Monoid as Import (Last(..), First(..)) import Data.Monoid.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.Binary as Import (Binary) diff --git a/src/Language/Haskell/TH/Instances.hs b/src/Language/Haskell/TH/Instances.hs new file mode 100644 index 000000000..48c419705 --- /dev/null +++ b/src/Language/Haskell/TH/Instances.hs @@ -0,0 +1,14 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Language.Haskell.TH.Instances + ( + ) where + +import Language.Haskell.TH +import Language.Haskell.TH.Lift (deriveLift) +import Data.Binary (Binary) + + +instance Binary Loc + +deriveLift ''Loc diff --git a/src/Mail.hs b/src/Mail.hs index 008af9987..283de2deb 100644 --- a/src/Mail.hs +++ b/src/Mail.hs @@ -27,6 +27,7 @@ module Mail , setSubjectI, setMailObjectId, setMailObjectId' , setDate, setDateCurrent , setMailSmtpData + , _addressName, _addressEmail , _mailFrom, _mailTo, _mailCc, _mailBcc, _mailHeaders, _mailHeader, _mailParts , _partType, _partEncoding, _partFilename, _partHeaders, _partContent ) where @@ -105,6 +106,7 @@ import Data.CaseInsensitive (CI) import qualified Data.CaseInsensitive as CI +makeLenses_ ''Address makeLenses_ ''Mail makeLenses_ ''Part diff --git a/src/Model/Token.hs b/src/Model/Tokens.hs similarity index 51% rename from src/Model/Token.hs rename to src/Model/Tokens.hs index e3b3148cf..2b445eb99 100644 --- a/src/Model/Token.hs +++ b/src/Model/Tokens.hs @@ -1,28 +1,23 @@ {-# LANGUAGE UndecidableInstances #-} -module Model.Token +module Model.Tokens ( BearerToken(..) , _tokenIdentifier, _tokenAuthority, _tokenRoutes, _tokenAddAuth, _tokenRestrictions, _tokenRestrictionIx, _tokenRestrictionAt, _tokenIssuedAt, _tokenIssuedBy, _tokenExpiresAt, _tokenStartsAt , tokenRestrict - , bearerToken - , encodeToken, BearerTokenException(..), decodeToken, jwtEncoding - , tokenToJSON, tokenParseJSON, tokenParseJSON' - , askJwt + , tokenToJSON, tokenParseJSON ) where import ClassyPrelude.Yesod +import Yesod.Core.Instances () + import Model -import Settings -import Utils (NTop(..), hoistMaybe, assertM') +import Utils (assertM') import Utils.Lens hiding ((.=)) import Data.Aeson.Lens (AsJSON(..)) -import Utils.Parameters import Yesod.Auth (AuthId) -import qualified Jose.Jwa as Jose -import Jose.Jwk (JwkSet(..)) -import Jose.Jwt (Jwt(..), IntDate(..)) +import Jose.Jwt (IntDate(..)) import qualified Jose.Jwt as Jose import Jose.Jwt.Instances () @@ -31,21 +26,20 @@ import Data.Aeson.Types.Instances () import Data.HashSet (HashSet) 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 Data.Aeson.Types (Parser, (.:?), (.:), (.!=), (.=)) import qualified Data.Aeson as JSON import qualified Data.Aeson.Types as JSON -import qualified Data.Aeson.Parser as JSON -import qualified Data.Aeson.Parser.Internal as JSON (jsonEOF') -import qualified Data.Aeson.Internal as JSON (iparse, formatError) import CryptoID -import Data.Time.Clock import Data.Time.Clock.POSIX -import Control.Monad.Random (MonadRandom(..)) -import Control.Monad.Trans.Maybe (MaybeT(..)) +import Data.Binary (Binary) + -- | Presenting a `BearerToken` transfers some authorisation from `tokenAuthority` to /whoever/ presents the token @@ -72,6 +66,8 @@ 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 @@ -130,7 +126,7 @@ tokenParseJSON :: forall site. -- -- Monadic context is needed because `AuthId`s are encrypted during encoding -- --- It's usually easier to use `tokenParseJSON'` +-- It's usually easier to use `Utils.Tokens.tokenParseJSON'` tokenParseJSON v@(Object o) = do tokenAuthority' <- lift (o .: "authority") :: ReaderT CryptoIDKey Parser (CryptoUUID (AuthId site)) tokenAuthority <- decrypt tokenAuthority' @@ -151,116 +147,3 @@ tokenParseJSON v@(Object o) = do return BearerToken{..} tokenParseJSON v = lift $ JSON.typeMismatch "BearerToken" v -tokenParseJSON' :: forall m. - ( HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser) - , ParseRoute (HandlerSite m) - , Hashable (Route (HandlerSite m)) - , MonadHandler m - , MonadCrypto m - , MonadCryptoKey m ~ CryptoIDKey - ) - => m (Value -> Parser (BearerToken (HandlerSite m))) --- ^ Read `CryptoIDKey` from monadic context and construct a `Parser` for `BearerToken`s -tokenParseJSON' = do - cidKey <- cryptoIDKey return - return $ flip runReaderT cidKey . tokenParseJSON - - -bearerToken :: forall m. - ( MonadHandler m - , HasInstanceID (HandlerSite m) InstanceId - , HasCryptoUUID (AuthId (HandlerSite m)) m - , HasAppSettings (HandlerSite m) - ) - => AuthId (HandlerSite m) - -> Maybe (HashSet (Route (HandlerSite m))) - -> Maybe AuthDNF - -> Maybe (Maybe UTCTime) -- ^ @Nothing@ determines default expiry time automatically - -> 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 - - defaultExpiration <- getsYesod $ view _appJWTExpiration - - let tokenExpiresAt - | Just t <- mTokenExpiresAt - = t - | Just tDiff <- defaultExpiration - = Just $ tDiff `addUTCTime` fromMaybe tokenIssuedAt tokenStartsAt - | otherwise - = Nothing - tokenRestrictions = HashMap.empty - - return BearerToken{..} - - -jwtEncoding :: Jose.JwtEncoding --- ^ How should `Jwt`s be signed and/or encrypted? -jwtEncoding = Jose.JwsEncoding Jose.HS256 - - -encodeToken :: forall m. - ( MonadHandler m - , HasJSONWebKeySet (HandlerSite m) JwkSet - , HasInstanceID (HandlerSite m) InstanceId - , HasCryptoUUID (AuthId (HandlerSite m)) m - , RenderRoute (HandlerSite m) - ) - => BearerToken (HandlerSite m) -> m Jwt --- ^ Call `tokenToJSON` and encode the result as a `Jwt` according to `jwtEncoding` -encodeToken token = do - payload <- Jose.Claims . toStrict . JSON.encode <$> tokenToJSON token - JwkSet jwks <- getsYesod $ view jsonWebKeySet - either throwM return =<< liftIO (Jose.encode jwks jwtEncoding payload) - - -data BearerTokenException - = BearerTokenJwtError Jose.JwtError -- ^ An Error occurred in the underlying `Jwt`-Implementation - | BearerTokenUnsecured -- ^ `Jwt` is insufficiently secured (unsigned and not encrypted) - | BearerTokenInvalidFormat String -- ^ Content of the `Jwt` could not be parsed as a `BearerToken` - | BearerTokenExpired | BearerTokenNotStarted - deriving (Eq, Show, Generic, Typeable) - -instance Exception BearerTokenException - -decodeToken :: forall m. - ( MonadHandler m - , HasJSONWebKeySet (HandlerSite m) JwkSet - , HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser) - , MonadCryptoKey m ~ CryptoIDKey - , MonadCrypto m - , MonadThrow m - , ParseRoute (HandlerSite m) - , Hashable (Route (HandlerSite m)) - ) - => Jwt -> m (BearerToken (HandlerSite m)) --- ^ Decode a `Jwt` according to `jwtEncoding` and call `tokenParseJSON` -decodeToken (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' - now <- liftIO getCurrentTime - unless (NTop tokenExpiresAt > NTop (Just now)) $ - throwM BearerTokenExpired - unless (tokenStartsAt <= Just now) $ - throwM BearerTokenNotStarted - return token - - -askJwt :: forall m. ( MonadHandler m ) - => m (Maybe Jwt) --- ^ Retrieve current `Jwt` from HTTP-Header, POST-Parameter, or GET-Parameter -askJwt = runMaybeT $ asum - [ MaybeT lookupBearerAuth >>= hoistMaybe . fromPathPiece - , MaybeT $ lookupGlobalPostParam PostBearer - , MaybeT $ lookupGlobalGetParam GetBearer - ] diff --git a/src/Model/Types.hs b/src/Model/Types.hs index 00bda42a1..3978399b4 100644 --- a/src/Model/Types.hs +++ b/src/Model/Types.hs @@ -81,6 +81,7 @@ import Model.Types.Wordlist import Data.Text.Metrics (damerauLevenshtein) import Data.Binary (Binary) +import qualified Data.Binary as Binary instance PathPiece UUID where @@ -795,6 +796,8 @@ instance PathPiece a => PathPiece (PredLiteral a) where fromPathPiece t = PLVariable <$> fromPathPiece t <|> PLNegated <$> (Text.stripPrefix "¬" t >>= fromPathPiece) +instance Binary a => Binary (PredLiteral a) + newtype PredDNF a = PredDNF { dnfTerms :: Set (NonNull (Set (PredLiteral a))) } deriving (Eq, Ord, Read, Show, Generic, Typeable) @@ -802,11 +805,15 @@ newtype PredDNF a = PredDNF { dnfTerms :: Set (NonNull (Set (PredLiteral a))) } $(return []) -instance (Ord a, ToJSON a) => ToJSON (PredDNF a) where +instance ToJSON a => ToJSON (PredDNF a) where toJSON = $(mkToJSON predNFAesonOptions ''PredDNF) instance (Ord a, FromJSON a) => FromJSON (PredDNF a) where parseJSON = $(mkParseJSON predNFAesonOptions ''PredDNF) +instance (Ord a, Binary a) => Binary (PredDNF a) where + get = PredDNF <$> Binary.get + put = Binary.put . dnfTerms + type AuthLiteral = PredLiteral AuthTag type AuthDNF = PredDNF AuthTag diff --git a/src/Settings.hs b/src/Settings.hs index ae2ce4b30..47e0b25e8 100644 --- a/src/Settings.hs +++ b/src/Settings.hs @@ -63,6 +63,9 @@ import Control.Monad.Trans.Maybe (MaybeT(..)) import qualified System.FilePath as FilePath +import Jose.Jwt (JwtEncoding(..)) + + -- | Runtime settings to configure this application. These settings can be -- loaded from various sources: defaults, environment variables, config files, -- theoretically even a database. @@ -100,7 +103,8 @@ data AppSettings = AppSettings , appNotificationExpiration :: NominalDiffTime , appSessionTimeout :: NominalDiffTime , appMaximumContentLength :: Maybe Word64 - , appJWTExpiration :: Maybe NominalDiffTime + , appJwtExpiration :: Maybe NominalDiffTime + , appJwtEncoding :: JwtEncoding , appInitialLogSettings :: LogSettings @@ -311,6 +315,18 @@ deriveFromJSON } ''SmtpAuthConf +instance FromJSON JwtEncoding where + parseJSON v@(String _) = JwsEncoding <$> parseJSON v + parseJSON v = flip (withObject "JwtEncoding") v $ \obj -> asum + [ do + alg <- obj .: "alg" + return $ JwsEncoding alg + , do + alg <- obj .: "alg" + enc <- obj .: "enc" + return $ JweEncoding alg enc + ] + instance FromJSON AppSettings where parseJSON = withObject "AppSettings" $ \o -> do @@ -353,7 +369,8 @@ instance FromJSON AppSettings where appNotificationRateLimit <- o .: "notification-rate-limit" appNotificationCollateDelay <- o .: "notification-collate-delay" appNotificationExpiration <- o .: "notification-expiration" - appJWTExpiration <- o .:? "jwt-expiration" + appJwtExpiration <- o .:? "jwt-expiration" + appJwtEncoding <- o .: "jwt-encoding" appSessionTimeout <- o .: "session-timeout" diff --git a/src/Utils.hs b/src/Utils.hs index a95cb7bfc..68906d803 100644 --- a/src/Utils.hs +++ b/src/Utils.hs @@ -52,6 +52,7 @@ import Control.Monad.Catch hiding (throwM) import qualified Database.Esqueleto as E (Value, unValue) import Language.Haskell.TH +import Language.Haskell.TH.Instances () import Instances.TH.Lift () import Text.Shakespeare.Text (st) @@ -69,6 +70,8 @@ import qualified Crypto.Data.PKCS7 as PKCS7 import Data.Fixed (Centi) import Data.Ratio ((%)) +import qualified Data.Binary as Binary + {-# ANN choice ("HLint: ignore Use asum" :: String) #-} @@ -607,6 +610,15 @@ choice = foldr (<|>) empty -- Sessions -- -------------- +data SessionKey = SessionActiveAuthTags | SessionInactiveAuthTags + | SessionNewStudyTerms + | SessionBearer + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) +instance Universe SessionKey +instance Finite SessionKey + +nullaryPathPiece ''SessionKey $ camelToPathPiece' 1 + setSessionJson :: (PathPiece k, ToJSON v, MonadHandler m) => k -> v -> m () setSessionJson (toPathPiece -> key) (LBS.toStrict . Aeson.encode -> val) = setSessionBS key val @@ -731,3 +743,12 @@ encodedSecretBoxOpen :: ( FromJSON a, MonadError EncodedSecretBoxException m, Mo encodedSecretBoxOpen ciphertext = do sKey <- secretBoxKey encodedSecretBoxOpen' sKey ciphertext + +------------- +-- Caching -- +------------- + +cachedHere :: Q Exp +cachedHere = do + loc <- location + [e| cachedBy (toStrict $ Binary.encode loc) |] diff --git a/src/Utils/Lens.hs b/src/Utils/Lens.hs index 4493dc612..05261e95b 100644 --- a/src/Utils/Lens.hs +++ b/src/Utils/Lens.hs @@ -1,12 +1,16 @@ module Utils.Lens ( module Utils.Lens ) where -import Import.NoFoundation +import ClassyPrelude.Yesod hiding ((.=)) +import Model import Control.Lens as Utils.Lens hiding ((<.>)) import Control.Lens.Extras as Utils.Lens (is) import Utils.Lens.TH as Utils.Lens (makeLenses_, makeClassyFor_) +import Yesod.Core.Types (HandlerContents(..), ErrorResponse(..)) + import qualified Database.Esqueleto as E (Value(..),InnerJoin(..)) + _unValue :: Lens' (E.Value a) a _unValue f (E.Value a) = E.Value <$> f a @@ -70,8 +74,6 @@ hasEntityUser = hasEntity -- hasUser = _entityVal . hasUser -makeLenses_ ''Address - makeLenses_ ''SheetCorrector makeLenses_ ''SubmissionGroup @@ -92,6 +94,10 @@ makeLenses_ ''StudyTerms makeLenses_ ''StudyTermCandidate +makePrisms ''HandlerContents + +makePrisms ''ErrorResponse + -- makeClassy_ ''Load diff --git a/src/Utils/Tokens.hs b/src/Utils/Tokens.hs new file mode 100644 index 000000000..f6533120d --- /dev/null +++ b/src/Utils/Tokens.hs @@ -0,0 +1,174 @@ +module Utils.Tokens + ( bearerToken + , encodeToken, BearerTokenException(..), decodeToken + , tokenParseJSON' + , askJwt + , formEmbedJwtPost, formEmbedJwtGet + ) where + +import ClassyPrelude.Yesod + +import Yesod.Auth (AuthId) + +import Utils (NTop(..), hoistMaybe, SessionKey(..)) +import Utils.Parameters +import Utils.Lens hiding ((.=)) + +import Model +import Model.Tokens + +import Jose.Jwk (JwkSet(..)) +import Jose.Jwt (Jwt(..)) +import qualified Jose.Jwt as Jose + +import Data.Aeson.Types (Parser) +import qualified Data.Aeson as JSON +import qualified Data.Aeson.Parser as JSON +import qualified Data.Aeson.Parser.Internal as JSON (jsonEOF') +import qualified Data.Aeson.Internal as JSON (iparse, formatError) + +import qualified Data.HashMap.Strict as HashMap + +import Data.Time.Clock + +import Control.Monad.Random (MonadRandom(..)) +import Control.Monad.Trans.Maybe (MaybeT(..)) + +import Settings + +import CryptoID + +import Text.Blaze (Markup) + + +tokenParseJSON' :: forall m. + ( HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser) + , ParseRoute (HandlerSite m) + , Hashable (Route (HandlerSite m)) + , MonadHandler m + , MonadCrypto m + , MonadCryptoKey m ~ CryptoIDKey + ) + => m (Value -> Parser (BearerToken (HandlerSite m))) +-- ^ Read `CryptoIDKey` from monadic context and construct a `Parser` for `BearerToken`s +tokenParseJSON' = do + cidKey <- cryptoIDKey return + return $ flip runReaderT cidKey . tokenParseJSON + + +bearerToken :: forall m. + ( MonadHandler m + , HasInstanceID (HandlerSite m) InstanceId + , HasCryptoUUID (AuthId (HandlerSite m)) m + , HasAppSettings (HandlerSite m) + ) + => AuthId (HandlerSite m) + -> Maybe (HashSet (Route (HandlerSite m))) + -> Maybe AuthDNF + -> Maybe (Maybe UTCTime) -- ^ @Nothing@ determines default expiry time automatically + -> 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 + + defaultExpiration <- getsYesod $ view _appJwtExpiration + + let tokenExpiresAt + | Just t <- mTokenExpiresAt + = t + | Just tDiff <- defaultExpiration + = Just $ tDiff `addUTCTime` fromMaybe tokenIssuedAt tokenStartsAt + | otherwise + = Nothing + tokenRestrictions = HashMap.empty + + return BearerToken{..} + + +encodeToken :: forall m. + ( MonadHandler m + , HasJSONWebKeySet (HandlerSite m) JwkSet + , HasInstanceID (HandlerSite m) InstanceId + , HasAppSettings (HandlerSite m) + , HasCryptoUUID (AuthId (HandlerSite m)) 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 + JwkSet jwks <- getsYesod $ view jsonWebKeySet + jwtEncoding <- getsYesod $ view _appJwtEncoding + either throwM return =<< liftIO (Jose.encode jwks jwtEncoding payload) + + +data BearerTokenException + = BearerTokenJwtError Jose.JwtError -- ^ An Error occurred in the underlying `Jwt`-Implementation + | BearerTokenUnsecured -- ^ `Jwt` is insufficiently secured (unsigned and not encrypted) + | BearerTokenInvalidFormat String -- ^ Content of the `Jwt` could not be parsed as a `BearerToken` + | BearerTokenExpired | BearerTokenNotStarted + deriving (Eq, Show, Generic, Typeable) + +instance Exception BearerTokenException + +decodeToken :: forall m. + ( MonadHandler m + , HasJSONWebKeySet (HandlerSite m) JwkSet + , HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser) + , MonadCryptoKey m ~ CryptoIDKey + , MonadCrypto m + , MonadThrow m + , ParseRoute (HandlerSite m) + , Hashable (Route (HandlerSite m)) + ) + => Jwt -> m (BearerToken (HandlerSite m)) +-- ^ Decode a `Jwt` and call `tokenParseJSON` +-- +-- Throws `bearerTokenException`s +decodeToken (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' + now <- liftIO getCurrentTime + unless (NTop tokenExpiresAt > NTop (Just now)) $ + throwM BearerTokenExpired + unless (tokenStartsAt <= Just now) $ + throwM BearerTokenNotStarted + return token + + +askJwt :: forall m. ( MonadHandler m ) + => m (Maybe Jwt) +-- ^ Retrieve current `Jwt` from HTTP-Header, POST-Parameter, or GET-Parameter +askJwt = 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 + f [shamlet| + $newline never + $maybe jwt <- mJwt + + #{fragment} + |] +formEmbedJwtGet f fragment = do + mJwt <- askJwt + f [shamlet| + $newline never + $maybe jwt <- mJwt + + #{fragment} + |]