espial 0.0.10 → 0.0.11
raw patch · 6 files changed
+91/−4 lines, 6 filesdep +base64dep +cryptohash-sha256
Dependencies added: base64, cryptohash-sha256
Files
- app/migration/Main.hs +25/−1
- changelog.md +3/−0
- espial.cabal +9/−1
- src/Foundation.hs +32/−1
- src/Model.hs +5/−1
- src/ModelCustom.hs +17/−0
app/migration/Main.hs view
@@ -51,8 +51,12 @@ , privateDefault :: Maybe Bool , archiveDefault :: Maybe Bool , privacyLock :: Maybe Bool }+ | CreateApiKey { conn :: Text+ , userName :: Text } | DeleteUser { conn :: Text , userName :: Text }+ | DeleteApiKey { conn :: Text+ , userName :: Text } | ImportBookmarks { conn :: Text , userName :: Text , bookmarkFile :: FilePath }@@ -92,12 +96,32 @@ (UniqueUserName userName) (User userName hash' Nothing False False False) [ UserPasswordHash P.=. hash'- , UserApiToken P.=. Nothing , UserPrivateDefault P.=. fromMaybe False privateDefault , UserArchiveDefault P.=. fromMaybe False archiveDefault , UserPrivacyLock P.=. fromMaybe False privacyLock ] pure () :: DB ()++ CreateApiKey {..} ->+ P.runSqlite conn $ do+ apiKey@(ApiKey plainKey) <- liftIO generateApiKey+ muser <- P.getBy (UniqueUserName userName)+ case muser of+ Nothing -> liftIO (print (userName ++ " not found"))+ Just (P.Entity uid _) -> do+ -- API key is only displayed once after creation,+ -- since it is stored in hashed form.+ let hashedKey = hashApiKey apiKey+ P.update uid [ UserApiToken P.=. Just hashedKey ]+ liftIO $ print plainKey++ DeleteApiKey {..} ->+ P.runSqlite conn $ do+ muser <- P.getBy (UniqueUserName userName)+ case muser of+ Nothing -> liftIO (print (userName ++ " not found"))+ Just (P.Entity uid _) -> do+ P.update uid [ UserApiToken P.=. Nothing ] DeleteUser {..} -> P.runSqlite conn $ do
changelog.md view
@@ -1,3 +1,6 @@+__v0.0.11__+add api key auth. +add CreateApiKey/DeleteApiKey commands to executable 'migration' __v0.0.10__ update purescript&package versions
espial.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack name: espial-version: 0.0.10+version: 0.0.11 synopsis: Espial is an open-source, web-based bookmarking server. description: . Espial is an open-source, web-based bookmarking server.@@ -163,6 +163,7 @@ aeson >=1.4 , attoparsec , base >=4.8.2.0 && <4.9 || >=4.9.1.0 && <5+ , base64 , bcrypt >=0.0.8 , blaze-html >=0.9 && <1.0 , bytestring >=0.9 && <0.11@@ -173,6 +174,7 @@ , conduit >=1.0 && <2.0 , connection , containers+ , cryptohash-sha256 , data-default , directory >=1.1 && <1.4 , entropy@@ -273,6 +275,7 @@ aeson >=1.4 , attoparsec , base >=4.8.2.0 && <4.9 || >=4.9.1.0 && <5+ , base64 , bcrypt >=0.0.8 , blaze-html >=0.9 && <1.0 , bytestring >=0.9 && <0.11@@ -283,6 +286,7 @@ , conduit >=1.0 && <2.0 , connection , containers+ , cryptohash-sha256 , data-default , directory >=1.1 && <1.4 , entropy@@ -380,6 +384,7 @@ aeson >=1.4 , attoparsec , base >=4.8.2.0 && <4.9 || >=4.9.1.0 && <5+ , base64 , bcrypt >=0.0.8 , blaze-html >=0.9 && <1.0 , bytestring >=0.9 && <0.11@@ -390,6 +395,7 @@ , conduit >=1.0 && <2.0 , connection , containers+ , cryptohash-sha256 , data-default , directory >=1.1 && <1.4 , entropy@@ -493,6 +499,7 @@ aeson >=1.4 , attoparsec , base >=4.8.2.0 && <4.9 || >=4.9.1.0 && <5+ , base64 , bcrypt >=0.0.8 , blaze-html >=0.9 && <1.0 , bytestring >=0.9 && <0.11@@ -503,6 +510,7 @@ , conduit >=1.0 && <2.0 , connection , containers+ , cryptohash-sha256 , data-default , directory >=1.1 && <1.4 , entropy
src/Foundation.hs view
@@ -17,6 +17,7 @@ import qualified Data.CaseInsensitive as CI import qualified Data.Text.Encoding as TE import qualified Yesod.Core.Unsafe as Unsafe+import qualified Network.Wai as Wai data App = App { appSettings :: AppSettings@@ -67,8 +68,20 @@ else id yesodMiddleware :: HandlerFor App res -> HandlerFor App res- yesodMiddleware = customMiddleware . defaultYesodMiddleware . defaultCsrfMiddleware+ yesodMiddleware = customMiddleware . defaultYesodMiddleware . customCsrfMiddleware where+ customCsrfMiddleware handler = do+ maybeRoute <- getCurrentRoute+ dontCheckCsrf <- case maybeRoute of+ -- `maybeAuthId` checks for the validity of the Authorization+ -- header anyway, but it is still a good idea to limit this+ -- flexibility to designated routes.+ -- For the time being, `AddR` is the only route that accepts an+ -- authentication token.+ Just AddR -> isJust <$> lookupHeader "Authorization"+ _ -> pure False+ (if dontCheckCsrf then id else defaultCsrfMiddleware) handler+ customMiddleware handler = do addHeader "X-Frame-Options" "DENY" yesod <- getYesod@@ -167,6 +180,24 @@ onLogout = deleteSession userNameKey redirectToReferer = const True+ maybeAuthId = do+ req <- waiRequest+ let mAuthHeader = lookup "Authorization" (Wai.requestHeaders req)+ extractKey = stripPrefix "ApiKey " . TE.decodeUtf8+ case mAuthHeader of+ Just authHeader ->+ case extractKey authHeader of+ Just apiKey -> do+ user <- liftHandler $ runDB $ getApiKeyUser (ApiKey apiKey)+ let userId = entityKey <$> user+ pure userId+ -- Since we disable CSRF middleware in the presence of Authorization+ -- header, we need to explicitly check for the validity of the header+ -- content. Otherwise, a dummy Authorization header with garbage input+ -- could be provided to circumvent CSRF token requirement, making the app+ -- vulnerable to CSRF attacks.+ Nothing -> pure Nothing+ _ -> defaultMaybeAuthId instance YesodAuthPersist App
src/Model.hs view
@@ -34,7 +34,7 @@ Id Int64 name Text passwordHash BCrypt- apiToken Text Maybe+ apiToken HashedApiKey Maybe privateDefault Bool archiveDefault Bool privacyLock Bool@@ -158,6 +158,10 @@ getUserByName :: UserNameP -> DB (Maybe (Entity User)) getUserByName (UserNameP uname) = selectFirst [UserName CP.==. uname] []++getApiKeyUser :: ApiKey -> DB (Maybe (Entity User))+getApiKeyUser apiKey =+ selectFirst [UserApiToken CP.==. Just (hashApiKey apiKey)] [] -- returns a list of pair of bookmark with tags merged into a string bookmarksTagsQuery
src/ModelCustom.hs view
@@ -12,6 +12,8 @@ import System.Entropy (getEntropy) import qualified Data.ByteString.Builder as BB import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString.Base64.URL as Base64Url+import qualified Crypto.Hash.SHA256 as SHA256 mkSlug :: Int -> IO T.Text mkSlug size =@@ -58,3 +60,18 @@ validatePasswordHash :: BCrypt -> T.Text -> Bool validatePasswordHash hash' pass = do validatePassword (TE.encodeUtf8 (unBCrypt hash')) (TE.encodeUtf8 pass)++newtype ApiKey = ApiKey { unApiKey :: T.Text }++newtype HashedApiKey+ = HashedApiKey T.Text+ deriving stock (Eq, Ord, Show)+ deriving newtype (PersistField, PersistFieldSql, A.FromJSON, A.ToJSON)++generateApiKey :: IO ApiKey+generateApiKey = do+ bytes <- getEntropy 32+ pure $ ApiKey $ Base64Url.encodeBase64 bytes++hashApiKey :: ApiKey -> HashedApiKey+hashApiKey = HashedApiKey . TE.decodeUtf8 . Base64Url.encodeBase64' . SHA256.hash . TE.encodeUtf8 . unApiKey