This removes one more dependency on Template Haskell.
201 lines
9.1 KiB
Haskell
201 lines
9.1 KiB
Haskell
{-|
|
|
Module : PostgREST.Auth
|
|
Description : PostgREST authentication functions.
|
|
|
|
This module provides functions to deal with the JWT authentication (http://jwt.io).
|
|
It also can be used to define other authentication functions,
|
|
in the future Oauth, LDAP and similar integrations can be coded here.
|
|
|
|
Authentication should always be implemented in an external service.
|
|
In the test suite there is an example of simple login function that can be used for a
|
|
very simple authentication system inside the PostgreSQL database.
|
|
-}
|
|
{-# LANGUAGE RecordWildCards #-}
|
|
module PostgREST.Auth
|
|
( AuthResult (..)
|
|
, getResult
|
|
, getJwtDur
|
|
, getRole
|
|
, middleware
|
|
) where
|
|
|
|
import qualified Data.Aeson as JSON
|
|
import qualified Data.Aeson.Key as K
|
|
import qualified Data.Aeson.KeyMap as KM
|
|
import qualified Data.Aeson.Types as JSON
|
|
import qualified Data.ByteString as BS
|
|
import qualified Data.ByteString.Lazy.Char8 as LBS
|
|
import qualified Data.Cache as C
|
|
import qualified Data.Scientific as Sci
|
|
import qualified Data.Vault.Lazy as Vault
|
|
import qualified Data.Vector as V
|
|
import qualified Jose.Jwk as JWT
|
|
import qualified Jose.Jwt as JWT
|
|
import qualified Network.HTTP.Types.Header as HTTP
|
|
import qualified Network.Wai as Wai
|
|
import qualified Network.Wai.Middleware.HttpAuth as Wai
|
|
|
|
import Control.Monad.Except (liftEither)
|
|
import Data.Either.Combinators (mapLeft)
|
|
import Data.List (lookup)
|
|
import Data.Time.Clock (UTCTime, nominalDiffTimeToSeconds)
|
|
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
|
|
import System.Clock (TimeSpec (..))
|
|
import System.IO.Unsafe (unsafePerformIO)
|
|
import System.TimeIt (timeItT)
|
|
|
|
import PostgREST.AppState (AppState, AuthResult (..), getConfig,
|
|
getJwtCache, getTime)
|
|
import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..))
|
|
import PostgREST.Error (Error (..))
|
|
|
|
import Protolude
|
|
|
|
|
|
-- | Receives the JWT secret and audience (from config) and a JWT and returns a
|
|
-- JSON object of JWT claims.
|
|
parseToken :: AppConfig -> ByteString -> UTCTime -> ExceptT Error IO JSON.Value
|
|
parseToken _ "" _ = return JSON.emptyObject
|
|
parseToken AppConfig{..} token time = do
|
|
secret <- liftEither . maybeToRight JwtTokenMissing $ configJWKS
|
|
eitherContent <- liftIO $ JWT.decode (JWT.keys secret) Nothing token
|
|
content <- liftEither . mapLeft jwtDecodeError $ eitherContent
|
|
liftEither $ verifyClaims content
|
|
where
|
|
-- TODO: Improve errors, those were just taken as-is from hs-jose to avoid
|
|
-- breaking changes.
|
|
jwtDecodeError :: JWT.JwtError -> Error
|
|
jwtDecodeError (JWT.KeyError _) = JwtTokenInvalid "JWSError JWSInvalidSignature"
|
|
jwtDecodeError JWT.BadCrypto = JwtTokenInvalid "JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"
|
|
jwtDecodeError (JWT.BadAlgorithm _) = JwtTokenInvalid "JWSError JWSNoSignatures"
|
|
jwtDecodeError e = JwtTokenInvalid $ show e
|
|
|
|
verifyClaims :: JWT.JwtContent -> Either Error JSON.Value
|
|
verifyClaims (JWT.Jws (_, claims)) = case JSON.decodeStrict claims of
|
|
Nothing -> Left $ JwtTokenInvalid "Parsing claims failed"
|
|
Just (JSON.Object mclaims)
|
|
| failedExpClaim mclaims -> Left $ JwtTokenInvalid "JWT expired"
|
|
| failedNbfClaim mclaims -> Left $ JwtTokenInvalid "JWTNotYetValid"
|
|
| failedIatClaim mclaims -> Left $ JwtTokenInvalid "JWTIssuedAtFuture"
|
|
| failedAudClaim mclaims -> Left $ JwtTokenInvalid "JWTNotInAudience"
|
|
Just jclaims -> Right jclaims
|
|
-- TODO: We could enable JWE support here (encrypted tokens)
|
|
verifyClaims _ = Left $ JwtTokenInvalid "Unsupported token type"
|
|
|
|
allowedSkewSeconds = 30 :: Int64
|
|
now = floor . nominalDiffTimeToSeconds $ utcTimeToPOSIXSeconds time
|
|
sciToInt = fromMaybe 0 . Sci.toBoundedInteger
|
|
|
|
failedExpClaim :: KM.KeyMap JSON.Value -> Bool
|
|
failedExpClaim mclaims = case KM.lookup "exp" mclaims of
|
|
Just (JSON.Number secs) -> now > (sciToInt secs + allowedSkewSeconds)
|
|
_ -> False
|
|
|
|
failedNbfClaim :: KM.KeyMap JSON.Value -> Bool
|
|
failedNbfClaim mclaims = case KM.lookup "nbf" mclaims of
|
|
Just (JSON.Number secs) -> now < (sciToInt secs - allowedSkewSeconds)
|
|
_ -> False
|
|
|
|
failedIatClaim :: KM.KeyMap JSON.Value -> Bool
|
|
failedIatClaim mclaims = case KM.lookup "iat" mclaims of
|
|
Just (JSON.Number secs) -> now < (sciToInt secs - allowedSkewSeconds)
|
|
_ -> False
|
|
|
|
failedAudClaim :: KM.KeyMap JSON.Value -> Bool
|
|
failedAudClaim mclaims = case KM.lookup "aud" mclaims of
|
|
Just (JSON.String str) -> maybe (const False) (/=) configJwtAudience str
|
|
_ -> False
|
|
|
|
parseClaims :: Monad m =>
|
|
AppConfig -> JSON.Value -> ExceptT Error m AuthResult
|
|
parseClaims AppConfig{..} jclaims@(JSON.Object mclaims) = do
|
|
-- role defaults to anon if not specified in jwt
|
|
role <- liftEither . maybeToRight JwtTokenRequired $
|
|
unquoted <$> walkJSPath (Just jclaims) configJwtRoleClaimKey <|> configDbAnonRole
|
|
return AuthResult
|
|
{ authClaims = mclaims & KM.insert "role" (JSON.toJSON $ decodeUtf8 role)
|
|
, authRole = role
|
|
}
|
|
where
|
|
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
|
|
walkJSPath x [] = x
|
|
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (KM.lookup (K.fromText key) o) rest
|
|
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
|
|
walkJSPath _ _ = Nothing
|
|
|
|
unquoted :: JSON.Value -> BS.ByteString
|
|
unquoted (JSON.String t) = encodeUtf8 t
|
|
unquoted v = LBS.toStrict $ JSON.encode v
|
|
-- impossible case - just added to please -Wincomplete-patterns
|
|
parseClaims _ _ = return AuthResult { authClaims = KM.empty, authRole = mempty }
|
|
|
|
-- | Validate authorization header.
|
|
-- Parse and store JWT claims for future use in the request.
|
|
middleware :: AppState -> Wai.Middleware
|
|
middleware appState app req respond = do
|
|
conf <- getConfig appState
|
|
time <- getTime appState
|
|
|
|
let token = fromMaybe "" $ Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
|
|
parseJwt = runExceptT $ parseToken conf token time >>= parseClaims conf
|
|
|
|
-- If DbPlanEnabled -> calculate JWT validation time
|
|
-- If JwtCacheMaxLifetime -> cache JWT validation result
|
|
req' <- case (configServerTimingEnabled conf, configJwtCacheMaxLifetime conf) of
|
|
(True, 0) -> do
|
|
(dur, authResult) <- timeItT parseJwt
|
|
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
|
|
|
|
(True, maxLifetime) -> do
|
|
(dur, authResult) <- timeItT $ getJWTFromCache appState token maxLifetime parseJwt time
|
|
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
|
|
|
|
(False, 0) -> do
|
|
authResult <- parseJwt
|
|
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
|
|
|
|
(False, maxLifetime) -> do
|
|
authResult <- getJWTFromCache appState token maxLifetime parseJwt time
|
|
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
|
|
|
|
app req' respond
|
|
|
|
-- | Used to retrieve and insert JWT to JWT Cache
|
|
getJWTFromCache :: AppState -> ByteString -> Int -> IO (Either Error AuthResult) -> UTCTime -> IO (Either Error AuthResult)
|
|
getJWTFromCache appState token maxLifetime parseJwt utc = do
|
|
checkCache <- C.lookup (getJwtCache appState) token
|
|
authResult <- maybe parseJwt (pure . Right) checkCache
|
|
|
|
case (authResult,checkCache) of
|
|
(Right res, Nothing) -> C.insert' (getJwtCache appState) (getTimeSpec res maxLifetime utc) token res
|
|
_ -> pure ()
|
|
|
|
return authResult
|
|
|
|
-- Used to extract JWT exp claim and add to JWT Cache
|
|
getTimeSpec :: AuthResult -> Int -> UTCTime -> Maybe TimeSpec
|
|
getTimeSpec res maxLifetime utc = do
|
|
let expireJSON = KM.lookup "exp" (authClaims res)
|
|
utcToSecs = floor . nominalDiffTimeToSeconds . utcTimeToPOSIXSeconds
|
|
sciToInt = fromMaybe 0 . Sci.toBoundedInteger
|
|
case expireJSON of
|
|
Just (JSON.Number seconds) -> Just $ TimeSpec (sciToInt seconds - utcToSecs utc) 0
|
|
_ -> Just $ TimeSpec (fromIntegral maxLifetime :: Int64) 0
|
|
|
|
authResultKey :: Vault.Key (Either Error AuthResult)
|
|
authResultKey = unsafePerformIO Vault.newKey
|
|
{-# NOINLINE authResultKey #-}
|
|
|
|
getResult :: Wai.Request -> Maybe (Either Error AuthResult)
|
|
getResult = Vault.lookup authResultKey . Wai.vault
|
|
|
|
jwtDurKey :: Vault.Key Double
|
|
jwtDurKey = unsafePerformIO Vault.newKey
|
|
{-# NOINLINE jwtDurKey #-}
|
|
|
|
getJwtDur :: Wai.Request -> Maybe Double
|
|
getJwtDur = Vault.lookup jwtDurKey . Wai.vault
|
|
|
|
getRole :: Wai.Request -> Maybe BS.ByteString
|
|
getRole req = authRole <$> (rightToMaybe =<< getResult req)
|