packages feed

hstratus-auth-0.1.0.0: src-internal/Network/HStratus/Internal/Session.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
{-# OPTIONS_HADDOCK prune #-}

module Network.HStratus.Internal.Session
  ( -- * Credentials
    Credentials (..)

    -- ** paths related to @Credentials@
  , cookiePath
  , clientIdPath
  , credentialsPath
  , savedHeadersPath
  , loginMsgPath

    -- * Session
  , Session (..)
  , SavedHeaders (..)
  , loadSession
  , saveCredentials
  , saveCredentialsTo
  , loadSavedHeaders
  , updateSessionSavedHeaders
  , updateSavedHeaders
  , pristine
  , saveLoginMsg

    -- * AccountData
  , Webservice (..)
  , AccountData (..)
  , accountDataRequires2FA
  , accountDataRequires2SA
  , unknownAccountData
  , accountDataPath
  , saveAccountData
  , loadAccountData

    -- * path components
  , appBase
  , (</>)

    -- * Utilities
  , encodeFileAtomic

    -- * File security
  , checkSecureMode
  , requireSecureFile
  , checkSessionFiles
  )
where

import Control.Applicative ((<|>))
import Control.Exception (bracketOnError, throwIO)
import Control.Monad (forM, when, (>=>))
import Data.Aeson
  ( FromJSON (..)
  , KeyValue (..)
  , Options (..)
  , ToJSON (..)
  , Value
  , eitherDecodeFileStrict
  , encode
  , genericParseJSON
  , genericToEncoding
  , genericToJSON
  , object
  , withObject
  , (.:)
  , (.:?)
  )
import Data.Aeson.Casing (aesonPrefix, snakeCase)
import qualified Data.Aeson.Key as AesonKey
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Bits ((.&.))
import qualified Data.ByteString.Lazy as LBS
import Data.Char (isAlphaNum)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (catMaybes)
import Data.String.Conv (toS)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import Data.UUID (toText)
import Data.UUID.V4 (nextRandom)
import GHC.Generics (Generic)
import Network.HStratus.Internal.Http
  ( hCounter
  , hCountry
  , hSessionId
  , hSessionToken
  , hTrustToken
  )
import Network.HTTP.Types.Header (Header)
import Numeric (showOct)
import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile, renameFile)
import System.Environment.XDG.BaseDir (getUserConfigDir)
import System.FilePath (takeDirectory, (</>))
import System.IO (hClose, openTempFile)
import System.Posix.Files (fileMode, getFileStatus, setFileMode)
import System.Posix.Types (FileMode)


-- | Update the @SavedHeaders@ using some response headers
updateSavedHeaders :: [Header] -> SavedHeaders -> SavedHeaders
updateSavedHeaders hs sd =
  sd
    { shCountry = (toS <$> lookup hCountry hs) <|> shCountry sd
    , shSessionId = (toS <$> lookup hSessionId hs) <|> shSessionId sd
    , shSessionToken = (toS <$> lookup hSessionToken hs) <|> shSessionToken sd
    , shTrustToken = (toS <$> lookup hTrustToken hs) <|> shTrustToken sd
    , shCounter = (toS <$> lookup hCounter hs) <|> shCounter sd
    }


data Session = Session
  { sessionCreds :: !Credentials
  -- ^ the credentials used to authenticate
  , sessionTopDir :: !FilePath
  -- ^ directory where session files (cookies, headers, account data) are stored
  , sessionClientId :: !Text
  -- ^ per-client OAuth state identifier sent with each request
  }
  deriving
    ( Eq
      -- ^ don't derive Show to avoid the risk of logging a password
    )


-- | Generates a new client ID.
newClientId :: IO Text
newClientId = ("auth-" <>) . toText <$> nextRandom


{- | Determine the path of file containing the HTTP response headers to be
preserved to maintain a user's authentication state
-}
savedHeadersPath :: FilePath -> Credentials -> FilePath
savedHeadersPath topDir creds = topDir </> Text.unpack (sessionBase creds)


-- | Determine the Cookie Jar file for user with the given credentials
cookiePath :: FilePath -> Credentials -> FilePath
cookiePath topDir creds = topDir </> Text.unpack (cookieBase creds)


{- | Determine the path of file containing the client ID for user with the given
credentials
-}
clientIdPath :: FilePath -> Credentials -> FilePath
clientIdPath topDir creds = topDir </> Text.unpack (clientIdBase creds)


-- | Determine the path of file to save the message the api returns on logon
loginMsgPath :: FilePath -> Credentials -> FilePath
loginMsgPath topDir creds = topDir </> Text.unpack (loginMsgBase creds)


-- | Save the login message to user specific filepath
saveLoginMsg :: Session -> Value -> IO ()
saveLoginMsg Session{sessionCreds = creds, sessionTopDir = topDir} = saveValue (loginMsgPath topDir creds)


-- | Metadata for a single iCloud webservice entry.
data Webservice = Webservice
  { wsUrl :: !Text
  -- ^ the base URL for the service
  , wsStatus :: !(Maybe Text)
  -- ^ service status, e.g. @"active"@ or @"inactive"@; @Nothing@ if absent
  }
  deriving (Eq, Show)


data AccountData = AccountData
  { adHsaVersion :: !Int
  -- ^ HSA protocol version; drives the two-factor flow selection
  , adHsaChallengeRequired :: !Bool
  -- ^ @True@ when a 2FA challenge must be completed before access is granted
  , adHsaTrustedBrowser :: !(Maybe Bool)
  {- ^ @Just True@ when this session is already trusted; @Just False@ when explicitly
  untrusted; @Nothing@ when the key was absent from Apple's response (treated as trusted)
  -}
  , adWebservices :: !(Map Text Webservice)
  -- ^ map of webservice name to service info; use 'lookupWebservice' to resolve a URL
  , adRaw :: !Value
  -- ^ the original JSON value from Apple; preserved so serialisation round-trips losslessly
  }
  deriving (Eq, Show)


instance FromJSON AccountData where
  parseJSON v = withObject "AccountData" go v
   where
    go o = do
      dsInfo <- o .: "dsInfo"
      adHsaVersion <- withObject "dsInfo" (.: "hsaVersion") dsInfo
      adHsaChallengeRequired <- o .:? "hsaChallengeRequired" >>= maybe (pure False) pure
      adHsaTrustedBrowser <- o .:? "hsaTrustedBrowser"
      adWebservices <- do
        mbWs <- o .:? "webservices"
        maybe (pure Map.empty) (withObject "webservices" parseWebservices) mbWs
      pure AccountData{adHsaVersion, adHsaChallengeRequired, adHsaTrustedBrowser, adWebservices, adRaw = v}
    parseWebservices obj = do
      let pairs = KeyMap.toAscList obj
      wsPairs <- forM pairs $ \(k, wsVal) ->
        withObject
          "webservice"
          ( \sv -> do
              mbUrl <- sv .:? "url"
              mbStatus <- sv .:? "status"
              pure $ fmap (\u -> (AesonKey.toText k, Webservice u mbStatus)) mbUrl
          )
          wsVal
      pure $ Map.fromList $ catMaybes wsPairs


instance ToJSON AccountData where
  toJSON AccountData{adRaw} = adRaw
  toEncoding AccountData{adRaw} = toEncoding adRaw


-- | True when full 2FA (auth-endpoint) challenge is required
accountDataRequires2FA :: AccountData -> Bool
accountDataRequires2FA ad =
  adHsaVersion ad == 2
    && (adHsaChallengeRequired ad || adHsaTrustedBrowser ad == Just False)


-- | True when legacy 2SA (setup-endpoint) challenge is required
accountDataRequires2SA :: AccountData -> Bool
accountDataRequires2SA ad = adHsaVersion ad == 1


-- | Sentinel used when no saved @AccountData@ is available
unknownAccountData :: AccountData
unknownAccountData =
  AccountData
    { adHsaVersion = 0
    , adHsaChallengeRequired = False
    , adHsaTrustedBrowser = Nothing
    , adWebservices = Map.empty
    , adRaw = object []
    }


accountDataBase :: Credentials -> Text
accountDataBase = (<> ".account-data.json") . sprucedName


-- | Determine the path of the saved account-data file for the given credentials
accountDataPath :: FilePath -> Credentials -> FilePath
accountDataPath topDir creds = topDir </> Text.unpack (accountDataBase creds)


-- | Persist @AccountData@ to the session's filesystem location
saveAccountData :: Session -> AccountData -> IO ()
saveAccountData Session{sessionCreds = creds, sessionTopDir = topDir} =
  secureEncodeFileAtomic (accountDataPath topDir creds)


-- | Load persisted @AccountData@; returns @Nothing@ if the file is absent
loadAccountData :: Session -> IO (Maybe AccountData)
loadAccountData Session{sessionCreds = creds, sessionTopDir = topDir} = do
  let path = accountDataPath topDir creds
  requireSecureFile path
  exists <- doesFileExist path
  if not exists
    then pure Nothing
    else eitherDecodeFileStrict path >>= either (const (pure Nothing)) (pure . Just)


{- | Determine the path of file containing the credentials in the configuration
   directory
-}
credentialsPath :: FilePath -> FilePath
credentialsPath topDir = topDir </> "credentials.json"


saveCredentials :: Credentials -> IO ()
saveCredentials creds = getUserConfigDir appBase >>= (`saveCredentialsTo` creds)


-- | Write 'Credentials' to @credentials.json@ inside @topDir@, creating @topDir@ if absent.
saveCredentialsTo :: FilePath -> Credentials -> IO ()
saveCredentialsTo topDir creds = do
  createDirectoryIfMissing True topDir
  secureEncodeFileAtomic (credentialsPath topDir) creds


data Credentials = Credentials
  { credAccountName :: !Text
  -- ^ the account ID; typically an email address
  , credPassword :: !Text
  -- ^ the iCloud account password
  }
  deriving
    ( Eq
      -- ^ don't derive Show to avoid the risk of logging a password
    )


instance FromJSON Credentials where
  parseJSON = withObject "Credentials" $ \o ->
    let accountName = o .: "accountName"
        password = o .: "password"
     in Credentials <$> accountName <*> password


instance ToJSON Credentials where
  toJSON c =
    object
      [ "password" .= credPassword c
      , "accountName" .= credAccountName c
      ]


sprucedName :: Credentials -> Text
sprucedName =
  let p aChar = isAlphaNum aChar || aChar == '@'
      replaceAt = Text.replace "@" "-"
   in replaceAt . Text.filter p . credAccountName


cookieBase :: Credentials -> Text
cookieBase = (<> ".cookies.txt") . sprucedName


sessionBase :: Credentials -> Text
sessionBase = (<> ".session.json") . sprucedName


clientIdBase :: Credentials -> Text
clientIdBase = (<> ".client-id.txt") . sprucedName


loginMsgBase :: Credentials -> Text
loginMsgBase = (<> ".last-logon.json") . sprucedName


-- | Data obtained from HTTP response headers that define a user session
data SavedHeaders = SavedHeaders
  { shCountry :: !(Maybe Text)
  -- ^ X-Apple-ID-Country value from the last response
  , shSessionId :: !(Maybe Text)
  -- ^ X-Apple-ID-Session-Id value from the last response
  , shSessionToken :: !(Maybe Text)
  -- ^ X-Apple-Session-Token value from the last response
  , shTrustToken :: !(Maybe Text)
  -- ^ X-Apple-TwoSV-Trust-Token value from the last response
  , shCounter :: !(Maybe Text)
  -- ^ X-Apple-HC-Bits value from the last response; used to derive hashcash proofs
  }
  deriving (Eq, Show, Generic)


instance FromJSON SavedHeaders where
  parseJSON = genericParseJSON simpleOptions


instance ToJSON SavedHeaders where
  toJSON = genericToJSON simpleOptions
  toEncoding = genericToEncoding simpleOptions


-- | A @SavedHeaders@ with nothing set
pristine :: SavedHeaders
pristine = SavedHeaders Nothing Nothing Nothing Nothing Nothing


{- | Update the stored saved headers

if the sessionData file exists
then
  load it.
  update the session data from the headers
  save the updated data
else
  ensure its parent directory exists
  create the session data from the headers
  save it

not handled (thrown as IOException):
  cannot create directory
  cannot write due to permissions
  file exists, but data cannot be parsed
-}
updateSessionSavedHeaders
  :: Session
  -> (SavedHeaders -> SavedHeaders)
  -- ^ a function that modifies the session's saved headers
  -> IO ()
updateSessionSavedHeaders s modSavedHeaders = do
  let dataPath = savedHeadersPath (sessionTopDir s) (sessionCreds s)
      updateAndSave = secureEncodeFileAtomic dataPath . modSavedHeaders
      loadLast False = pure pristine
      loadLast True = eitherDecodeFileStrict dataPath >>= either (fail . show) pure

  doesFileExist dataPath >>= loadLast >>= updateAndSave


loadSession :: IO Session
loadSession = do
  sessionTopDir <- getUserConfigDir appBase
  createDirectoryIfMissing True sessionTopDir
  s <-
    loadSessionOr sessionTopDir
      >>= orFail "Credentials are missing or corrupt; run 'hstratus auth login' to authenticate"
  checkSessionFiles s
  pure s


-- | Saves a JSON @Value@ to @filepath@
saveValue :: FilePath -> Value -> IO ()
saveValue fp v = LBS.writeFile fp $ encode v


orFail :: String -> Either String a -> IO a
orFail hint = either (\e -> fail (hint <> " (" <> e <> ")")) pure


-- | Write a JSON-encodable value to @path@ atomically via a temp file and rename.
encodeFileAtomic :: (ToJSON a) => FilePath -> a -> IO ()
encodeFileAtomic path value =
  bracketOnError
    (openTempFile (takeDirectory path) ".tmp")
    (\(tmpPath, h) -> hClose h >> removeFile tmpPath)
    ( \(tmpPath, h) -> do
        LBS.hPut h (encode value)
        hClose h
        renameFile tmpPath path
    )


{- | Like 'encodeFileAtomic' but sets mode @0o600@ on the temp file before
renaming, so the file is never visible at a more permissive mode.
-}
secureEncodeFileAtomic :: (ToJSON a) => FilePath -> a -> IO ()
secureEncodeFileAtomic path value =
  bracketOnError
    (openTempFile (takeDirectory path) ".tmp")
    (\(tmpPath, h) -> hClose h >> removeFile tmpPath)
    ( \(tmpPath, h) -> do
        LBS.hPut h (encode value)
        hClose h
        setFileMode tmpPath 0o600
        renameFile tmpPath path
    )


-- | Write text to @path@ atomically, setting mode @0o600@ before renaming.
secureWriteTextFileAtomic :: FilePath -> Text -> IO ()
secureWriteTextFileAtomic path content =
  bracketOnError
    (openTempFile (takeDirectory path) ".tmp")
    (\(tmpPath, h) -> hClose h >> removeFile tmpPath)
    ( \(tmpPath, h) -> do
        Text.hPutStr h content
        hClose h
        setFileMode tmpPath 0o600
        renameFile tmpPath path
    )


{- | Pure permission check using the SSH convention: no group or world bits may
be set.  Returns @Left@ with a descriptive message (including a @chmod 600@ hint)
when the mode is too permissive.
-}
checkSecureMode :: FileMode -> FilePath -> Either String ()
checkSecureMode mode path
  | mode .&. 0o077 /= 0 =
      Left $
        path
          <> " has unsafe permissions ("
          <> showOct (fromIntegral mode :: Int) ""
          <> "); fix with: chmod 600 "
          <> path
  | otherwise = Right ()


{- | Verify that a file has secure permissions before it is read.  Does nothing
if the file does not exist; absence is handled by the caller.  Throws an
'IOError' when the file exists but its mode has group or world bits set.
-}
requireSecureFile :: FilePath -> IO ()
requireSecureFile path = do
  exists <- doesFileExist path
  when exists $ do
    mode <- fileMode <$> getFileStatus path
    either (throwIO . userError) pure (checkSecureMode mode path)


{- | Check that every session file that exists has secure permissions.  Absent
files are skipped silently.  Throws an 'IOError' for the first file found with
group or world bits set.

Covers all five session paths: credentials, saved headers, client ID, account
data, and the cookie jar.
-}
checkSessionFiles :: Session -> IO ()
checkSessionFiles sess = do
  let topDir = sessionTopDir sess
      creds = sessionCreds sess
  mapM_
    requireSecureFile
    [ credentialsPath topDir
    , savedHeadersPath topDir creds
    , clientIdPath topDir creds
    , accountDataPath topDir creds
    , cookiePath topDir creds
    ]


loadCredentials :: FilePath -> IO (Either String Credentials)
loadCredentials topDir = do
  let path = credentialsPath topDir
  requireSecureFile path
  eitherDecodeFileStrict path


loadCredentials' :: FilePath -> IO (Either String (FilePath, Credentials))
loadCredentials' topDir = fmap (topDir,) <$> loadCredentials topDir


loadSession' :: Either String (FilePath, Credentials) -> IO (Either String Session)
loadSession' (Left err) = pure $ Left err
loadSession' (Right (sessionTopDir, sessionCreds)) = do
  sessionClientId <- loadClientId sessionTopDir sessionCreds
  pure $ Right Session{sessionClientId, sessionCreds, sessionTopDir}


loadSessionOr :: FilePath -> IO (Either String Session)
loadSessionOr = loadCredentials' >=> loadSession'


-- | Load the @SavedHeaders@ for this session
loadSavedHeaders :: Session -> IO SavedHeaders
loadSavedHeaders Session{sessionTopDir, sessionCreds} =
  loadSavedHeaders' sessionTopDir sessionCreds
    >>= orFail "Session state is corrupt; run 'hstratus auth login' to re-authenticate"


loadSavedHeaders' :: FilePath -> Credentials -> IO (Either String SavedHeaders)
loadSavedHeaders' topDir creds = do
  let dataPath = savedHeadersPath topDir creds
  requireSecureFile dataPath
  pathExists <- doesFileExist dataPath
  if not pathExists
    then pure $ Right pristine
    else eitherDecodeFileStrict dataPath


loadClientId :: FilePath -> Credentials -> IO Text
loadClientId topDir creds = do
  let dataPath = clientIdPath topDir creds
  requireSecureFile dataPath
  pathExists <- doesFileExist dataPath
  if pathExists
    then Text.readFile dataPath
    else do
      anId <- newClientId
      secureWriteTextFileAtomic dataPath anId
      pure anId


simpleOptions :: Options
simpleOptions = aesonPrefix snakeCase


appBase :: FilePath
appBase = "hstratus"