refactor: Make JWT authorization a middleware

This follows the style of wai-middleware-auth package and
makes the JWT parsing a middleware.

Co-authored-by: Wolfgang Walther <walther@technowledgy.de>
This commit is contained in:
David Lindbom
2022-01-07 20:26:33 +01:00
committed by Wolfgang Walther
co-authored by Wolfgang Walther
parent d97bf3b864
commit 3c17f97c87
7 changed files with 119 additions and 93 deletions
+1
View File
@@ -108,6 +108,7 @@ library
, text >= 1.2.2 && < 1.3 , text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.11 , time >= 1.6 && < 1.11
, unordered-containers >= 0.2.8 && < 0.3 , unordered-containers >= 0.2.8 && < 0.3
, vault >= 0.3.1.5 && < 0.4
, vector >= 0.11 && < 0.13 , vector >= 0.11 && < 0.13
, wai >= 3.2.1 && < 3.3 , wai >= 3.2.1 && < 3.3
, wai-cors >= 0.2.5 && < 0.3 , wai-cors >= 0.2.5 && < 0.3
+35 -35
View File
@@ -20,8 +20,8 @@ module PostgREST.App
import Control.Monad.Except (liftEither) import Control.Monad.Except (liftEither)
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
import Data.List (union) import Data.List (union)
import Data.Maybe (fromJust)
import Data.String (IsString (..)) import Data.String (IsString (..))
import Data.Time.Clock (UTCTime)
import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort, import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort,
setServerName) setServerName)
import System.Posix.Types (FileMode) import System.Posix.Types (FileMode)
@@ -56,6 +56,7 @@ import qualified PostgREST.Request.ApiRequest as ApiRequest
import qualified PostgREST.Request.DbRequestBuilder as ReqBuilder import qualified PostgREST.Request.DbRequestBuilder as ReqBuilder
import PostgREST.AppState (AppState) import PostgREST.AppState (AppState)
import PostgREST.Auth (AuthResult (..))
import PostgREST.Config (AppConfig (..), import PostgREST.Config (AppConfig (..),
LogLevel (..), LogLevel (..),
OpenAPIMode (..)) OpenAPIMode (..))
@@ -149,28 +150,32 @@ serverSettings AppConfig{..} =
-- | PostgREST application -- | PostgREST application
postgrest :: LogLevel -> AppState.AppState -> IO () -> Wai.Application postgrest :: LogLevel -> AppState.AppState -> IO () -> Wai.Application
postgrest logLevel appState connWorker = postgrest logLevel appState connWorker =
Logger.middleware logLevel . Cors.middleware .
Cors.middleware $ Auth.middleware appState .
\req respond -> do Logger.middleware logLevel $
time <- AppState.getTime appState -- fromJust can be used, because the auth middleware will **always** add
conf <- AppState.getConfig appState -- some AuthResult to the vault.
maybeDbStructure <- AppState.getDbStructure appState \req respond -> case fromJust $ Auth.getResult req of
pgVer <- AppState.getPgVersion appState Left err -> respond $ Error.errorResponseFor err
jsonDbS <- AppState.getJsonDbS appState Right authResult -> do
conf <- AppState.getConfig appState
maybeDbStructure <- AppState.getDbStructure appState
pgVer <- AppState.getPgVersion appState
jsonDbS <- AppState.getJsonDbS appState
let let
eitherResponse :: IO (Either Error Wai.Response) eitherResponse :: IO (Either Error Wai.Response)
eitherResponse = eitherResponse =
runExceptT $ postgrestResponse conf maybeDbStructure jsonDbS pgVer (AppState.getPool appState) time req runExceptT $ postgrestResponse conf maybeDbStructure jsonDbS pgVer (AppState.getPool appState) authResult req
response <- either Error.errorResponseFor identity <$> eitherResponse response <- either Error.errorResponseFor identity <$> eitherResponse
-- Launch the connWorker when the connection is down. The postgrest -- Launch the connWorker when the connection is down. The postgrest
-- function can respond successfully (with a stale schema cache) before -- function can respond successfully (with a stale schema cache) before
-- the connWorker is done. -- the connWorker is done.
let isPGAway = Wai.responseStatus response == HTTP.status503 let isPGAway = Wai.responseStatus response == HTTP.status503
when isPGAway connWorker when isPGAway connWorker
resp <- addRetryHint isPGAway appState response resp <- addRetryHint isPGAway appState response
respond resp respond resp
addRetryHint :: Bool -> AppState -> Wai.Response -> IO Wai.Response addRetryHint :: Bool -> AppState -> Wai.Response -> IO Wai.Response
addRetryHint shouldAdd appState response = do addRetryHint shouldAdd appState response = do
@@ -184,10 +189,10 @@ postgrestResponse
-> ByteString -> ByteString
-> PgVersion -> PgVersion
-> SQL.Pool -> SQL.Pool
-> UTCTime -> AuthResult
-> Wai.Request -> Wai.Request
-> Handler IO Wai.Response -> Handler IO Wai.Response
postgrestResponse conf maybeDbStructure jsonDbS pgVer pool time req = do postgrestResponse conf@AppConfig{..} maybeDbStructure jsonDbS pgVer pool AuthResult{..} req = do
body <- lift $ Wai.strictRequestBody req body <- lift $ Wai.strictRequestBody req
dbStructure <- dbStructure <-
@@ -197,30 +202,25 @@ postgrestResponse conf maybeDbStructure jsonDbS pgVer pool time req = do
Nothing -> Nothing ->
throwError Error.NoSchemaCacheError throwError Error.NoSchemaCacheError
apiRequest@ApiRequest{..} <- apiRequest <-
liftEither . mapLeft Error.ApiRequestError $ liftEither . mapLeft Error.ApiRequestError $
ApiRequest.userApiRequest conf dbStructure req body ApiRequest.userApiRequest conf dbStructure req body
-- The JWT must be checked before touching the db let handleReq apiReq = handleRequest $ RequestContext conf dbStructure apiReq pgVer
jwtClaims <- Auth.jwtClaims conf (toUtf8Lazy iJWT) time
let runDbHandler pool (txMode apiRequest) (authRole /= configDbAnonRole) configDbPreparedStatements .
handleReq apiReq =
handleRequest $ RequestContext conf dbStructure apiReq pgVer
runDbHandler pool (txMode apiRequest) jwtClaims (configDbPreparedStatements conf) .
Middleware.optionalRollback conf apiRequest $ Middleware.optionalRollback conf apiRequest $
Middleware.runPgLocals conf jwtClaims handleReq apiRequest jsonDbS pgVer Middleware.runPgLocals conf authClaims authRole handleReq apiRequest jsonDbS pgVer
runDbHandler :: SQL.Pool -> SQL.Mode -> Auth.JWTClaims -> Bool -> DbHandler a -> Handler IO a runDbHandler :: SQL.Pool -> SQL.Mode -> Bool -> Bool -> DbHandler a -> Handler IO a
runDbHandler pool mode jwtClaims prepared handler = do runDbHandler pool mode authenticated prepared handler = do
dbResp <- dbResp <-
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in
lift . SQL.use pool . transaction SQL.ReadCommitted mode $ runExceptT handler lift . SQL.use pool . transaction SQL.ReadCommitted mode $ runExceptT handler
resp <- resp <-
liftEither . mapLeft Error.PgErr $ liftEither . mapLeft Error.PgErr $
mapLeft (Error.PgError $ Auth.containsRole jwtClaims) dbResp mapLeft (Error.PgError authenticated) dbResp
liftEither resp liftEither resp
+70 -33
View File
@@ -12,40 +12,54 @@ very simple authentication system inside the PostgreSQL database.
-} -}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.Auth module PostgREST.Auth
( containsRole ( AuthResult (..)
, jwtClaims , getResult
, JWTClaims , getRole
, middleware
) where ) where
import qualified Crypto.JWT as JWT import qualified Crypto.JWT as JWT
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M import qualified Data.Aeson.Types as JSON
import qualified Data.Vector as V import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.HashMap.Strict as M
import qualified Data.Text.Encoding as T
import qualified Data.Vault.Lazy as Vault
import qualified Data.Vector as V
import qualified Network.HTTP.Types.Header as HTTP
import qualified Network.Wai as Wai
import qualified Network.Wai.Middleware.HttpAuth as Wai
import Control.Lens (set) import Control.Lens (set)
import Control.Monad.Except (liftEither) import Control.Monad.Except (liftEither)
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft, mapRight)
import Data.List (lookup)
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime)
import System.IO.Unsafe (unsafePerformIO)
import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..)) import PostgREST.AppState (AppState, getConfig, getTime)
import PostgREST.Error (Error (..)) import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..))
import PostgREST.Error (Error (..))
import Protolude import Protolude
type JWTClaims = M.HashMap Text JSON.Value data AuthResult = AuthResult
{ authClaims :: M.HashMap Text JSON.Value
, authRole :: Text
}
-- | Receives the JWT secret and audience (from config) and a JWT and returns a -- | Receives the JWT secret and audience (from config) and a JWT and returns a
-- map of JWT claims. -- JSON object of JWT claims.
jwtClaims :: Monad m => parseToken :: Monad m =>
AppConfig -> LByteString -> UTCTime -> ExceptT Error m JWTClaims AppConfig -> LByteString -> UTCTime -> ExceptT Error m JSON.Value
jwtClaims _ "" _ = return M.empty parseToken _ "" _ = return JSON.emptyObject
jwtClaims AppConfig{..} payload time = do parseToken AppConfig{..} token time = do
secret <- liftEither . maybeToRight JwtTokenMissing $ configJWKS secret <- liftEither . maybeToRight JwtTokenMissing $ configJWKS
eitherClaims <- eitherClaims <-
lift . runExceptT $ lift . runExceptT $
JWT.verifyClaimsAt validation secret time =<< JWT.decodeCompact payload JWT.verifyClaimsAt validation secret time =<< JWT.decodeCompact token
liftEither . mapLeft jwtClaimsError $ claimsMap configJwtRoleClaimKey <$> eitherClaims liftEither . mapLeft jwtClaimsError $ JSON.toJSON <$> eitherClaims
where where
validation = validation =
JWT.defaultJWTValidationSettings audienceCheck & set JWT.allowedSkew 1 JWT.defaultJWTValidationSettings audienceCheck & set JWT.allowedSkew 1
@@ -57,19 +71,15 @@ jwtClaims AppConfig{..} payload time = do
jwtClaimsError JWT.JWTExpired = JwtTokenInvalid "JWT expired" jwtClaimsError JWT.JWTExpired = JwtTokenInvalid "JWT expired"
jwtClaimsError e = JwtTokenInvalid $ show e jwtClaimsError e = JwtTokenInvalid $ show e
-- | Turn JWT ClaimSet into something easier to work with. parseClaims :: AppConfig -> JSON.Value -> AuthResult
-- parseClaims AppConfig{..} jclaims@(JSON.Object mclaims) =
-- Also, here the jspath is applied to put the "role" in the map. AuthResult
claimsMap :: JSPath -> JWT.ClaimsSet -> JWTClaims { authClaims = mclaims & M.insert "role" (JSON.toJSON role)
claimsMap jspath claims = , authRole = role
case JSON.toJSON claims of }
val@(JSON.Object o) ->
M.delete "role" o `M.union` role val
_ ->
M.empty
where where
role value = -- role defaults to anon if not specified in jwt
maybe M.empty (M.singleton "role") $ walkJSPath (Just value) jspath role = maybe configDbAnonRole unquoted (walkJSPath (Just jclaims) configJwtRoleClaimKey)
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
walkJSPath x [] = x walkJSPath x [] = x
@@ -77,6 +87,33 @@ claimsMap jspath claims =
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
walkJSPath _ _ = Nothing walkJSPath _ _ = Nothing
-- | Whether a response from jwtClaims contains a role claim unquoted :: JSON.Value -> Text
containsRole :: JWTClaims -> Bool unquoted (JSON.String t) = t
containsRole = M.member "role" unquoted v = T.decodeUtf8 . LBS.toStrict $ JSON.encode v
-- impossible case - just added to please -Wincomplete-patterns
parseClaims _ _ = AuthResult { authClaims = M.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)
claims <- runExceptT $ parseToken conf (LBS.fromStrict token) time
let
authResult = mapRight (parseClaims conf) claims
req' = req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
app req' respond
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
getRole :: Wai.Request -> Maybe Text
getRole req = authRole <$> (rightToMaybe =<< getResult req)
+6 -8
View File
@@ -20,6 +20,7 @@ import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Transaction as SQL import qualified Hasql.Transaction as SQL
import qualified Network.Wai as Wai import qualified Network.Wai as Wai
import Control.Arrow ((***)) import Control.Arrow ((***))
import Data.Scientific (FPFormat (..), formatScientific, isInteger) import Data.Scientific (FPFormat (..), formatScientific, isInteger)
@@ -37,10 +38,10 @@ import PostgREST.Request.Preferences
import Protolude import Protolude
-- | Runs local(transaction scoped) GUCs for every request, plus the pre-request function -- | Runs local(transaction scoped) GUCs for every request, plus the pre-request function
runPgLocals :: AppConfig -> M.HashMap Text JSON.Value -> runPgLocals :: AppConfig -> M.HashMap Text JSON.Value -> Text ->
(ApiRequest -> ExceptT Error SQL.Transaction Wai.Response) -> (ApiRequest -> ExceptT Error SQL.Transaction Wai.Response) ->
ApiRequest -> ByteString -> PgVersion -> ExceptT Error SQL.Transaction Wai.Response ApiRequest -> ByteString -> PgVersion -> ExceptT Error SQL.Transaction Wai.Response
runPgLocals conf claims app req jsonDbS actualPgVersion = do runPgLocals conf claims role app req jsonDbS actualPgVersion = do
lift $ SQL.statement mempty $ SQL.dynamicallyParameterized lift $ SQL.statement mempty $ SQL.dynamicallyParameterized
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql ++ specSql)) ("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql ++ specSql))
HD.noResult (configDbPreparedStatements conf) HD.noResult (configDbPreparedStatements conf)
@@ -55,13 +56,10 @@ runPgLocals conf claims app req jsonDbS actualPgVersion = do
cookiesSql = if usesLegacyGucs cookiesSql = if usesLegacyGucs
then setConfigLocal "request.cookie." <$> iCookies req then setConfigLocal "request.cookie." <$> iCookies req
else setConfigLocalJson "request.cookies" (iCookies req) else setConfigLocalJson "request.cookies" (iCookies req)
claimsWithRole =
let anon = JSON.String . toS $ configDbAnonRole conf in -- role claim defaults to anon if not specified in jwt
M.union claims (M.singleton "role" anon)
claimsSql = if usesLegacyGucs claimsSql = if usesLegacyGucs
then setConfigLocal "request.jwt.claim." <$> [(toUtf8 c, toUtf8 $ unquoted v) | (c,v) <- M.toList claimsWithRole] then setConfigLocal "request.jwt.claim." <$> [(toUtf8 c, toUtf8 $ unquoted v) | (c,v) <- M.toList claims]
else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claimsWithRole)] else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
roleSql = maybeToList $ (\x -> setConfigLocal mempty ("role", toUtf8 $ unquoted x)) <$> M.lookup "role" claimsWithRole roleSql = [setConfigLocal mempty ("role", toUtf8 role)]
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings conf) appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings conf)
searchPathSql = searchPathSql =
let schemas = T.intercalate ", " (iSchema req : configDbExtraSearchPath conf) in let schemas = T.intercalate ", " (iSchema req : configDbExtraSearchPath conf) in
+1 -9
View File
@@ -25,7 +25,6 @@ import qualified Data.HashMap.Strict as M
import qualified Data.List as L import qualified Data.List as L
import qualified Data.List.NonEmpty as NonEmptyList import qualified Data.List.NonEmpty as NonEmptyList
import qualified Data.Set as S import qualified Data.Set as S
import qualified Data.Text as T
import qualified Data.Text.Encoding as T import qualified Data.Text.Encoding as T
import qualified Data.Vector as V import qualified Data.Vector as V
@@ -34,7 +33,7 @@ import Data.Aeson.Types (emptyArray, emptyObject)
import Data.List (lookup, union) import Data.List (lookup, union)
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import Data.Ranged.Ranges (emptyRange, rangeIntersection) import Data.Ranged.Ranges (emptyRange, rangeIntersection)
import Network.HTTP.Types.Header (hAuthorization, hCookie) import Network.HTTP.Types.Header (hCookie)
import Network.HTTP.Types.URI (parseSimpleQuery) import Network.HTTP.Types.URI (parseSimpleQuery)
import Network.Wai (Request (..)) import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept) import Network.Wai.Parse (parseHttpAccept)
@@ -157,7 +156,6 @@ data ApiRequest = ApiRequest {
, iPreferTransaction :: Maybe PreferTransaction -- ^ Whether the clients wants to commit or rollback the transaction , iPreferTransaction :: Maybe PreferTransaction -- ^ Whether the clients wants to commit or rollback the transaction
, iQueryParams :: QueryParams.QueryParams , iQueryParams :: QueryParams.QueryParams
, iColumns :: S.Set FieldName -- ^ parsed colums from &columns parameter and payload , iColumns :: S.Set FieldName -- ^ parsed colums from &columns parameter and payload
, iJWT :: Text -- ^ JSON Web Token
, iHeaders :: [(ByteString, ByteString)] -- ^ HTTP request headers , iHeaders :: [(ByteString, ByteString)] -- ^ HTTP request headers
, iCookies :: [(ByteString, ByteString)] -- ^ Request Cookies , iCookies :: [(ByteString, ByteString)] -- ^ Request Cookies
, iPath :: ByteString -- ^ Raw request path , iPath :: ByteString -- ^ Raw request path
@@ -195,7 +193,6 @@ apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..
, iPreferTransaction = preferTransaction , iPreferTransaction = preferTransaction
, iQueryParams = queryparams , iQueryParams = queryparams
, iColumns = payloadColumns , iColumns = payloadColumns
, iJWT = tokenStr
, iHeaders = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie] , iHeaders = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie]
, iCookies = maybe [] parseCookies $ lookupHeader "Cookie" , iCookies = maybe [] parseCookies $ lookupHeader "Cookie"
, iPath = rawPathInfo req , iPath = rawPathInfo req
@@ -324,11 +321,6 @@ apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..
hdrs = requestHeaders req hdrs = requestHeaders req
lookupHeader = flip lookup hdrs lookupHeader = flip lookup hdrs
Preferences.Preferences{..} = Preferences.fromHeaders hdrs Preferences.Preferences{..} = Preferences.fromHeaders hdrs
auth = fromMaybe "" $ lookupHeader hAuthorization
tokenStr = case T.split (== ' ') (T.decodeUtf8 auth) of
("Bearer" : t : _) -> t
("bearer" : t : _) -> t
_ -> ""
headerRange = rangeRequested hdrs headerRange = rangeRequested hdrs
ranges = M.insert "limit" (rangeIntersection headerRange (fromMaybe allRange (M.lookup "limit" qsRanges))) qsRanges ranges = M.insert "limit" (rangeIntersection headerRange (fromMaybe allRange (M.lookup "limit" qsRanges))) qsRanges
-6
View File
@@ -73,9 +73,3 @@ create function reload_pgrst_config() returns void as $_$
begin begin
perform pg_notify('pgrst', 'reload config'); perform pg_notify('pgrst', 'reload config');
end $_$ language plpgsql ; end $_$ language plpgsql ;
create or replace function raise_bad_pt() returns void as $$
begin
raise sqlstate 'PT40A' using message = 'Wrong';
end;
$$ language plpgsql;
+6 -2
View File
@@ -847,6 +847,10 @@ def test_log_level(level, has_output, defaultenv):
env = {**defaultenv, "PGRST_LOG_LEVEL": level} env = {**defaultenv, "PGRST_LOG_LEVEL": level}
# expired token to test 500 response for "JWT expired"
claim = {"role": "postgrest_test_author", "exp": 0}
headers = jwtauthheader(claim, SECRET)
with run(env=env) as postgrest: with run(env=env) as postgrest:
response = postgrest.session.get("/") response = postgrest.session.get("/")
assert response.status_code == 200 assert response.status_code == 200
@@ -864,10 +868,10 @@ def test_log_level(level, has_output, defaultenv):
postgrest.process.stdout.readline().decode(), postgrest.process.stdout.readline().decode(),
) )
response = postgrest.session.get("/rpc/raise_bad_pt") response = postgrest.session.get("/", headers=headers)
assert response.status_code == 500 assert response.status_code == 500
if has_output[2]: if has_output[2]:
assert re.match( assert re.match(
r'unknownSocket - - \[.+\] "GET /rpc/raise_bad_pt HTTP/1.1" 500 - "" "python-requests/.+"', r'unknownSocket - - \[.+\] "GET / HTTP/1.1" 500 - "" "python-requests/.+"',
postgrest.process.stdout.readline().decode(), postgrest.process.stdout.readline().decode(),
) )