Merge pull request #344 from calebmer/feature/jwt-expire
Ensure JWT expires
This commit is contained in:
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- Filter columns, e.g. `?select=col1,col2` - @ruslantalpa
|
- Filter columns, e.g. `?select=col1,col2` - @ruslantalpa
|
||||||
- Does not execute the count total if header "Prefer: count=none" - @diogob
|
- Does not execute the count total if header "Prefer: count=none" - @diogob
|
||||||
- Postgres connection string argument - @calebmer
|
- Postgres connection string argument - @calebmer
|
||||||
|
- Ensure JWT expires - @calebmer
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- API versioning feature - @calebmer
|
- API versioning feature - @calebmer
|
||||||
|
|||||||
+13
-2
@@ -18,6 +18,7 @@ module PostgREST.Auth (
|
|||||||
, tokenJWT
|
, tokenJWT
|
||||||
) where
|
) where
|
||||||
|
|
||||||
|
import Control.Monad (join)
|
||||||
import Data.Aeson (Value (..), Object)
|
import Data.Aeson (Value (..), Object)
|
||||||
import Data.Aeson.Types (emptyObject, emptyArray)
|
import Data.Aeson.Types (emptyObject, emptyArray)
|
||||||
import Data.Vector as V (null, head)
|
import Data.Vector as V (null, head)
|
||||||
@@ -25,6 +26,7 @@ import Data.Map as M (fromList, toList)
|
|||||||
import Data.Monoid ((<>))
|
import Data.Monoid ((<>))
|
||||||
import Data.String.Conversions (cs)
|
import Data.String.Conversions (cs)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
|
import Data.Time.Clock (NominalDiffTime)
|
||||||
import PostgREST.PgQuery (pgFmtLit, pgFmtIdent, unquoted)
|
import PostgREST.PgQuery (pgFmtLit, pgFmtIdent, unquoted)
|
||||||
import qualified Web.JWT as JWT
|
import qualified Web.JWT as JWT
|
||||||
import qualified Data.HashMap.Lazy as H
|
import qualified Data.HashMap.Lazy as H
|
||||||
@@ -49,10 +51,19 @@ claimsToSQL = map setVar . toList
|
|||||||
returns a map of JWT claims
|
returns a map of JWT claims
|
||||||
In case there is any problem decoding the JWT it returns Nothing.
|
In case there is any problem decoding the JWT it returns Nothing.
|
||||||
-}
|
-}
|
||||||
jwtClaims :: Text -> Text -> Maybe JWT.ClaimsMap
|
jwtClaims :: Text -> Text -> NominalDiffTime -> Maybe JWT.ClaimsMap
|
||||||
jwtClaims secret input = JWT.unregisteredClaims . JWT.claims <$> decoded
|
jwtClaims secret input time =
|
||||||
|
case join $ claim JWT.exp of
|
||||||
|
Just expires ->
|
||||||
|
if JWT.secondsSinceEpoch expires > time
|
||||||
|
then customClaims
|
||||||
|
else Nothing
|
||||||
|
_ -> customClaims
|
||||||
where
|
where
|
||||||
decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input
|
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
|
-- | Receives the name of a role and returns a SET ROLE statement
|
||||||
setRole :: Text -> Text
|
setRole :: Text -> Text
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ module Main where
|
|||||||
|
|
||||||
|
|
||||||
import PostgREST.App
|
import PostgREST.App
|
||||||
-- import PostgREST.QueryBuilder
|
|
||||||
import PostgREST.Config (AppConfig (..),
|
import PostgREST.Config (AppConfig (..),
|
||||||
minimumPgVersion,
|
minimumPgVersion,
|
||||||
prettyVersion,
|
prettyVersion,
|
||||||
@@ -70,13 +69,6 @@ main = do
|
|||||||
<> show minimumPgVersion)
|
<> show minimumPgVersion)
|
||||||
) supportedOrError
|
) 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)
|
let txSettings = Just (H.ReadCommitted, Just True)
|
||||||
metadata <- H.session pool $ H.tx txSettings $ do
|
metadata <- H.session pool $ H.tx txSettings $ do
|
||||||
rels <- allRelations
|
rels <- allRelations
|
||||||
|
|||||||
+24
-17
@@ -6,6 +6,7 @@ module PostgREST.Middleware where
|
|||||||
import Data.Maybe (fromMaybe, isNothing)
|
import Data.Maybe (fromMaybe, isNothing)
|
||||||
import Data.Text
|
import Data.Text
|
||||||
import Data.String.Conversions (cs)
|
import Data.String.Conversions (cs)
|
||||||
|
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||||
import qualified Hasql as H
|
import qualified Hasql as H
|
||||||
import qualified Hasql.Postgres as P
|
import qualified Hasql.Postgres as P
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ import PostgREST.App (contentTypeForAccept)
|
|||||||
import PostgREST.Auth (setRole, jwtClaims, claimsToSQL)
|
import PostgREST.Auth (setRole, jwtClaims, claimsToSQL)
|
||||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||||
|
|
||||||
|
import System.IO.Unsafe (unsafePerformIO)
|
||||||
|
|
||||||
import Prelude hiding(concat)
|
import Prelude hiding(concat)
|
||||||
|
|
||||||
import qualified Data.Vector as V
|
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) ->
|
||||||
Request -> H.Tx P.Postgres s Response
|
Request -> H.Tx P.Postgres s Response
|
||||||
runWithClaims conf app req = do
|
runWithClaims conf app req = do
|
||||||
mapM_ H.unitEx $ stmt <$> env
|
_ <- H.unitEx $ stmt setAnon
|
||||||
app req
|
let time = unsafePerformIO getPOSIXTime
|
||||||
where
|
case split (== ' ') (cs auth) of
|
||||||
stmt = (flip $ flip B.Stmt V.empty) True
|
("Bearer" : tokenStr : _) ->
|
||||||
hdrs = requestHeaders req
|
case jwtClaims jwtSecret tokenStr time of
|
||||||
jwtSecret = (cs $ configJwtSecret conf) :: Text
|
Just claims ->
|
||||||
auth = fromMaybe "" $ lookup hAuthorization hdrs
|
if M.member "role" claims
|
||||||
anon = cs $ configAnonRole conf
|
then do
|
||||||
claims =
|
mapM_ H.unitEx $ stmt <$> claimsToSQL claims
|
||||||
fromMaybe (M.fromList []) $
|
app req
|
||||||
case split (==' ') (cs auth) of
|
else invalidJWT
|
||||||
("Bearer" : jwt : _) -> jwtClaims jwtSecret jwt
|
_ -> invalidJWT
|
||||||
_ -> Nothing
|
_ -> app req
|
||||||
env = if M.member "role" claims
|
where
|
||||||
then jwtEnv
|
stmt c = B.Stmt c V.empty True
|
||||||
else setRole anon : jwtEnv
|
hdrs = requestHeaders req
|
||||||
jwtEnv = claimsToSQL claims
|
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 :: Application -> Application
|
||||||
unsupportedAccept app req respond = do
|
unsupportedAccept app req respond = do
|
||||||
|
|||||||
@@ -31,15 +31,36 @@ spec = beforeAll
|
|||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` 200
|
`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
|
it "hides tables from users with invalid JWT" $ do
|
||||||
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
request methodGet "/authors_only" [auth] ""
|
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"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MKYc_lOECtB0LJOiykilAdlHodB-I0_id2qHKq35dmc"
|
||||||
request methodGet "/authors_only" [auth] ""
|
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
|
it "recovers after 400 error with logged in user" $ do
|
||||||
_ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
|
_ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ clearProjectsTable :: IO ()
|
|||||||
clearProjectsTable = do
|
clearProjectsTable = do
|
||||||
pool <- testPool
|
pool <- testPool
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing $
|
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 ()
|
createItems :: Int -> IO ()
|
||||||
|
|||||||
Vendored
+1
@@ -291,6 +291,7 @@ $$ LANGUAGE SQL;
|
|||||||
|
|
||||||
|
|
||||||
CREATE TYPE public.jwt_claims AS (role text, id text);
|
CREATE TYPE public.jwt_claims AS (role text, id text);
|
||||||
|
|
||||||
CREATE FUNCTION test.login(id text, pass text)
|
CREATE FUNCTION test.login(id text, pass text)
|
||||||
RETURNS public.jwt_claims
|
RETURNS public.jwt_claims
|
||||||
SECURITY DEFINER
|
SECURITY DEFINER
|
||||||
|
|||||||
Reference in New Issue
Block a user