diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fb3b9ce..3076a4e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Filter columns, e.g. `?select=col1,col2` - @ruslantalpa - Does not execute the count total if header "Prefer: count=none" - @diogob - Postgres connection string argument - @calebmer +- Ensure JWT expires - @calebmer ### Removed - API versioning feature - @calebmer diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 80f13a3f3..a608b684c 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -18,6 +18,7 @@ module PostgREST.Auth ( , tokenJWT ) where +import Control.Monad (join) import Data.Aeson (Value (..), Object) import Data.Aeson.Types (emptyObject, emptyArray) import Data.Vector as V (null, head) @@ -25,6 +26,7 @@ import Data.Map as M (fromList, toList) import Data.Monoid ((<>)) import Data.String.Conversions (cs) import Data.Text (Text) +import Data.Time.Clock (NominalDiffTime) import PostgREST.PgQuery (pgFmtLit, pgFmtIdent, unquoted) import qualified Web.JWT as JWT import qualified Data.HashMap.Lazy as H @@ -49,10 +51,19 @@ claimsToSQL = map setVar . toList returns a map of JWT claims In case there is any problem decoding the JWT it returns Nothing. -} -jwtClaims :: Text -> Text -> Maybe JWT.ClaimsMap -jwtClaims secret input = JWT.unregisteredClaims . JWT.claims <$> decoded +jwtClaims :: Text -> Text -> NominalDiffTime -> Maybe JWT.ClaimsMap +jwtClaims secret input time = + case join $ claim JWT.exp of + Just expires -> + if JWT.secondsSinceEpoch expires > time + then customClaims + else Nothing + _ -> customClaims where decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input + claim :: (JWT.JWTClaimsSet -> a) -> Maybe a + claim prop = prop . JWT.claims <$> decoded + customClaims = claim JWT.unregisteredClaims -- | Receives the name of a role and returns a SET ROLE statement setRole :: Text -> Text diff --git a/src/PostgREST/Main.hs b/src/PostgREST/Main.hs index 311e895cc..15f3ca76d 100644 --- a/src/PostgREST/Main.hs +++ b/src/PostgREST/Main.hs @@ -2,7 +2,6 @@ module Main where import PostgREST.App --- import PostgREST.QueryBuilder import PostgREST.Config (AppConfig (..), minimumPgVersion, prettyVersion, @@ -70,13 +69,6 @@ main = do <> show minimumPgVersion) ) supportedOrError - -- what was this code for? - -- roleOrError <- H.session pool $ do - -- Identity (role :: Text) <- H.tx Nothing $ H.singleEx - -- [H.stmt|SELECT SESSION_USER|] - -- return role - -- authenticator <- either hasqlError return roleOrError - let txSettings = Just (H.ReadCommitted, Just True) metadata <- H.session pool $ H.tx txSettings $ do rels <- allRelations diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs index cc9fb3fa8..ff8a434b6 100644 --- a/src/PostgREST/Middleware.hs +++ b/src/PostgREST/Middleware.hs @@ -6,6 +6,7 @@ module PostgREST.Middleware where import Data.Maybe (fromMaybe, isNothing) import Data.Text import Data.String.Conversions (cs) +import Data.Time.Clock.POSIX (getPOSIXTime) import qualified Hasql as H import qualified Hasql.Postgres as P @@ -21,6 +22,8 @@ import PostgREST.App (contentTypeForAccept) import PostgREST.Auth (setRole, jwtClaims, claimsToSQL) import PostgREST.Config (AppConfig (..), corsPolicy) +import System.IO.Unsafe (unsafePerformIO) + import Prelude hiding(concat) import qualified Data.Vector as V @@ -31,23 +34,27 @@ runWithClaims :: forall s. AppConfig -> (Request -> H.Tx P.Postgres s Response) -> Request -> H.Tx P.Postgres s Response runWithClaims conf app req = do - mapM_ H.unitEx $ stmt <$> env - app req - where - stmt = (flip $ flip B.Stmt V.empty) True - hdrs = requestHeaders req - jwtSecret = (cs $ configJwtSecret conf) :: Text - auth = fromMaybe "" $ lookup hAuthorization hdrs - anon = cs $ configAnonRole conf - claims = - fromMaybe (M.fromList []) $ - case split (==' ') (cs auth) of - ("Bearer" : jwt : _) -> jwtClaims jwtSecret jwt - _ -> Nothing - env = if M.member "role" claims - then jwtEnv - else setRole anon : jwtEnv - jwtEnv = claimsToSQL claims + _ <- H.unitEx $ stmt setAnon + let time = unsafePerformIO getPOSIXTime + case split (== ' ') (cs auth) of + ("Bearer" : tokenStr : _) -> + case jwtClaims jwtSecret tokenStr time of + Just claims -> + if M.member "role" claims + then do + mapM_ H.unitEx $ stmt <$> claimsToSQL claims + app req + else invalidJWT + _ -> invalidJWT + _ -> app req + where + stmt c = B.Stmt c V.empty True + hdrs = requestHeaders req + jwtSecret = (cs $ configJwtSecret conf) :: Text + auth = fromMaybe "" $ lookup hAuthorization hdrs + anon = cs $ configAnonRole conf + setAnon = setRole anon + invalidJWT = return $ responseLBS status400 [("Content-Type","application/json")] "{\"message\":\"Invalid JWT\"}" unsupportedAccept :: Application -> Application unsupportedAccept app req respond = do diff --git a/test/Feature/AuthSpec.hs b/test/Feature/AuthSpec.hs index 992ca1d9e..136ae4549 100644 --- a/test/Feature/AuthSpec.hs +++ b/test/Feature/AuthSpec.hs @@ -31,15 +31,36 @@ spec = beforeAll request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200 + it "works with tokens which have extra fields" $ do + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIiwia2V5MSI6InZhbHVlMSIsImtleTIiOiJ2YWx1ZTIiLCJrZXkzIjoidmFsdWUzIiwiYSI6MSwiYiI6MiwiYyI6M30.GfydCh-F4wnM379xs0n1zUgalwJIsb6YoBapCo8HlFk" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + -- this test will stop working 9999999999s after the UNIX EPOCH + it "succeeds with an unexpired token" $ do + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.QaPPLWTuyydMu_q7H4noMT7Lk6P4muet1OpJXF6ofhc" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "fails with an expired token" $ do + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.enk_qZ_u6gZsXY4R8bREKB_HNExRpM0lIWSLktk9JJQ" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 400 + it "hides tables from users with invalid JWT" $ do let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" request methodGet "/authors_only" [auth] "" - `shouldRespondWith` 404 + `shouldRespondWith` 400 - it "hides tables from users with JWT that contain no claims about role" $ do + it "should fail when jwt contains no claims" $ do let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MKYc_lOECtB0LJOiykilAdlHodB-I0_id2qHKq35dmc" request methodGet "/authors_only" [auth] "" - `shouldRespondWith` 404 + `shouldRespondWith` 400 + + it "hides tables from users with JWT that contain no claims about role" $ do + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.zyohGMnrDy4_8eJTl6I2AUXO3MeCCiwR24aGWRkTE9o" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 400 it "recovers after 400 error with logged in user" $ do _ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |] diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 45cbcbe96..96dfdc5cb 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -132,7 +132,7 @@ clearProjectsTable :: IO () clearProjectsTable = do pool <- testPool void . liftIO $ H.session pool $ H.tx Nothing $ - H.unitEx $ B.Stmt ("delete from test.projects where id > 4") V.empty True + H.unitEx $ B.Stmt "delete from test.projects where id > 4" V.empty True createItems :: Int -> IO () diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 8c17f72ff..0abf60888 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -291,6 +291,7 @@ $$ LANGUAGE SQL; CREATE TYPE public.jwt_claims AS (role text, id text); + CREATE FUNCTION test.login(id text, pass text) RETURNS public.jwt_claims SECURITY DEFINER