{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PackageImports #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE UndecidableInstances #-}

-- | Query haveibeenpwned database to check basic password strength in a secure way.
--
--   By checking new user passwords against a database of leaked passwords you
--   get some means for rejecting very weak or just leaked passwords.
module HaveIBeenPwned where

import "cryptonite" Crypto.Hash
import Control.Exception
import Control.Monad.Logger
import Control.Monad.Reader
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as LBS
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text.Lazy as LT
import qualified Data.Text.Lazy.Encoding
import Network.HTTP.Client
import Network.HTTP.Types.Status (Status(..))
import Safe (readMay)

data HaveIBeenPwnedConfig = HaveIBeenPwnedConfig
  { _haveIBeenPwnedConfig_manager :: Manager
  , _haveIBeenPwnedConfig_apihost :: Text
  }

-- | Result of a password check.
--
--   It is either considered secure, insecure or we can't say because of an
--   error.
data HaveIBeenPwnedResult =
    HaveIBeenPwnedResult_Secure
    -- ^ We could not find the password in any database, thus it is considered
    -- "secure" as far as this library is concerned.
  | HaveIBeenPwnedResult_Pwned Int
    -- ^ How many times the password was found in public places. Usually this
    -- will be a value greater than 0, but in any case if you hit this
    -- constructor you must assume tha password has been leaked.
  | HaveIBeenPwnedResult_Error
    -- ^ The check failed for some reason. We can't say anything about the
    -- password quality.
  deriving (Eq, Ord, Show)

class Monad m => MonadPwned m where
  -- | Returns the number of disclosures the supplied password has been seen in.
  --
  -- If this is not zero, do not use the supplied password, it is known to hackers.
  -- If it *is* zero, it might still not be safe, only that if it is
  -- compromised, that is not yet known.
  --
  -- https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange
  haveIBeenPwned :: Text -> m HaveIBeenPwnedResult

newtype PwnedT m a = PwnedT { unPwnedT :: ReaderT HaveIBeenPwnedConfig m a }
  deriving (Functor, Applicative, Monad , MonadIO, MonadLogger
    , MonadTrans
    )

runPwnedT :: PwnedT m a -> HaveIBeenPwnedConfig -> m a
runPwnedT (PwnedT (ReaderT f)) = f

mapPwnedT :: (m a -> n b) -> PwnedT m a -> PwnedT n b
mapPwnedT f = PwnedT . mapReaderT f . unPwnedT

instance MonadReader r m => MonadReader r (PwnedT m) where
  ask = lift ask
  local = mapPwnedT . local
  reader = lift . reader

instance (MonadLogger m, MonadIO m) => MonadPwned (PwnedT m) where
 haveIBeenPwned password = do
  let (pfx, rest) = passwdDigest password
  cfg <- PwnedT ask
  let request = parseRequest_ $ T.unpack $ T.concat [_haveIBeenPwnedConfig_apihost cfg, "/", pfx]
  result' <- liftIO $ try $ httpLbs request (_haveIBeenPwnedConfig_manager cfg)
  case result' of
    Left err -> do
      $(logError) $ T.pack $ show @ HttpException $ err
      return HaveIBeenPwnedResult_Error
    Right result -> case responseStatus result of
      Status 200 _ -> do
        let r = parseHIBPResponse (responseBody result) rest
        case r of
          HaveIBeenPwnedResult_Error ->
            $(logError) $ "Parsing number of occurrences failed. (Not an Int)."
          _ -> pure ()
        pure r
      Status code phrase -> do
        $(logError) $ T.pack $ show $ Status code phrase
        return HaveIBeenPwnedResult_Error


-- | Get the sha1 digest for the supplied password, split into two parts, to agree with the
--   hibp api.
passwdDigest :: Text -> (Text, Text)
passwdDigest passwd = (T.take 5 digest, T.drop 5 digest)
  where digest = T.toUpper $ T.pack $ show $ sha1 $ encodeUtf8 passwd
        sha1 :: ByteString -> Digest SHA1
        sha1 = hash

-- | The hibp response is a line separated list of colon separated hash
-- *suffixes* and a number indicating the number of times that password(hash)
-- has been seen in known publicly disclosed leaks
parseHIBPResponse :: LBS.ByteString -> Text -> HaveIBeenPwnedResult
parseHIBPResponse response suffix =
  let
    digests :: [(LT.Text, Maybe Int)]
    digests = fmap (fmap (readMay . LT.unpack . LT.drop 1) . LT.breakOn ":") $ LT.lines $ Data.Text.Lazy.Encoding.decodeUtf8 response
  in case filter ((LT.fromStrict suffix ==) . fst) digests of
    ((_,n):_) -> maybe HaveIBeenPwnedResult_Error HaveIBeenPwnedResult_Pwned n
    [] -> HaveIBeenPwnedResult_Secure