Merge pull request #344 from calebmer/feature/jwt-expire

Ensure JWT expires
This commit is contained in:
Joe Nelson
2015-11-11 08:31:20 -08:00
7 changed files with 64 additions and 31 deletions
+1
View File
@@ -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
View File
@@ -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
-8
View File
@@ -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
View File
@@ -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
+24 -3
View File
@@ -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
View File
@@ -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 ()
+1
View File
@@ -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