From 3add3f5b6c8a2ec3052336f12584326de4fce5a4 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Thu, 8 Oct 2015 23:40:21 -0400 Subject: [PATCH 01/19] First draft of big auth simplification --- src/PostgREST/App.hs | 43 +++---------------------- src/PostgREST/Auth.hs | 63 ++++++++++--------------------------- src/PostgREST/Main.hs | 2 +- src/PostgREST/Middleware.hs | 53 +++++++++++-------------------- test/SpecHelper.hs | 2 +- 5 files changed, 42 insertions(+), 121 deletions(-) diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 40c86babd..680fe62a3 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -57,12 +57,12 @@ import PostgREST.Types import Prelude -app :: DbStructure -> AppConfig -> DbRole -> BL.ByteString -> DbRole -> Request -> H.Tx P.Postgres s Response -app dbstructure conf authenticator reqBody dbrole req = +app :: DbStructure -> AppConfig -> BL.ByteString -> Request -> H.Tx P.Postgres s Response +app dbstructure conf reqBody req = case (path, verb) of ([], _) -> do - let body = encode $ filter (filterTableAcl dbrole) $ filter ((cs schema==).tableSchema) allTabs + let body = encode $ filter ((cs schema==).tableSchema) allTabs return $ responseLBS status200 [jsonH] $ cs body ([table], "OPTIONS") -> do @@ -130,41 +130,6 @@ app dbstructure conf authenticator reqBody dbrole req = countQuery = requestToCountQuery schema <$> apiRequest queries = (,) <$> query <*> countQuery - - (["postgrest", "users"], "POST") -> do - let user = decode reqBody :: Maybe AuthUser - - case user of - Nothing -> return $ responseLBS status400 [jsonH] $ - encode . object $ [("message", String "Failed to parse user.")] - Just u -> do - _ <- addUser (cs $ userId u) - (cs $ userPass u) (cs <$> userRole u) - return $ responseLBS status201 - [ jsonH - , (hLocation, "/postgrest/users?id=eq." <> cs (userId u)) - ] "" - - (["postgrest", "tokens"], "POST") -> - case jwtSecret of - "secret" -> return $ responseLBS status500 [jsonH] $ - encode . object $ [("message", String "JWT Secret is set as \"secret\" which is an unsafe default.")] - _ -> do - let user = decode reqBody :: Maybe AuthUser - - case user of - Nothing -> return $ responseLBS status400 [jsonH] $ - encode . object $ [("message", String "Failed to parse user.")] - Just u -> do - setRole authenticator - login <- signInRole (cs $ userId u) (cs $ userPass u) - case login of - LoginSuccess role uid -> - return $ responseLBS status201 [ jsonH ] $ - encode . object $ [("token", String $ tokenJWT jwtSecret uid role)] - _ -> return $ responseLBS status401 [jsonH] $ - encode . object $ [("message", String "Failed authentication.")] - ([table], "POST") -> do let qt = qualify table echoRequested = hasPrefer "return=representation" @@ -295,7 +260,7 @@ app dbstructure conf authenticator reqBody dbrole req = hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs accept = lookupHeader hAccept schema = cs $ configSchema conf - jwtSecret = cs $ configJwtSecret conf + jwtSecret = (cs $ configJwtSecret conf) :: Text range = rangeRequested hdrs allOrigins = ("Access-Control-Allow-Origin", "*") :: Header contentType = fromMaybe "application/json" $ contentTypeForAccept accept diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index edcf86cf9..4ced51892 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -1,23 +1,24 @@ +{-# LANGUAGE FlexibleContexts #-} module PostgREST.Auth where import Control.Applicative import Control.Monad (mzero) -import Crypto.BCrypt + import Data.Aeson -import Data.Map +import Data.Map (lookup, fromList, toList) import Data.Monoid import Data.String.Conversions (cs) -import Data.Text import Data.Maybe (isNothing) +import Data.Text (Text) import qualified Data.Vector as V import qualified Hasql as H import qualified Hasql.Backend as B import qualified Hasql.Postgres as P import PostgREST.PgQuery (pgFmtLit) -import Prelude +import Prelude import qualified Web.JWT as JWT -import System.IO.Unsafe + data AuthUser = AuthUser { userId :: String @@ -48,51 +49,21 @@ data LoginAttempt = | LoginSuccess DbRole UserId deriving (Eq, Show) -checkPass :: Text -> Text -> Bool -checkPass = (. cs) . validatePassword . cs +setJWTEnv :: Text -> Text -> Maybe [Text] +setJWTEnv secret input = setDBEnv $ jwtClaims secret input -setRole :: Text -> H.Tx P.Postgres s () -setRole role = H.unitEx $ B.Stmt ("set local role " <> cs (pgFmtLit role)) V.empty True +setDBEnv :: Maybe JWT.ClaimsMap -> Maybe [Text] +setDBEnv maybeClaims = + (map setVar . toList) <$> maybeClaims + where + setVar ("role", String val) = setRole val + setVar (key, String val) = "set local postgrest." <> key <> " = " <> cs (pgFmtLit val) <> ";" -setUserId :: Text -> H.Tx P.Postgres s () -setUserId uid = - if uid /= "" - then H.unitEx $ B.Stmt ("set local user_vars.user_id = " <> cs (pgFmtLit uid)) V.empty True - else resetUserId +setRole role = "set local role " <> cs (pgFmtLit role) <> ";" -resetUserId :: H.Tx P.Postgres s () -resetUserId = H.unitEx [H.stmt|reset user_vars.user_id|] - -addUser :: Text -> Text -> Maybe Text -> H.Tx P.Postgres s () -addUser identity pass role = - H.unitEx $ - if isNothing role - then [H.stmt|insert into postgrest.auth (id, pass) values (?, ?)|] - identity hashedText - else [H.stmt|insert into postgrest.auth (id, pass, rolname) values (?, ?, ?)|] - identity hashedText role - where Just hashed = unsafePerformIO $ hashPasswordUsingPolicy fastBcryptHashingPolicy (cs pass) - hashedText = cs hashed :: Text - -signInRole :: Text -> Text -> H.Tx P.Postgres s LoginAttempt -signInRole user pass = do - u <- H.maybeEx $ [H.stmt|select id, pass, rolname from postgrest.auth where id = ?|] user - return $ maybe LoginFailed (\r -> - let (uid, hashed, role) = r in - if checkPass hashed pass - then LoginSuccess role uid - else LoginFailed - ) u - -signInWithJWT :: Text -> Text -> LoginAttempt -signInWithJWT secret input = case maybeRole of - Just (Just (String role)) -> case maybeUserId of - Just (Just (String uid)) -> LoginSuccess (cs role) (cs uid) - _ -> LoginFailed - _ -> LoginFailed +jwtClaims :: Text -> Text -> Maybe JWT.ClaimsMap +jwtClaims secret input = claims where - maybeRole = (Data.Map.lookup "role" <$> claims) ::Maybe (Maybe Value) - maybeUserId = (Data.Map.lookup "id" <$> claims) ::Maybe (Maybe Value) claims = JWT.unregisteredClaims <$> JWT.claims <$> decoded decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input diff --git a/src/PostgREST/Main.hs b/src/PostgREST/Main.hs index 86e710424..fd6bf26a5 100644 --- a/src/PostgREST/Main.hs +++ b/src/PostgREST/Main.hs @@ -98,5 +98,5 @@ main = do runSettings appSettings $ middle $ \ req respond -> do body <- strictRequestBody req resOrError <- liftIO $ H.session pool $ H.tx txSettings $ - authenticated conf authenticator (app dbstructure conf authenticator body) req + runWithClaims conf (app dbstructure conf body) req either (respond . errResponse) respond resOrError diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs index 9dd3a5c48..0f6e379bf 100644 --- a/src/PostgREST/Middleware.hs +++ b/src/PostgREST/Middleware.hs @@ -27,46 +27,31 @@ import Network.Wai.Middleware.Static (only, staticPolicy) import Codec.Binary.Base64.String (decode) import PostgREST.App (contentTypeForAccept) import PostgREST.Auth (DbRole, LoginAttempt (..), - setRole, setUserId, signInRole, - signInWithJWT) + setRole, setJWTEnv) import PostgREST.Config (AppConfig (..), corsPolicy) -import Prelude +import Prelude hiding(concat) +import qualified Web.JWT as JWT -authenticated :: forall s. AppConfig -> DbRole -> - (DbRole -> Request -> H.Tx P.Postgres s Response) -> +import qualified Data.Vector as V +import qualified Hasql.Backend as B + +runWithClaims :: forall s. AppConfig -> + (Request -> H.Tx P.Postgres s Response) -> Request -> H.Tx P.Postgres s Response -authenticated conf authenticator app req = do - attempt <- httpRequesterRole (requestHeaders req) - case attempt of - MalformedAuth -> - return $ responseLBS status400 [] "Malformed basic auth header" - LoginFailed -> - return $ responseLBS status401 [] "Invalid username or password" - LoginSuccess role uid -> if role /= authenticator then runInRole role uid else app authenticator req - NoCredentials -> if anon /= authenticator then runInRole anon "" else app authenticator req - +runWithClaims conf app req = do + H.unitEx $ B.Stmt env V.empty True + app req where - jwtSecret = cs $ configJwtSecret conf + hdrs = requestHeaders req + jwtSecret = (cs $ configJwtSecret conf) :: Text + auth = fromMaybe "" $ lookup hAuthorization hdrs anon = cs $ configAnonRole conf - httpRequesterRole :: RequestHeaders -> H.Tx P.Postgres s LoginAttempt - httpRequesterRole hdrs = do - let auth = fromMaybe "" $ lookup hAuthorization hdrs - case split (==' ') (cs auth) of - ("Basic" : b64 : _) -> - case split (==':') (cs . decode . cs $ b64) of - (u:p:_) -> signInRole u p - _ -> return MalformedAuth - ("Bearer" : jwt : _) -> - return $ signInWithJWT jwtSecret jwt - _ -> return NoCredentials - - runInRole :: Text -> Text -> H.Tx P.Postgres s Response - runInRole r uid = do - setUserId uid - setRole r - app r req - + jwtEnv = + case split (==' ') (cs auth) of + ("Bearer" : jwt : _) -> fromMaybe [] (setJWTEnv jwtSecret jwt) + _ -> [] + env = concat $ setRole anon : jwtEnv redirectInsecure :: Application -> Application redirectInsecure app req respond = do diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index c4b8727cc..52e2387f5 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -80,7 +80,7 @@ withApp perform = do perform $ middle $ \req resp -> do body <- strictRequestBody req result <- liftIO $ H.session pool $ H.tx txSettings - $ authenticated cfg authenticator (app dbstructure cfg authenticator body) req + $ runWithClaims cfg (app dbstructure cfg body) req either (resp . errResponse) resp result where middle = defaultMiddle False From 275002e25d9771fd25230d4bd4186867d21879ae Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Mon, 12 Oct 2015 16:37:02 -0400 Subject: [PATCH 02/19] Adds dbrole filter back to root path querying the database --- src/PostgREST/App.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 680fe62a3..77fabf02b 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -62,7 +62,8 @@ app dbstructure conf reqBody req = case (path, verb) of ([], _) -> do - let body = encode $ filter ((cs schema==).tableSchema) allTabs + Identity (dbrole :: Text) <- H.singleEx $ [H.stmt|SELECT current_user|] + let body = encode $ filter (filterTableAcl dbrole) $ filter ((cs schema==).tableSchema) allTabs return $ responseLBS status200 [jsonH] $ cs body ([table], "OPTIONS") -> do From 31f1a30d6fb9399e01c740055208e2cb039c70aa Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Thu, 15 Oct 2015 21:20:51 -0400 Subject: [PATCH 03/19] Cleans imports in Middleware and define exports in Auth --- src/PostgREST/App.hs | 1 - src/PostgREST/Auth.hs | 17 +++++++++-------- src/PostgREST/Middleware.hs | 9 ++------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 77fabf02b..b5fe77c81 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -46,7 +46,6 @@ import qualified Hasql as H import qualified Hasql.Backend as B import qualified Hasql.Postgres as P -import PostgREST.Auth import PostgREST.Config (AppConfig (..)) import PostgREST.Parsers import PostgREST.PgQuery diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 4ced51892..16949c2a2 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -1,19 +1,19 @@ {-# LANGUAGE FlexibleContexts #-} -module PostgREST.Auth where +module PostgREST.Auth ( + DbRole + , LoginAttempt (..) + , setRole + , setJWTEnv + ) where import Control.Applicative import Control.Monad (mzero) import Data.Aeson -import Data.Map (lookup, fromList, toList) +import Data.Map (fromList, toList) import Data.Monoid import Data.String.Conversions (cs) -import Data.Maybe (isNothing) import Data.Text (Text) -import qualified Data.Vector as V -import qualified Hasql as H -import qualified Hasql.Backend as B -import qualified Hasql.Postgres as P import PostgREST.PgQuery (pgFmtLit) import Prelude import qualified Web.JWT as JWT @@ -57,8 +57,9 @@ setDBEnv maybeClaims = (map setVar . toList) <$> maybeClaims where setVar ("role", String val) = setRole val - setVar (key, String val) = "set local postgrest." <> key <> " = " <> cs (pgFmtLit val) <> ";" + setVar (key, String val) = "set local postgrest.claims" <> key <> " = " <> cs (pgFmtLit val) <> ";" +setRole :: Text -> Text setRole role = "set local role " <> cs (pgFmtLit role) <> ";" jwtClaims :: Text -> Text -> Maybe JWT.ClaimsMap diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs index 0f6e379bf..cabe54303 100644 --- a/src/PostgREST/Middleware.hs +++ b/src/PostgREST/Middleware.hs @@ -10,11 +10,9 @@ import Data.String.Conversions (cs) import qualified Hasql as H import qualified Hasql.Postgres as P -import Network.HTTP.Types (RequestHeaders) import Network.HTTP.Types.Header (hAccept, hAuthorization, hLocation) -import Network.HTTP.Types.Status (status301, status400, status401, - status415) +import Network.HTTP.Types.Status (status301, status400, status415) import Network.URI (URI (..), parseURI) import Network.Wai (Application, Request (..), Response, isSecure, rawPathInfo, @@ -24,14 +22,11 @@ import Network.Wai.Middleware.Cors (cors) import Network.Wai.Middleware.Gzip (def, gzip) import Network.Wai.Middleware.Static (only, staticPolicy) -import Codec.Binary.Base64.String (decode) import PostgREST.App (contentTypeForAccept) -import PostgREST.Auth (DbRole, LoginAttempt (..), - setRole, setJWTEnv) +import PostgREST.Auth (setRole, setJWTEnv) import PostgREST.Config (AppConfig (..), corsPolicy) import Prelude hiding(concat) -import qualified Web.JWT as JWT import qualified Data.Vector as V import qualified Hasql.Backend as B From aae55e028261980f36c7a69ca9b9174fea36fef5 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sat, 17 Oct 2015 20:41:42 -0400 Subject: [PATCH 04/19] Make complete match agains ClaimsMap in setVar --- src/PostgREST/Auth.hs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 16949c2a2..3a5d75209 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -57,7 +57,12 @@ setDBEnv maybeClaims = (map setVar . toList) <$> maybeClaims where setVar ("role", String val) = setRole val - setVar (key, String val) = "set local postgrest.claims" <> key <> " = " <> cs (pgFmtLit val) <> ";" + setVar (key, String val) = "set local postgrest.claims" <> key <> " = " <> pgFmtLit val <> ";" + setVar (key, Bool val) = "set local postgrest.claims" <> key <> " = " <> showText val <> ";" + setVar (key, Number val) = "set local postgrest.claims" <> key <> " = " <> showText val <> ";" + setVar _ = "" + showText :: Show a => a -> Text + showText = cs . show setRole :: Text -> Text setRole role = "set local role " <> cs (pgFmtLit role) <> ";" From 241a38e9585ea1a9588bfa3a9fbf60d3ca05eff7 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 18 Oct 2015 00:17:49 -0400 Subject: [PATCH 05/19] Makes JWT generation possible in RPC endpoints Fixes SET execution to execute in separate statements as Hasql uses prepared statements we need to send 1 commend per statement. --- postgrest.cabal | 72 +++++++++++++++++++++++-------------- src/PostgREST/App.hs | 8 +++-- src/PostgREST/Auth.hs | 57 ++++++++--------------------- src/PostgREST/Middleware.hs | 5 +-- 4 files changed, 69 insertions(+), 73 deletions(-) diff --git a/postgrest.cabal b/postgrest.cabal index 55d5fcc68..2b3642b6c 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -53,6 +53,8 @@ executable postgrest , mtl , cassava , jwt + , lens + , lens-aeson >= 1.0.0.5 , parsec , errors , bifunctors @@ -66,34 +68,50 @@ library default-language: Haskell2010 default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes - build-depends: base >=4.6 && <5 - , hasql, hasql-backend - , hasql-postgres - , warp, wai - , wai-extra, wai-cors - , wai-middleware-static - , HTTP, convertible, http-types - , case-insensitive - , scientific, time - , aeson, network - , bytestring, text, split, string-conversions - , stringsearch - , containers, unordered-containers - , optparse-applicative - , regex-base, regex-tdfa + build-depends: HTTP + , MissingH , Ranged-sets - , transformers, MissingH - , bcrypt, base64-string - , network-uri - , resource-pool - , blaze-builder - , vector - , mtl - , cassava - , jwt - , parsec - , errors + , aeson + , base >=4.6 && <5 + , base64-string + , bcrypt , bifunctors + , blaze-builder + , bytestring + , case-insensitive + , cassava + , containers + , convertible + , errors + , hasql + , hasql-backend + , hasql-postgres + , http-types + , jwt + , lens + , lens-aeson >= 1.0.0.5 + , mtl + , network + , network-uri + , optparse-applicative + , parsec + , regex-base + , regex-tdfa + , resource-pool + , scientific + , split + , string-conversions + , stringsearch + , text + , time + , transformers + , unordered-containers + , vector + , wai + , wai-cors + , wai-extra + , wai-middleware-static + , warp Other-Modules: Paths_postgrest Exposed-Modules: PostgREST.App @@ -163,6 +181,8 @@ Test-Suite spec , process , heredoc , jwt + , lens + , lens-aeson >= 1.0.0.5 , parsec , errors , bifunctors diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index b5fe77c81..18248ddff 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -40,6 +40,7 @@ import Network.Wai.Internal (Response (..)) import Network.Wai.Parse (parseHttpAccept) import Data.Aeson +import Data.Aeson.Types (emptyArray) import Data.Monoid import qualified Data.Vector as V import qualified Hasql as H @@ -53,6 +54,7 @@ import PostgREST.PgStructure import PostgREST.QueryBuilder import PostgREST.RangeQuery import PostgREST.Types +import PostgREST.Auth (tokenJWT) import Prelude @@ -172,9 +174,11 @@ app dbstructure conf reqBody req = then do let call = B.Stmt "select " V.empty True <> asJson (callProc qi $ fromMaybe M.empty (decode reqBody)) - body :: Maybe (Identity Text) <- H.maybeEx call + bodyJson :: Maybe (Identity Value) <- H.maybeEx call return $ responseLBS status200 [jsonH] - (cs $ fromMaybe "[]" $ runIdentity <$> body) + (if hasPrefer "return=jwt" + then ("{\"token\":\"" <> (cs $ tokenJWT jwtSecret $ fromMaybe "[]" $ runIdentity <$> bodyJson) <> "\"}") + else (cs $ encode $ fromMaybe emptyArray $ runIdentity <$> bodyJson)) else return $ responseLBS status404 [] "" -- check that proc exists diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 3a5d75209..43c3e99c9 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -1,53 +1,23 @@ {-# LANGUAGE FlexibleContexts #-} module PostgREST.Auth ( - DbRole - , LoginAttempt (..) - , setRole + setRole , setJWTEnv + , tokenJWT ) where import Control.Applicative -import Control.Monad (mzero) - import Data.Aeson import Data.Map (fromList, toList) +import Data.Maybe (fromMaybe) import Data.Monoid import Data.String.Conversions (cs) import Data.Text (Text) import PostgREST.PgQuery (pgFmtLit) -import Prelude +import Prelude import qualified Web.JWT as JWT - - - -data AuthUser = AuthUser { - userId :: String - , userPass :: String - , userRole :: Maybe String - } deriving (Show) - -instance FromJSON AuthUser where - parseJSON (Object v) = AuthUser <$> - v .: "id" <*> - v .: "pass" <*> - v .:? "role" - parseJSON _ = mzero - -instance ToJSON AuthUser where - toJSON u = object [ - "id" .= userId u - , "pass" .= userPass u - , "role" .= userRole u ] - -type DbRole = Text -type UserId = Text - -data LoginAttempt = - NoCredentials - | MalformedAuth - | LoginFailed - | LoginSuccess DbRole UserId - deriving (Eq, Show) +import qualified Data.HashMap.Lazy as HashMap +import Data.Aeson.Lens +import Control.Lens.Operators setJWTEnv :: Text -> Text -> Maybe [Text] setJWTEnv secret input = setDBEnv $ jwtClaims secret input @@ -57,9 +27,9 @@ setDBEnv maybeClaims = (map setVar . toList) <$> maybeClaims where setVar ("role", String val) = setRole val - setVar (key, String val) = "set local postgrest.claims" <> key <> " = " <> pgFmtLit val <> ";" - setVar (key, Bool val) = "set local postgrest.claims" <> key <> " = " <> showText val <> ";" - setVar (key, Number val) = "set local postgrest.claims" <> key <> " = " <> showText val <> ";" + setVar (k, String val) = "set local postgrest.claims." <> k <> " = " <> pgFmtLit val <> ";" + setVar (k, Bool val) = "set local postgrest.claims." <> k <> " = " <> showText val <> ";" + setVar (k, Number val) = "set local postgrest.claims." <> k <> " = " <> showText val <> ";" setVar _ = "" showText :: Show a => a -> Text showText = cs . show @@ -73,9 +43,10 @@ jwtClaims secret input = claims claims = JWT.unregisteredClaims <$> JWT.claims <$> decoded decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input -tokenJWT :: Text -> Text -> Text -> Text -tokenJWT secret uid role = JWT.encodeSigned JWT.HS256 (JWT.secret secret) claimsSet +tokenJWT :: Text -> Value -> Text +tokenJWT secret claims = JWT.encodeSigned JWT.HS256 (JWT.secret secret) claimsSet where claimsSet = JWT.def { - JWT.unregisteredClaims = Data.Map.fromList [("id", String uid), ("role", String role)] + JWT.unregisteredClaims = Data.Map.fromList claimsList } + claimsList = fromMaybe [] $ HashMap.toList <$> (claims ^? nth 0 . _Object) diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs index cabe54303..119f5f939 100644 --- a/src/PostgREST/Middleware.hs +++ b/src/PostgREST/Middleware.hs @@ -35,7 +35,7 @@ runWithClaims :: forall s. AppConfig -> (Request -> H.Tx P.Postgres s Response) -> Request -> H.Tx P.Postgres s Response runWithClaims conf app req = do - H.unitEx $ B.Stmt env V.empty True + mapM_ H.unitEx $ stmt <$> env app req where hdrs = requestHeaders req @@ -46,7 +46,8 @@ runWithClaims conf app req = do case split (==' ') (cs auth) of ("Bearer" : jwt : _) -> fromMaybe [] (setJWTEnv jwtSecret jwt) _ -> [] - env = concat $ setRole anon : jwtEnv + env = setRole anon : jwtEnv + stmt = (flip $ flip B.Stmt V.empty) True redirectInsecure :: Application -> Application redirectInsecure app req respond = do From 7ef5b7b43a7c28d5096d1ab0ad1de1b0434ea979 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 18 Oct 2015 14:03:43 -0400 Subject: [PATCH 06/19] Simplifies pattern matching using insertableValue and quote variable as identifier. --- src/PostgREST/Auth.hs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 43c3e99c9..f7cce5fec 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -12,7 +12,7 @@ import Data.Maybe (fromMaybe) import Data.Monoid import Data.String.Conversions (cs) import Data.Text (Text) -import PostgREST.PgQuery (pgFmtLit) +import PostgREST.PgQuery (pgFmtLit, pgFmtIdent, insertableValue) import Prelude import qualified Web.JWT as JWT import qualified Data.HashMap.Lazy as HashMap @@ -27,12 +27,8 @@ setDBEnv maybeClaims = (map setVar . toList) <$> maybeClaims where setVar ("role", String val) = setRole val - setVar (k, String val) = "set local postgrest.claims." <> k <> " = " <> pgFmtLit val <> ";" - setVar (k, Bool val) = "set local postgrest.claims." <> k <> " = " <> showText val <> ";" - setVar (k, Number val) = "set local postgrest.claims." <> k <> " = " <> showText val <> ";" - setVar _ = "" - showText :: Show a => a -> Text - showText = cs . show + setVar (k, val) = "set local postgrest.claims." <> pgFmtIdent k <> + " = " <> insertableValue val <> ";" setRole :: Text -> Text setRole role = "set local role " <> cs (pgFmtLit role) <> ";" From 8b5f4e8556101caf4e6c8c707e412a7d9c5a6cd2 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 18 Oct 2015 17:14:11 -0400 Subject: [PATCH 07/19] Removes lenses and uses simpler approach to generate JWT claims. Also fixes the setVar to avoid the ::unknown type cast from insertableValue --- postgrest.cabal | 6 ------ src/PostgREST/Auth.hs | 28 +++++++++++++++------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/postgrest.cabal b/postgrest.cabal index 2b3642b6c..38f5bf364 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -53,8 +53,6 @@ executable postgrest , mtl , cassava , jwt - , lens - , lens-aeson >= 1.0.0.5 , parsec , errors , bifunctors @@ -88,8 +86,6 @@ library , hasql-postgres , http-types , jwt - , lens - , lens-aeson >= 1.0.0.5 , mtl , network , network-uri @@ -181,8 +177,6 @@ Test-Suite spec , process , heredoc , jwt - , lens - , lens-aeson >= 1.0.0.5 , parsec , errors , bifunctors diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index f7cce5fec..8b8f6e32b 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -7,17 +7,16 @@ module PostgREST.Auth ( import Control.Applicative import Data.Aeson -import Data.Map (fromList, toList) -import Data.Maybe (fromMaybe) +import Data.Aeson.Types (emptyObject, emptyArray) +import Data.Vector as V (null, head) +import Data.Map as M (fromList, toList) import Data.Monoid import Data.String.Conversions (cs) import Data.Text (Text) -import PostgREST.PgQuery (pgFmtLit, pgFmtIdent, insertableValue) +import PostgREST.PgQuery (pgFmtLit, pgFmtIdent, unquoted) import Prelude import qualified Web.JWT as JWT -import qualified Data.HashMap.Lazy as HashMap -import Data.Aeson.Lens -import Control.Lens.Operators +import qualified Data.HashMap.Lazy as H setJWTEnv :: Text -> Text -> Maybe [Text] setJWTEnv secret input = setDBEnv $ jwtClaims secret input @@ -28,7 +27,8 @@ setDBEnv maybeClaims = where setVar ("role", String val) = setRole val setVar (k, val) = "set local postgrest.claims." <> pgFmtIdent k <> - " = " <> insertableValue val <> ";" + " = " <> valueToVariable val <> ";" + valueToVariable = pgFmtLit . unquoted setRole :: Text -> Text setRole role = "set local role " <> cs (pgFmtLit role) <> ";" @@ -40,9 +40,11 @@ jwtClaims secret input = claims decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input tokenJWT :: Text -> Value -> Text -tokenJWT secret claims = JWT.encodeSigned JWT.HS256 (JWT.secret secret) claimsSet - where - claimsSet = JWT.def { - JWT.unregisteredClaims = Data.Map.fromList claimsList - } - claimsList = fromMaybe [] $ HashMap.toList <$> (claims ^? nth 0 . _Object) +tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 (JWT.secret secret) + JWT.def { JWT.unregisteredClaims = fromHashMap o } + where + Object o = if V.null a then emptyObject else V.head a +tokenJWT secret _ = tokenJWT secret emptyArray + +fromHashMap :: Object -> JWT.ClaimsMap +fromHashMap = M.fromList . H.toList From f56efeb039783dbfcf546c4d3ca95833657f6988 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 18 Oct 2015 20:02:33 -0400 Subject: [PATCH 08/19] Reduces conde duplication assimbling response vody for RPC --- src/PostgREST/App.hs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 18248ddff..bcd0627e1 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -176,9 +176,10 @@ app dbstructure conf reqBody req = asJson (callProc qi $ fromMaybe M.empty (decode reqBody)) bodyJson :: Maybe (Identity Value) <- H.maybeEx call return $ responseLBS status200 [jsonH] - (if hasPrefer "return=jwt" - then ("{\"token\":\"" <> (cs $ tokenJWT jwtSecret $ fromMaybe "[]" $ runIdentity <$> bodyJson) <> "\"}") - else (cs $ encode $ fromMaybe emptyArray $ runIdentity <$> bodyJson)) + (let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in + if hasPrefer "return=jwt" + then ("{\"token\":\"" <> (cs $ tokenJWT jwtSecret $ body) <> "\"}") + else (cs $ encode $ body)) else return $ responseLBS status404 [] "" -- check that proc exists From 4e9afc8096253caa1f9428a11b5baf8399486958 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 18 Oct 2015 20:05:34 -0400 Subject: [PATCH 09/19] Adds import needed by ghc 7.8 --- src/PostgREST/Middleware.hs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs index 119f5f939..164b37a15 100644 --- a/src/PostgREST/Middleware.hs +++ b/src/PostgREST/Middleware.hs @@ -3,6 +3,9 @@ module PostgREST.Middleware where +-- needed for ghc 7.8 +import Data.Functor ((<$>)) + import Data.Maybe (fromMaybe, isNothing) import Data.Monoid import Data.Text From a3aba84ba8485328fc8444cc7d217c57ed0c8810 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 18 Oct 2015 20:10:26 -0400 Subject: [PATCH 10/19] Fixes linter suggestions --- src/PostgREST/App.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index bcd0627e1..be2f366ea 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -178,8 +178,8 @@ app dbstructure conf reqBody req = return $ responseLBS status200 [jsonH] (let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in if hasPrefer "return=jwt" - then ("{\"token\":\"" <> (cs $ tokenJWT jwtSecret $ body) <> "\"}") - else (cs $ encode $ body)) + then "{\"token\":\"" <> cs (tokenJWT jwtSecret body) <> "\"}" + else cs $ encode body) else return $ responseLBS status404 [] "" -- check that proc exists From 2ea7bc29c64e165003a4a672e031e375f005c390 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Tue, 20 Oct 2015 00:43:21 -0400 Subject: [PATCH 11/19] Moves function from top level to where --- src/PostgREST/Auth.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 8b8f6e32b..20a7d0285 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -44,7 +44,7 @@ tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 (JWT.secret secret) JWT.def { JWT.unregisteredClaims = fromHashMap o } where Object o = if V.null a then emptyObject else V.head a + fromHashMap :: Object -> JWT.ClaimsMap + fromHashMap = M.fromList . H.toList tokenJWT secret _ = tokenJWT secret emptyArray -fromHashMap :: Object -> JWT.ClaimsMap -fromHashMap = M.fromList . H.toList From 044e3865acf1adc8e3ee916db04b56ef5a124521 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Tue, 20 Oct 2015 01:03:11 -0400 Subject: [PATCH 12/19] Removes 'Prefer: return=jwt' header and chooses jwt return based on function type --- src/PostgREST/App.hs | 3 ++- src/PostgREST/PgStructure.hs | 22 ++++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index be2f366ea..50106fac2 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -175,9 +175,10 @@ app dbstructure conf reqBody req = let call = B.Stmt "select " V.empty True <> asJson (callProc qi $ fromMaybe M.empty (decode reqBody)) bodyJson :: Maybe (Identity Value) <- H.maybeEx call + returnJWT <- doesProcReturnJWT schema proc return $ responseLBS status200 [jsonH] (let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in - if hasPrefer "return=jwt" + if returnJWT then "{\"token\":\"" <> cs (tokenJWT jwtSecret body) <> "\"}" else cs $ encode body) else return $ responseLBS status404 [] "" diff --git a/src/PostgREST/PgStructure.hs b/src/PostgREST/PgStructure.hs index 8ed08873b..a33d7829d 100644 --- a/src/PostgREST/PgStructure.hs +++ b/src/PostgREST/PgStructure.hs @@ -13,25 +13,39 @@ import Data.Monoid import Data.Text (Text, split) import qualified Hasql as H import qualified Hasql.Postgres as P +import qualified Hasql.Backend as B import PostgREST.PgQuery () import PostgREST.Types import GHC.Exts (groupWith) import Prelude +doesProc :: forall c s. B.CxValue c Int => + (Text -> Text -> B.Stmt c) -> Text -> Text -> H.Tx c s Bool +doesProc stmt schema proc = do + row :: Maybe (Identity Int) <- H.maybeEx $ stmt schema proc + return $ isJust row doesProcExist :: Text -> Text -> H.Tx P.Postgres s Bool -doesProcExist schema proc = do - row :: Maybe (Identity Int) <- H.maybeEx $ [H.stmt| +doesProcExist = doesProc [H.stmt| SELECT 1 FROM pg_catalog.pg_namespace n JOIN pg_catalog.pg_proc p ON pronamespace = n.oid WHERE nspname = ? AND proname = ? - |] schema proc - return $ isJust row + |] +doesProcReturnJWT :: Text -> Text -> H.Tx P.Postgres s Bool +doesProcReturnJWT = doesProc [H.stmt| + SELECT 1 + FROM pg_catalog.pg_namespace n + JOIN pg_catalog.pg_proc p + ON pronamespace = n.oid + WHERE nspname = ? + AND proname = ? + AND pg_catalog.pg_get_function_result(p.oid) = 'jwt' + |] tableFromRow :: (Text, Text, Bool, Maybe Text) -> Table tableFromRow (s, n, i, a) = Table s n i (parseAcl a) From a192cadcedf93c5f7c61b0865591c33a31249190 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Tue, 20 Oct 2015 19:20:53 -0400 Subject: [PATCH 13/19] All green :D --- src/PostgREST/PgStructure.hs | 4 ++-- test/Feature/AuthSpec.hs | 45 +++-------------------------------- test/Feature/InsertSpec.hs | 3 ++- test/Feature/StructureSpec.hs | 3 +-- test/fixtures/schema.sql | 35 +++++++++++++++++++-------- 5 files changed, 33 insertions(+), 57 deletions(-) diff --git a/src/PostgREST/PgStructure.hs b/src/PostgREST/PgStructure.hs index a33d7829d..df736cf3a 100644 --- a/src/PostgREST/PgStructure.hs +++ b/src/PostgREST/PgStructure.hs @@ -44,7 +44,7 @@ doesProcReturnJWT = doesProc [H.stmt| ON pronamespace = n.oid WHERE nspname = ? AND proname = ? - AND pg_catalog.pg_get_function_result(p.oid) = 'jwt' + AND pg_catalog.pg_get_function_result(p.oid) = 'jwt_claims' |] tableFromRow :: (Text, Text, Bool, Maybe Text) -> Table @@ -167,7 +167,7 @@ allRelations = do allColumns :: [Relation] -> H.Tx P.Postgres s [Column] allColumns rels = do cols <- H.listEx $ [H.stmt| - SELECT + SELECT DISTINCT info.table_schema AS schema, info.table_name AS table_name, info.column_name AS name, diff --git a/test/Feature/AuthSpec.hs b/test/Feature/AuthSpec.hs index 7362e7a15..c12bcdee0 100644 --- a/test/Feature/AuthSpec.hs +++ b/test/Feature/AuthSpec.hs @@ -18,50 +18,11 @@ spec = beforeAll it "hides tables that anonymous does not own" $ get "/authors_only" `shouldRespondWith` 404 - it "indicates login failure (BasicAuth)" $ do - let auth = authHeaderBasic "postgrest_test_author" "fakefake" - request methodGet "/authors_only" [auth] "" - `shouldRespondWith` 401 - - it "allows users with permissions to see their tables (BasicAuth)" $ do - _ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |] - let auth = authHeaderBasic "jdoe" "1234" - request methodGet "/authors_only" [auth] "" - `shouldRespondWith` 200 - - it "respects database constraints for role" $ - post "/postgrest/users" [json| { "id": "ssmith", "pass": "1234", "role": "SUPER_ADMIN_TRUNCATE_POWERS" } |] - `shouldRespondWith` 400 - - it "does not send a value when no role is provided" $ do - post "/postgrest/users" [json| { "id": "bdeey", "pass": "1234" } |] - `shouldRespondWith` 201 - let auth = authHeaderBasic "jdoe" "1234" - request methodGet "/authors_only" [auth] "" - `shouldRespondWith` 200 - - it "recovers after 400 error with logged in user" $ do - _ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |] - let auth = authHeaderBasic "jdoe" "1234" - _ <- request methodPost "/rpc/problem" [auth] "" - request methodGet "/authors_only" [auth] "" - `shouldRespondWith` 200 - - it "allows users to login (JWT)" $ do - _ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |] - post "/postgrest/tokens" [json| { "id": "jdoe", "pass": "1234" } |] + it "returns jwt functions as jwt tokens" $ do + post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |] `shouldRespondWith` ResponseMatcher { matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |] - , matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json"] - } - - it "indicates login failure (JWT)" $ do - _ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |] - post "/postgrest/tokens" [json| { "id": "jdoe", "pass": "NOPE" } |] - `shouldRespondWith` ResponseMatcher { - matchBody = Just [json| {"message":"Failed authentication."} |] - , matchStatus = 401 + , matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json"] } diff --git a/test/Feature/InsertSpec.hs b/test/Feature/InsertSpec.hs index 7d19e5683..91c5c0b04 100644 --- a/test/Feature/InsertSpec.hs +++ b/test/Feature/InsertSpec.hs @@ -284,11 +284,12 @@ spec = afterAll_ resetDb $ around withApp $ do describe "Row level permission" $ it "set user_id when inserting rows" $ do + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" _ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |] _ <- post "/postgrest/users" [json| { "id":"jroe", "pass": "1234", "role": "postgrest_test_author" } |] p1 <- request methodPost "/authors_only" - [ authHeaderBasic "jdoe" "1234", ("Prefer", "return=representation") ] + [ auth, ("Prefer", "return=representation") ] [json| { "secret": "nyancat" } |] liftIO $ do simpleBody p1 `shouldBe` [json| { "owner":"jdoe", "secret":"nyancat" } |] diff --git a/test/Feature/StructureSpec.hs b/test/Feature/StructureSpec.hs index 187584f28..2a28f43db 100644 --- a/test/Feature/StructureSpec.hs +++ b/test/Feature/StructureSpec.hs @@ -40,8 +40,7 @@ spec = around withApp $ do {matchStatus = 200} it "lists only views user has permission to see" $ do - _ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |] - let auth = authHeaderBasic "jdoe" "1234" + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" request methodGet "/" [auth] "" `shouldRespondWith` [json| [ diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 2066c4f68..61ab265c6 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -76,7 +76,7 @@ CREATE FUNCTION set_authors_only_owner() RETURNS trigger LANGUAGE plpgsql AS $$ begin - NEW.owner = current_setting('user_vars.user_id'); + NEW.owner = current_setting('postgrest.claims.id'); RETURN NEW; end $$; @@ -268,15 +268,6 @@ CREATE VIEW test.projects_view AS projects.client_id FROM projects; ALTER TABLE test.projects_view OWNER TO postgrest_test; -------- SAMPLE DATA ----- -INSERT INTO clients VALUES (1, 'Microsoft'),(2, 'Apple'); -INSERT INTO projects VALUES (1,'Windows 7', 1),(2,'Windows 10', 1),(3,'IOS', 2),(4,'OSX', 2); -INSERT INTO tasks VALUES (1,'Design w7',1),(2,'Code w7',1),(3,'Design w10',2),(4,'Code w10',2),(5,'Design IOS',3),(6,'Code IOS',3),(7,'Design OSX',4),(8,'Code OSX',4); -INSERT INTO users VALUES (1, 'Angela Martin'),(2, 'Michael Scott'),(3, 'Dwight Schrute'); -INSERT INTO users_projects VALUES(1,1),(1,2),(2,3),(2,4),(3,1),(3,3); -INSERT INTO users_tasks VALUES(1,1),(1,2),(1,3),(1,4),(2,5),(2,6),(2,7),(3,1),(3,5); -INSERT INTO comments VALUES (1, 1, 2, 6, 'Needs to be delivered ASAP'); ----------------- CREATE SEQUENCE items_id_seq START WITH 1 @@ -298,6 +289,13 @@ CREATE FUNCTION test.getitemrange(min bigint, max bigint) RETURNS SETOF test.ite $$ 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 +AS $$ +SELECT rolname::text, id::text FROM postgrest.auth WHERE id = id AND pass = pass; +$$ LANGUAGE SQL; CREATE FUNCTION test.sayhello(name text) RETURNS text AS $$ SELECT 'Hello, ' || $1; @@ -663,6 +661,10 @@ REVOKE ALL ON FUNCTION getitemrange(bigint, bigint) FROM postgrest_test; GRANT EXECUTE ON FUNCTION getitemrange(bigint, bigint) TO postgrest_test; GRANT EXECUTE ON FUNCTION getitemrange(bigint, bigint) TO postgrest_anonymous; +REVOKE ALL ON FUNCTION login(text, text) FROM PUBLIC; +REVOKE ALL ON FUNCTION login(text, text) FROM postgrest_test; +GRANT EXECUTE ON FUNCTION login(text, text) TO postgrest_test; +GRANT EXECUTE ON FUNCTION login(text, text) TO postgrest_anonymous; REVOKE ALL ON FUNCTION sayhello(text) FROM PUBLIC; REVOKE ALL ON FUNCTION sayhello(text) FROM postgrest_test; @@ -758,3 +760,16 @@ SET search_path = private, pg_catalog; REVOKE ALL ON TABLE articles FROM PUBLIC; REVOKE ALL ON TABLE articles FROM postgrest_test; GRANT ALL ON TABLE articles TO postgrest_test; + +SET search_path = test, private, postgrest, public, pg_catalog; + +------- SAMPLE DATA ----- +INSERT INTO clients VALUES (1, 'Microsoft'),(2, 'Apple'); +INSERT INTO projects VALUES (1,'Windows 7', 1),(2,'Windows 10', 1),(3,'IOS', 2),(4,'OSX', 2); +INSERT INTO tasks VALUES (1,'Design w7',1),(2,'Code w7',1),(3,'Design w10',2),(4,'Code w10',2),(5,'Design IOS',3),(6,'Code IOS',3),(7,'Design OSX',4),(8,'Code OSX',4); +INSERT INTO users VALUES (1, 'Angela Martin'),(2, 'Michael Scott'),(3, 'Dwight Schrute'); +INSERT INTO users_projects VALUES(1,1),(1,2),(2,3),(2,4),(3,1),(3,3); +INSERT INTO users_tasks VALUES(1,1),(1,2),(1,3),(1,4),(2,5),(2,6),(2,7),(3,1),(3,5); +INSERT INTO comments VALUES (1, 1, 2, 6, 'Needs to be delivered ASAP'); +INSERT INTO postgrest.auth (id, pass, rolname) VALUES ('jdoe', '1234', 'postgrest_test_author'); +---------------- From 6e55017f9687063c0d7ea1604f7b23f6a3a2c4cc Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Tue, 20 Oct 2015 19:48:54 -0400 Subject: [PATCH 14/19] Cleans and adds haddock comments --- src/PostgREST/Auth.hs | 56 +++++++++++++++++++++++++++---------------- test/SpecHelper.hs | 5 ---- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 20a7d0285..8ddfa1dea 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -1,3 +1,15 @@ +{-| +Module : PostgREST.Auth +Description : PostgREST authorization functions. + +This module provides functions to deal with the JWT authorization (http://jwt.io). +It also can be used to define other authorization 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 FlexibleContexts #-} module PostgREST.Auth ( setRole @@ -5,40 +17,45 @@ module PostgREST.Auth ( , tokenJWT ) where -import Control.Applicative -import Data.Aeson -import Data.Aeson.Types (emptyObject, emptyArray) -import Data.Vector as V (null, head) -import Data.Map as M (fromList, toList) -import Data.Monoid +import Data.Aeson (Value (..), Object) +import Data.Aeson.Types (emptyObject, emptyArray) +import Data.Vector as V (null, head) +import Data.Map as M (fromList, toList) +import Data.Monoid ((<>)) import Data.String.Conversions (cs) -import Data.Text (Text) +import Data.Text (Text) import PostgREST.PgQuery (pgFmtLit, pgFmtIdent, unquoted) -import Prelude import qualified Web.JWT as JWT import qualified Data.HashMap.Lazy as H +{-| + Receives the JWT secret (from config) and a JWT and + returns a list of PostgreSQL statements to set the claims + as user defined GUCs. + Except if we have a claim called role, this one is mapped to + a SET ROLE statement. + In case there is any problem decoding the JWT it returns Nothing. +-} setJWTEnv :: Text -> Text -> Maybe [Text] -setJWTEnv secret input = setDBEnv $ jwtClaims secret input - -setDBEnv :: Maybe JWT.ClaimsMap -> Maybe [Text] -setDBEnv maybeClaims = - (map setVar . toList) <$> maybeClaims +setJWTEnv secret input = setDBEnv jwtClaims where + setDBEnv maybeClaims = (map setVar . toList) <$> maybeClaims setVar ("role", String val) = setRole val setVar (k, val) = "set local postgrest.claims." <> pgFmtIdent k <> - " = " <> valueToVariable val <> ";" + " = " <> valueToVariable val <> ";" valueToVariable = pgFmtLit . unquoted + jwtClaims = JWT.unregisteredClaims <$> JWT.claims <$> decoded + decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input +-- | Receives the name of a role and returns a SET ROLE statement setRole :: Text -> Text setRole role = "set local role " <> cs (pgFmtLit role) <> ";" -jwtClaims :: Text -> Text -> Maybe JWT.ClaimsMap -jwtClaims secret input = claims - where - claims = JWT.unregisteredClaims <$> JWT.claims <$> decoded - decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input +{-| + Receives the JWT secret (from config) and a JWT and a JSON value + and returns a signed JWT. +-} tokenJWT :: Text -> Value -> Text tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 (JWT.secret secret) JWT.def { JWT.unregisteredClaims = fromHashMap o } @@ -47,4 +64,3 @@ tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 (JWT.secret secret) fromHashMap :: Object -> JWT.ClaimsMap fromHashMap = M.fromList . H.toList tokenJWT secret _ = tokenJWT secret emptyArray - diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 52e2387f5..bf7042c11 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -20,7 +20,6 @@ import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange, import Codec.Binary.Base64.String (encode) import Data.CaseInsensitive (CI(..)) import Data.Maybe (fromMaybe) -import Data.Functor.Identity import Text.Regex.TDFA ((=~)) import qualified Data.ByteString.Char8 as BS import System.Process (readProcess) @@ -55,10 +54,6 @@ withApp perform = do pool :: H.Pool P.Postgres <- H.acquirePool pgSettings testPoolOpts - Right authenticator <- H.session pool $ do - Identity (role :: Text) <- H.tx Nothing $ H.singleEx [H.stmt|SELECT SESSION_USER|] - return role - let txSettings = Just (H.ReadCommitted, Just True) metadata <- H.session pool $ H.tx txSettings $ do tabs <- allTables From 250a4dcfb29950cfd2e77abf7e57c44cab188ba4 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Tue, 20 Oct 2015 19:51:25 -0400 Subject: [PATCH 15/19] Adds back import to make GHC 7.8 happy --- src/PostgREST/Auth.hs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 8ddfa1dea..daa75b4af 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -17,6 +17,9 @@ module PostgREST.Auth ( , tokenJWT ) where +--line needed for ghc 7.8 +import Data.Functor ((<$>)) + import Data.Aeson (Value (..), Object) import Data.Aeson.Types (emptyObject, emptyArray) import Data.Vector as V (null, head) From 81ee7cbd5e929caf2af351b1285f975c18d19eeb Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Tue, 20 Oct 2015 19:54:29 -0400 Subject: [PATCH 16/19] Removes redundant do --- test/Feature/AuthSpec.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Feature/AuthSpec.hs b/test/Feature/AuthSpec.hs index c12bcdee0..8c593a42b 100644 --- a/test/Feature/AuthSpec.hs +++ b/test/Feature/AuthSpec.hs @@ -18,7 +18,7 @@ spec = beforeAll it "hides tables that anonymous does not own" $ get "/authors_only" `shouldRespondWith` 404 - it "returns jwt functions as jwt tokens" $ do + it "returns jwt functions as jwt tokens" $ post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |] `shouldRespondWith` ResponseMatcher { matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |] From d000a6c61a2fd8e080480f41a9409c331c2a68fe Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Thu, 22 Oct 2015 00:01:15 -0400 Subject: [PATCH 17/19] Eliminates SET role duplication and changes Auth module interface --- src/PostgREST/Auth.hs | 29 ++++++++++++++++++----------- src/PostgREST/Middleware.hs | 17 +++++++++++------ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index daa75b4af..94e36d90b 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE FlexibleContexts #-} {-| Module : PostgREST.Auth Description : PostgREST authorization functions. @@ -10,10 +11,10 @@ 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 FlexibleContexts #-} module PostgREST.Auth ( setRole - , setJWTEnv + , claimsToSQL + , jwtClaims , tokenJWT ) where @@ -32,22 +33,28 @@ import qualified Web.JWT as JWT import qualified Data.HashMap.Lazy as H {-| - Receives the JWT secret (from config) and a JWT and - returns a list of PostgreSQL statements to set the claims - as user defined GUCs. - Except if we have a claim called role, this one is mapped to - a SET ROLE statement. + Receives a map of JWT claims and returns a list + of PostgreSQL statements to set the claims as user defined GUCs. + Except if we have a claim called role, + this one is mapped to a SET ROLE statement. In case there is any problem decoding the JWT it returns Nothing. -} -setJWTEnv :: Text -> Text -> Maybe [Text] -setJWTEnv secret input = setDBEnv jwtClaims +claimsToSQL :: JWT.ClaimsMap -> [Text] +claimsToSQL = map setVar . toList where - setDBEnv maybeClaims = (map setVar . toList) <$> maybeClaims setVar ("role", String val) = setRole val setVar (k, val) = "set local postgrest.claims." <> pgFmtIdent k <> " = " <> valueToVariable val <> ";" valueToVariable = pgFmtLit . unquoted - jwtClaims = JWT.unregisteredClaims <$> JWT.claims <$> decoded + +{-| + Receives the JWT secret (from config) and a JWT and + 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 + where decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input -- | Receives the name of a role and returns a SET ROLE statement diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs index 164b37a15..90b71a40e 100644 --- a/src/PostgREST/Middleware.hs +++ b/src/PostgREST/Middleware.hs @@ -26,13 +26,14 @@ import Network.Wai.Middleware.Gzip (def, gzip) import Network.Wai.Middleware.Static (only, staticPolicy) import PostgREST.App (contentTypeForAccept) -import PostgREST.Auth (setRole, setJWTEnv) +import PostgREST.Auth (setRole, jwtClaims, claimsToSQL) import PostgREST.Config (AppConfig (..), corsPolicy) import Prelude hiding(concat) import qualified Data.Vector as V import qualified Hasql.Backend as B +import qualified Data.Map.Lazy as M runWithClaims :: forall s. AppConfig -> (Request -> H.Tx P.Postgres s Response) -> @@ -41,16 +42,20 @@ 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 - jwtEnv = + claims = + fromMaybe (M.fromList []) $ case split (==' ') (cs auth) of - ("Bearer" : jwt : _) -> fromMaybe [] (setJWTEnv jwtSecret jwt) - _ -> [] - env = setRole anon : jwtEnv - stmt = (flip $ flip B.Stmt V.empty) True + ("Bearer" : jwt : _) -> jwtClaims jwtSecret jwt + _ -> Nothing + env = if M.member "role" claims + then jwtEnv + else setRole anon : jwtEnv + jwtEnv = claimsToSQL claims redirectInsecure :: Application -> Application redirectInsecure app req respond = do From aad19b53c7bb26253087e146eb0f746dfa8cd04f Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Thu, 22 Oct 2015 23:12:14 -0400 Subject: [PATCH 18/19] Includes one test case for recovering from from 400 error and another for invalid JWT tokens --- test/Feature/AuthSpec.hs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/Feature/AuthSpec.hs b/test/Feature/AuthSpec.hs index 8c593a42b..b02d9a334 100644 --- a/test/Feature/AuthSpec.hs +++ b/test/Feature/AuthSpec.hs @@ -26,8 +26,19 @@ spec = beforeAll , matchHeaders = ["Content-Type" <:> "application/json"] } - it "allows users with permissions to see their tables (JWT)" $ do - _ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |] + it "allows users with permissions to see their tables" $ do let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200 + + it "hides tables from users with invalid JWT" $ do + let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 404 + + it "recovers after 400 error with logged in user" $ do + _ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |] + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" + _ <- request methodPost "/rpc/problem" [auth] "" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 From 91dfd47f1d157bd1ffc6026a69b24edd11a90bb2 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Thu, 22 Oct 2015 23:20:16 -0400 Subject: [PATCH 19/19] Adds another test case for jwt with empty claims --- test/Feature/AuthSpec.hs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/Feature/AuthSpec.hs b/test/Feature/AuthSpec.hs index b02d9a334..992ca1d9e 100644 --- a/test/Feature/AuthSpec.hs +++ b/test/Feature/AuthSpec.hs @@ -36,6 +36,11 @@ spec = beforeAll request methodGet "/authors_only" [auth] "" `shouldRespondWith` 404 + it "hides tables from users with JWT that contain no claims about role" $ do + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MKYc_lOECtB0LJOiykilAdlHodB-I0_id2qHKq35dmc" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 404 + it "recovers after 400 error with logged in user" $ do _ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |] let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"