Merge pull request #194 from framp/jwt

JWT support
This commit is contained in:
Joe Nelson
2015-05-19 22:17:34 -07:00
13 changed files with 124 additions and 30 deletions
+6 -4
View File
@@ -19,8 +19,8 @@ executable postgrest
default-language: Haskell2010 default-language: Haskell2010
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
build-depends: base >=4.6 && <5 build-depends: base >=4.6 && <5
, hasql == 0.7.3, hasql-backend == 0.4.1 , hasql == 0.7.3.1, hasql-backend == 0.4.1
, hasql-postgres == 0.10.3 , hasql-postgres == 0.10.3.1
, warp >= 3.0.2, wai >= 3.0.1 , warp >= 3.0.2, wai >= 3.0.1
, wai-extra, wai-cors , wai-extra, wai-cors
, wai-middleware-static >= 0.6.0 , wai-middleware-static >= 0.6.0
@@ -43,6 +43,7 @@ executable postgrest
, vector , vector
, mtl , mtl
, cassava , cassava
, jwt
Other-Modules: App Other-Modules: App
, Auth , Auth
, Config , Config
@@ -74,8 +75,8 @@ Test-Suite spec
, SpecHelper , SpecHelper
Build-Depends: base, hspec >= 2.1.2, QuickCheck Build-Depends: base, hspec >= 2.1.2, QuickCheck
, hspec-wai >= 0.5.0, hspec-wai-json , hspec-wai >= 0.5.0, hspec-wai-json
, hasql == 0.7.3, hasql-backend == 0.4.1 , hasql == 0.7.3.1, hasql-backend == 0.4.1
, hasql-postgres == 0.10.3 , hasql-postgres == 0.10.3.1
, warp, wai , warp, wai
, packdeps, hlint , packdeps, hlint
, HTTP, convertible , HTTP, convertible
@@ -102,3 +103,4 @@ Test-Suite spec
, cassava , cassava
, process , process
, heredoc , heredoc
, jwt
+29 -3
View File
@@ -34,13 +34,16 @@ import qualified Hasql as H
import qualified Hasql.Backend as B import qualified Hasql.Backend as B
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
import Config (AppConfig(..))
import Auth import Auth
import PgQuery import PgQuery
import RangeQuery import RangeQuery
import PgStructure import PgStructure
app :: Text -> BL.ByteString -> Request -> H.Tx P.Postgres s Response import Prelude
app v1schema reqBody req =
app :: AppConfig -> BL.ByteString -> Request -> H.Tx P.Postgres s Response
app conf reqBody req =
case (path, verb) of case (path, verb) of
([], _) -> do ([], _) -> do
body <- encode <$> tables (cs schema) body <- encode <$> tables (cs schema)
@@ -102,6 +105,27 @@ app v1schema reqBody req =
, (hLocation, "/postgrest/users?id=eq." <> cs (userId u)) , (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 ->
return $ responseLBS status201 [ jsonH ] $
encode . object $ [("token", String $ tokenJWT jwtSecret (cs $ userId u) role)]
_ -> return $ responseLBS status401 [jsonH] $
encode . object $ [("message", String "Failed authentication.")]
([table], "POST") -> do ([table], "POST") -> do
let qt = QualifiedTable schema (cs table) let qt = QualifiedTable schema (cs table)
echoRequested = lookup "Prefer" hdrs == Just "return=representation" echoRequested = lookup "Prefer" hdrs == Just "return=representation"
@@ -201,7 +225,9 @@ app v1schema reqBody req =
verb = requestMethod req verb = requestMethod req
qq = queryString req qq = queryString req
hdrs = requestHeaders req hdrs = requestHeaders req
schema = requestedSchema v1schema hdrs schema = requestedSchema (cs $ configV1Schema conf) hdrs
authenticator = cs $ configDbUser conf
jwtSecret = cs $ configJwtSecret conf
range = rangeRequested hdrs range = rangeRequested hdrs
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
+22 -2
View File
@@ -3,17 +3,21 @@ module Auth where
import Data.Aeson import Data.Aeson
import Control.Monad (mzero) import Control.Monad (mzero)
import Control.Applicative ( (<*>), (<$>) ) import Control.Applicative
import Crypto.BCrypt import Crypto.BCrypt
import Data.Text import Data.Text
import Data.Monoid import Data.Monoid
import Data.Map
import qualified Data.Vector as V import qualified Data.Vector as V
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Backend as B import qualified Hasql.Backend as B
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
import qualified Web.JWT as JWT
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import PgQuery (pgFmtLit) import PgQuery (pgFmtLit)
import Prelude
import System.IO.Unsafe import System.IO.Unsafe
data AuthUser = AuthUser { data AuthUser = AuthUser {
@@ -26,7 +30,7 @@ instance FromJSON AuthUser where
parseJSON (Object v) = AuthUser <$> parseJSON (Object v) = AuthUser <$>
v .: "id" <*> v .: "id" <*>
v .: "pass" <*> v .: "pass" <*>
v .: "role" v .:? "role" .!= ""
parseJSON _ = mzero parseJSON _ = mzero
instance ToJSON AuthUser where instance ToJSON AuthUser where
@@ -69,3 +73,19 @@ signInRole user pass = do
then LoginSuccess role then LoginSuccess role
else LoginFailed else LoginFailed
) u ) u
signInWithJWT :: Text -> Text -> LoginAttempt
signInWithJWT secret input = case maybeRole of
Just (Just (String role)) -> LoginSuccess $ cs role
_ -> LoginFailed
where
maybeRole = (Data.Map.lookup "role" <$> claims) ::Maybe (Maybe Value)
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
where
claimsSet = JWT.def {
JWT.unregisteredClaims = Data.Map.fromList [("id", String uid), ("role", String role)]
}
+4
View File
@@ -8,6 +8,7 @@ import qualified Data.ByteString.Char8 as BS
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Options.Applicative hiding (columns) import Options.Applicative hiding (columns)
import Network.Wai.Middleware.Cors (CorsResourcePolicy(..)) import Network.Wai.Middleware.Cors (CorsResourcePolicy(..))
import Prelude
data AppConfig = AppConfig { data AppConfig = AppConfig {
configDbName :: String configDbName :: String
@@ -21,6 +22,8 @@ data AppConfig = AppConfig {
, configSecure :: Bool , configSecure :: Bool
, configPool :: Int , configPool :: Int
, configV1Schema :: String , configV1Schema :: String
, configJwtSecret :: String
} }
argParser :: Parser AppConfig argParser :: Parser AppConfig
@@ -36,6 +39,7 @@ argParser = AppConfig
<*> switch (long "secure" <> short 's' <> help "Redirect all requests to HTTPS") <*> switch (long "secure" <> short 's' <> help "Redirect all requests to HTTPS")
<*> option auto (long "db-pool" <> metavar "COUNT" <> value 10 <> help "Max connections in database pool" <> showDefault) <*> option auto (long "db-pool" <> metavar "COUNT" <> value 10 <> help "Max connections in database pool" <> showDefault)
<*> strOption (long "v1schema" <> metavar "NAME" <> value "1" <> help "Schema to use for nonspecified version (or explicit v1)" <> showDefault) <*> strOption (long "v1schema" <> metavar "NAME" <> value "1" <> help "Schema to use for nonspecified version (or explicit v1)" <> showDefault)
<*> strOption (long "jwt-secret" <> metavar "SECRET" <> value "secret" <> help "Secret used to encrypt and decrypt JWT tokens)" <> showDefault)
defaultCorsPolicy :: CorsResourcePolicy defaultCorsPolicy :: CorsResourcePolicy
defaultCorsPolicy = CorsResourcePolicy Nothing defaultCorsPolicy = CorsResourcePolicy Nothing
+3 -3
View File
@@ -38,6 +38,8 @@ main = do
unless (configSecure conf) $ unless (configSecure conf) $
putStrLn "WARNING, running in insecure mode, auth will be in plaintext" putStrLn "WARNING, running in insecure mode, auth will be in plaintext"
unless ("secret" /= configJwtSecret conf) $
putStrLn "WARNING, running in insecure mode, JWT secret is the default value"
Prelude.putStrLn $ "Listening on port " ++ Prelude.putStrLn $ "Listening on port " ++
(show $ configPort conf :: String) (show $ configPort conf :: String)
@@ -53,8 +55,6 @@ main = do
. (if configSecure conf then redirectInsecure else id) . (if configSecure conf then redirectInsecure else id)
. gzip def . cors corsPolicy . gzip def . cors corsPolicy
. staticPolicy (only [("favicon.ico", "static/favicon.ico")]) . staticPolicy (only [("favicon.ico", "static/favicon.ico")])
anonRole = cs $ configAnonRole conf
currRole = cs $ configDbUser conf
poolSettings <- maybe (fail "Improper session settings") return $ poolSettings <- maybe (fail "Improper session settings") return $
H.poolSettings (fromIntegral $ configPool conf) 30 H.poolSettings (fromIntegral $ configPool conf) 30
@@ -64,7 +64,7 @@ main = do
runSettings appSettings $ middle $ \req respond -> do runSettings appSettings $ middle $ \req respond -> do
body <- strictRequestBody req body <- strictRequestBody req
resOrError <- liftIO $ H.session pool $ H.tx Nothing $ resOrError <- liftIO $ H.session pool $ H.tx Nothing $
authenticated currRole anonRole (app (cs $ configV1Schema conf) body) req authenticated conf (app conf body) req
either (respond . errResponse) respond resOrError either (respond . errResponse) respond resOrError
where where
+12 -4
View File
@@ -4,7 +4,7 @@
module Middleware where module Middleware where
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Monoid (mconcat) import Data.Monoid
import Data.Text import Data.Text
-- import Data.Pool(withResource, Pool) -- import Data.Pool(withResource, Pool)
@@ -19,13 +19,16 @@ import Network.Wai (Application, requestHeaders, responseLBS, rawPathInfo,
rawQueryString, isSecure, Request(..), Response) rawQueryString, isSecure, Request(..), Response)
import Network.URI (URI(..), parseURI) import Network.URI (URI(..), parseURI)
import Auth (LoginAttempt(..), signInRole, setRole, resetRole) import Config (AppConfig(..))
import Auth (LoginAttempt(..), signInRole, signInWithJWT, setRole, resetRole)
import Codec.Binary.Base64.String (decode) import Codec.Binary.Base64.String (decode)
authenticated :: forall s. Text -> Text -> import Prelude
authenticated :: 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
authenticated currentRole anon app req = do authenticated conf app req = do
attempt <- httpRequesterRole (requestHeaders req) attempt <- httpRequesterRole (requestHeaders req)
case attempt of case attempt of
MalformedAuth -> MalformedAuth ->
@@ -36,6 +39,9 @@ authenticated currentRole anon app req = do
NoCredentials -> if anon /= currentRole then runInRole anon else app req NoCredentials -> if anon /= currentRole then runInRole anon else app req
where where
jwtSecret = cs $ configJwtSecret conf
currentRole = cs $ configDbUser conf
anon = cs $ configAnonRole conf
httpRequesterRole :: RequestHeaders -> H.Tx P.Postgres s LoginAttempt httpRequesterRole :: RequestHeaders -> H.Tx P.Postgres s LoginAttempt
httpRequesterRole hdrs = do httpRequesterRole hdrs = do
let auth = fromMaybe "" $ lookup hAuthorization hdrs let auth = fromMaybe "" $ lookup hAuthorization hdrs
@@ -44,6 +50,8 @@ authenticated currentRole anon app req = do
case split (==':') (cs . decode . cs $ b64) of case split (==':') (cs . decode . cs $ b64) of
(u:p:_) -> signInRole u p (u:p:_) -> signInRole u p
_ -> return MalformedAuth _ -> return MalformedAuth
("Bearer" : jwt : _) ->
return $ signInWithJWT jwtSecret jwt
_ -> return NoCredentials _ -> return NoCredentials
runInRole :: Text -> H.Tx P.Postgres s Response runInRole :: Text -> H.Tx P.Postgres s Response
+3 -1
View File
@@ -17,7 +17,7 @@ import qualified Data.ByteString.Char8 as BS
import Data.Monoid import Data.Monoid
import Data.Vector (empty) import Data.Vector (empty)
import Data.Maybe (fromMaybe, mapMaybe) import Data.Maybe (fromMaybe, mapMaybe)
import Data.Functor ( (<$>) ) import Data.Functor
import Control.Monad (join) import Control.Monad (join)
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
@@ -25,6 +25,8 @@ import qualified Data.List as L
import qualified Data.Vector as V import qualified Data.Vector as V
import Data.Scientific (isInteger, formatScientific, FPFormat(..)) import Data.Scientific (isInteger, formatScientific, FPFormat(..))
import Prelude
type PStmt = H.Stmt P.Postgres type PStmt = H.Stmt P.Postgres
instance Monoid PStmt where instance Monoid PStmt where
mappend (B.Stmt query params prep) (B.Stmt query' params' prep') = mappend (B.Stmt query params prep) (B.Stmt query' params' prep') =
+3 -1
View File
@@ -9,13 +9,15 @@ import Data.Aeson
import Data.Functor.Identity import Data.Functor.Identity
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Control.Applicative ( (<$>) ) import Control.Applicative
import qualified Data.Map as Map import qualified Data.Map as Map
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
import Prelude
foreignKeys :: QualifiedTable -> H.Tx P.Postgres s (Map.Map Text ForeignKey) foreignKeys :: QualifiedTable -> H.Tx P.Postgres s (Map.Map Text ForeignKey)
foreignKeys table = do foreignKeys table = do
r <- H.listEx $ [H.stmt| r <- H.listEx $ [H.stmt|
+2
View File
@@ -20,6 +20,8 @@ import Text.Read (readMaybe)
import Data.Maybe (fromMaybe, listToMaybe) import Data.Maybe (fromMaybe, listToMaybe)
import Prelude
type NonnegRange = Range Int type NonnegRange = Range Int
rangeParse :: BS.ByteString -> Maybe NonnegRange rangeParse :: BS.ByteString -> Maybe NonnegRange
+28 -4
View File
@@ -18,13 +18,37 @@ spec = beforeAll
it "hides tables that anonymous does not own" $ it "hides tables that anonymous does not own" $
get "/authors_only" `shouldRespondWith` 404 get "/authors_only" `shouldRespondWith` 404
it "indicates login failure" $ do it "indicates login failure (BasicAuth)" $ do
let auth = authHeader "postgrest_test_author" "fakefake" let auth = authHeaderBasic "postgrest_test_author" "fakefake"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 401 `shouldRespondWith` 401
it "allows users with permissions to see their tables" $ do it "allows users with permissions to see their tables (BasicAuth)" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |] _ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
let auth = authHeader "jdoe" "1234" let auth = authHeaderBasic "jdoe" "1234"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 200 `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" } |]
`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
, 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" } |]
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 200
+1 -1
View File
@@ -27,7 +27,7 @@ spec = around withApp $ do
it "lists only views user has permission to see" $ do it "lists only views user has permission to see" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |] _ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
let auth = authHeader "jdoe" "1234" let auth = authHeaderBasic "jdoe" "1234"
request methodGet "/" [auth] "" request methodGet "/" [auth] ""
`shouldRespondWith` [json| [ `shouldRespondWith` [json| [
+8 -6
View File
@@ -36,7 +36,7 @@ isLeft (Left _ ) = True
isLeft _ = False isLeft _ = False
cfg :: AppConfig cfg :: AppConfig
cfg = AppConfig "postgrest_test" 5432 "postgrest_test" "" "localhost" 3000 "postgrest_anonymous" False 10 "1" cfg = AppConfig "postgrest_test" 5432 "postgrest_test" "" "localhost" 3000 "postgrest_anonymous" False 10 "1" "safe"
testPoolOpts :: PoolSettings testPoolOpts :: PoolSettings
testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30 testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30
@@ -50,15 +50,13 @@ pgSettings = P.ParamSettings (cs $ configDbHost cfg)
withApp :: ActionWith Application -> IO () withApp :: ActionWith Application -> IO ()
withApp perform = do withApp perform = do
let anonRole = cs $ configAnonRole cfg
currRole = cs $ configDbUser cfg
pool :: H.Pool P.Postgres pool :: H.Pool P.Postgres
<- H.acquirePool pgSettings testPoolOpts <- H.acquirePool pgSettings testPoolOpts
perform $ middle $ \req resp -> do perform $ middle $ \req resp -> do
body <- strictRequestBody req body <- strictRequestBody req
result <- liftIO $ H.session pool $ H.tx Nothing result <- liftIO $ H.session pool $ H.tx Nothing
$ authenticated currRole anonRole (app (cs $ configV1Schema cfg) body) req $ authenticated cfg (app cfg body) req
either (resp . errResponse) resp result either (resp . errResponse) resp result
where middle = cors corsPolicy where middle = cors corsPolicy
@@ -93,9 +91,13 @@ matchHeader :: CI BS.ByteString -> String -> [Header] -> Bool
matchHeader name valRegex headers = matchHeader name valRegex headers =
maybe False (=~ valRegex) $ lookup name headers maybe False (=~ valRegex) $ lookup name headers
authHeader :: String -> String -> Header authHeaderBasic :: String -> String -> Header
authHeader u p = authHeaderBasic u p =
(hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p)) (hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p))
authHeaderJWT :: String -> Header
authHeaderJWT token =
(hAuthorization, cs $ "Bearer " ++ token)
testPool :: IO(H.Pool P.Postgres) testPool :: IO(H.Pool P.Postgres)
testPool = H.acquirePool pgSettings testPoolOpts testPool = H.acquirePool pgSettings testPoolOpts
+3 -1
View File
@@ -8,9 +8,11 @@ module TestTypes (
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Aeson ((.:)) import Data.Aeson ((.:))
-- import Data.Maybe (fromJust) -- import Data.Maybe (fromJust)
import Control.Applicative ((<$>), (<*>)) import Control.Applicative
import Control.Monad (mzero) import Control.Monad (mzero)
import Prelude
data IncPK = IncPK { data IncPK = IncPK {
incId :: Int incId :: Int
, incNullableStr :: Maybe String , incNullableStr :: Maybe String