From e364cbc3ffc7717906764b0280516eed66d2cdf8 Mon Sep 17 00:00:00 2001 From: Trevor Basinger Date: Sun, 11 Dec 2016 11:01:03 -0600 Subject: [PATCH] Support binary (b64) JWT secrets (#772) --- .gitignore | 3 +++ CHANGELOG.md | 1 + main/Main.hs | 36 ++++++++++++++++++++++------- postgrest.cabal | 5 +++- src/PostgREST/App.hs | 4 ++-- src/PostgREST/Config.hs | 33 +++++++++++++++----------- test/Feature/BinaryJwtSecretSpec.hs | 21 +++++++++++++++++ test/Main.hs | 16 +++++++++---- test/SpecHelper.hs | 34 +++++++++++++++++---------- 9 files changed, 112 insertions(+), 41 deletions(-) create mode 100644 test/Feature/BinaryJwtSecretSpec.hs diff --git a/.gitignore b/.gitignore index d4653b751..3bc1d11e1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ codex.tags .stack-work* tags site +*~ +*#* +.#* diff --git a/CHANGELOG.md b/CHANGELOG.md index 853497b59..583f6c8c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Custom request validation with `--pre-request` argument - @begriffs - Ability to order by jsonb keys - @steve-chavez - Ability to specify offset for a deeper level - @ruslantalpa +- Ability to use binary base64 encoded secrets - @TrevorBasinger ### Fixed - Do not apply limit to parent items - @ruslantalpa diff --git a/main/Main.hs b/main/Main.hs index a72235efb..e3a4dc8b6 100644 --- a/main/Main.hs +++ b/main/Main.hs @@ -14,9 +14,11 @@ import PostgREST.OpenAPI (isMalformedProxyUri) import PostgREST.DbStructure import Control.AutoUpdate +import Data.ByteString.Base64 (decode) import Data.String (IsString (..)) -import Data.Text (stripPrefix) -import Data.Text.IO (hPutStrLn) +import Data.Text (stripPrefix, pack, replace) +import Data.Text.Encoding (encodeUtf8, decodeUtf8) +import Data.Text.IO (hPutStrLn, readFile) import Data.Function (id) import Data.Time.Clock.POSIX (getPOSIXTime) import qualified Hasql.Query as H @@ -99,9 +101,27 @@ main = do runSettings appSettings $ postgrest conf refDbStructure pool getTime loadSecretFile :: AppConfig -> IO AppConfig -loadSecretFile conf = do - let s = configJwtSecret conf - real <- case join (stripPrefix "@" <$> s) of - Nothing -> return s -- the string is the secret, not a filename - Just filename -> sequence . Just $ readFile (toS filename) - return conf { configJwtSecret = real } +loadSecretFile conf = extractAndTransform mSecret + where + mSecret = decodeUtf8 <$> configJwtSecret conf + isB64 = configJwtSecretIsBase64 conf + + extractAndTransform :: Maybe Text -> IO AppConfig + extractAndTransform Nothing = return conf + extractAndTransform (Just s) = + fmap setSecret $ transformString isB64 =<< + case stripPrefix "@" s of + Nothing -> return s + Just filename -> readFile (toS filename) + + transformString :: Bool -> Text -> IO ByteString + transformString False t = return . encodeUtf8 $ t + transformString True t = + case decode (encodeUtf8 $ replaceUrlChars t) of + Left errMsg -> panic $ pack errMsg + Right bs -> return bs + + setSecret bs = conf { configJwtSecret = Just bs } + + replaceUrlChars = replace "_" "/" . replace "-" "+" . replace "." "=" + diff --git a/postgrest.cabal b/postgrest.cabal index b14cef968..2d0c937aa 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -38,6 +38,8 @@ executable postgrest , text , time , warp + , bytestring + , base64-bytestring if !os(windows) build-depends: unix @@ -108,6 +110,7 @@ Test-Suite spec Hs-Source-Dirs: test Main-Is: Main.hs Other-Modules: Feature.AuthSpec + , Feature.BinaryJwtSecretSpec , Feature.ConcurrentSpec , Feature.CorsSpec , Feature.DeleteSpec @@ -126,8 +129,8 @@ Test-Suite spec , async , auto-update , base - , base64-string , bytestring + , base64-bytestring , case-insensitive , cassava , contravariant diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 67ceff272..4cb58885d 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -29,7 +29,7 @@ import Network.HTTP.Types.Status import Network.HTTP.Types.URI (renderSimpleQuery) import Network.Wai import Network.Wai.Middleware.RequestLogger (logStdout) -import Web.JWT (secret) +import Web.JWT (binarySecret) import Data.Aeson import Data.Aeson.Types (emptyArray) @@ -83,7 +83,7 @@ postgrest conf refDbStructure pool getTime = response <- case userApiRequest (configSchema conf) req body of Left err -> return $ apiRequestErrResponse err Right apiRequest -> do - let jwtSecret = secret <$> configJwtSecret conf + let jwtSecret = binarySecret <$> configJwtSecret conf eClaims = jwtClaims jwtSecret (iJWT apiRequest) time authed = containsRole eClaims handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 17b745bcd..a680c3e7a 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -23,12 +23,14 @@ module PostgREST.Config ( prettyVersion import System.IO.Error (IOError) import Control.Applicative +import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BS import qualified Data.CaseInsensitive as CI import qualified Data.Configurator as C import qualified Data.Configurator.Types as C import Data.List (lookup) import Data.Text (strip, intercalate, lines) +import Data.Text.Encoding (encodeUtf8) import Data.Text.IO (hPutStrLn) import Data.Version (versionBranch) import Network.Wai @@ -36,24 +38,27 @@ import Network.Wai.Middleware.Cors (CorsResourcePolicy (..)) import Options.Applicative hiding (str) import Paths_postgrest (version) import Text.Heredoc -import Text.PrettyPrint.ANSI.Leijen hiding ((<>)) +import Text.PrettyPrint.ANSI.Leijen hiding ((<>), (<$>)) import Protolude hiding (intercalate , (<>)) -- | Config file settings for the server data AppConfig = AppConfig { - configDatabase :: Text - , configAnonRole :: Text - , configProxyUri :: Maybe Text - , configSchema :: Text - , configHost :: Text - , configPort :: Int - , configJwtSecret :: Maybe Text - , configPool :: Int - , configMaxRows :: Maybe Integer - , configReqCheck :: Maybe Text - , configQuiet :: Bool + configDatabase :: Text + , configAnonRole :: Text + , configProxyUri :: Maybe Text + , configSchema :: Text + , configHost :: Text + , configPort :: Int + + , configJwtSecret :: Maybe B.ByteString + , configJwtSecretIsBase64 :: Bool + + , configPool :: Int + , configMaxRows :: Maybe Integer + , configReqCheck :: Maybe Text + , configQuiet :: Bool } defaultCorsPolicy :: CorsResourcePolicy @@ -105,12 +110,13 @@ readOptions = do cProxy <- C.lookup conf "server-proxy-uri" -- jwt --------------- cJwtSec <- C.lookup conf "jwt-secret" + cJwtB64 <- C.lookupDefault False conf "secret-is-base64" -- safety ------------ cMaxRows <- C.lookup conf "max-rows" cReqCheck <- C.lookup conf "pre-request" return $ AppConfig cDbUri cDbAnon cProxy cDbSchema cHost cPort - cJwtSec cPool cMaxRows cReqCheck False + (encodeUtf8 <$> cJwtSec) cJwtB64 cPool cMaxRows cReqCheck False where opts = info (helper <*> pathParser) $ @@ -156,6 +162,7 @@ readOptions = do |## choose a secret to enable JWT auth |## (use "@filename" to load from separate file) |# jwt-secret = "foo" + |# secret-is-base64 = false | |## limit rows in response |# max-rows = 1000 diff --git a/test/Feature/BinaryJwtSecretSpec.hs b/test/Feature/BinaryJwtSecretSpec.hs new file mode 100644 index 000000000..d2cf272a0 --- /dev/null +++ b/test/Feature/BinaryJwtSecretSpec.hs @@ -0,0 +1,21 @@ +module Feature.BinaryJwtSecretSpec where + +-- {{{ Imports +import Test.Hspec +import Test.Hspec.Wai +import Network.HTTP.Types + +import SpecHelper +import Network.Wai (Application) + +import Protolude hiding (get) +-- }}} + +spec :: SpecWith Application +spec = describe "server started with binary JWT secret" $ + + -- this test will stop working 9999999999s after the UNIX EPOCH + it "succeeds with jwt token encoded with a binary secret" $ do + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.l_EcSRWeNtL4OKUTIplrHyioNrff9Rd0MV7RXNCxCyk" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 diff --git a/test/Main.hs b/test/Main.hs index 4be07c0cf..ccb31fcf6 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -13,6 +13,7 @@ import Data.IORef import Data.Time.Clock.POSIX (getPOSIXTime) import qualified Feature.AuthSpec +import qualified Feature.BinaryJwtSecretSpec import qualified Feature.ConcurrentSpec import qualified Feature.CorsSpec import qualified Feature.DeleteSpec @@ -39,11 +40,12 @@ main = do result <- P.use pool $ getDbStructure "test" refDbStructure <- newIORef $ either (panic.show) id result - let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime - ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime - unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime - proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime - noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime + let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime + ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime + unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime + proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime + noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime + binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime let reset = resetDb testDbConn hspec $ do @@ -65,6 +67,10 @@ main = do beforeAll_ reset . before noJwtApp $ describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec + -- this test runs with a binary JWT secret + beforeAll_ reset . before binaryJwtApp $ + describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec + where specs = map (uncurry describe) [ ("Feature.AuthSpec" , Feature.AuthSpec.spec) diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 247cb330c..2b93e7b8a 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -5,7 +5,7 @@ import Control.Monad (void) import qualified System.IO.Error as E import System.Environment (getEnv) -import Codec.Binary.Base64.String (encode) +import qualified Data.ByteString.Base64 as B64 (encode, decodeLenient) import Data.CaseInsensitive (CI(..)) import Data.List (lookup) import Text.Regex.TDFA ((=~)) @@ -54,25 +54,35 @@ getEnvVarWithDefault var def = do varValue <- getEnv (toS var) `E.catchIOError` const (return $ toS def) return $ toS varValue +_baseCfg :: AppConfig +_baseCfg = -- Connection Settings + AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000 + -- Jwt settings + (Just $ encodeUtf8 "safe") False + -- Connection Modifiers + 10 Nothing (Just "test.switch_role") + -- Debug Settings + True + testCfg :: Text -> AppConfig -testCfg testDbConn = - AppConfig testDbConn "postgrest_test_anonymous" Nothing "test" "localhost" 3000 (Just "safe") 10 Nothing (Just "test.switch_role") True +testCfg testDbConn = _baseCfg { configDatabase = testDbConn } testCfgNoJWT :: Text -> AppConfig -testCfgNoJWT testDbConn = - AppConfig testDbConn "postgrest_test_anonymous" Nothing "test" "localhost" 3000 Nothing 10 Nothing Nothing True +testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing } testUnicodeCfg :: Text -> AppConfig -testUnicodeCfg testDbConn = - AppConfig testDbConn "postgrest_test_anonymous" Nothing "تست" "localhost" 3000 (Just "safe") 10 Nothing Nothing True +testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" } testLtdRowsCfg :: Text -> AppConfig -testLtdRowsCfg testDbConn = - AppConfig testDbConn "postgrest_test_anonymous" Nothing "test" "localhost" 3000 (Just "safe") 10 (Just 2) Nothing True +testLtdRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 } testProxyCfg :: Text -> AppConfig -testProxyCfg testDbConn = - AppConfig testDbConn "postgrest_test_anonymous" (Just "https://postgrest.com/openapi.json") "test" "localhost" 3000 (Just "safe") 10 Nothing Nothing True +testProxyCfg testDbConn = (testCfg testDbConn) { configProxyUri = Just "https://postgrest.com/openapi.json" } + +testCfgBinaryJWT :: Text -> AppConfig +testCfgBinaryJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Just secretBs } + where secretBs = B64.decodeLenient "h2CGB1FoBd51aQooCS2g+UmRgYQfTPQ6v3+9ALbaqM4=" + resetDb :: Text -> IO () resetDb dbConn = loadFixture dbConn "data" @@ -99,7 +109,7 @@ matchHeader name valRegex headers = authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header authHeaderBasic u p = - (hAuthorization, "Basic " <> (toS . encode . toS $ u <> ":" <> p)) + (hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p)) authHeaderJWT :: BS.ByteString -> Header authHeaderJWT token =