hstratus-auth-0.1.0.0: src/Network/HStratus/Internal/Http/Login.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_HADDOCK prune #-}
module Network.HStratus.Internal.Http.Login
( -- * Login state
AuthState (..)
-- * Login
, login
, loginWith
-- * Completing two-factor challenges
, completeTwoFactor
, completeTwoFactorWith
-- * Completing two-step challenges
, complete2SA
, complete2SAWith
)
where
import Control.Exception (IOException, catch, throwIO)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Trans.Reader (ReaderT, runReaderT)
import qualified Control.Monad.Trans.Reader as Reader
import Crypto.SRP (calcResults, mkFromClient)
import Data.Aeson (FromJSON (..))
import Data.Aeson.Types (Value (..), parseEither)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.Text as Text
import Data.Word (Word8)
import Network.HStratus.Internal.Http (SrpContext (..))
import Network.HStratus.Internal.Http.Api
( Api (..)
, AuthCode
)
import Network.HStratus.Internal.Http.Signin
( accountLogin
, doTrustStep
, fetchTrustData
, listSetupDevices
, requestSmsCode
, runSigninComplete
, runSigninInit
, sendSetupVerification
, triggerTwoFaPush
, validate
, validateSetupVerification
, verifySmsCode
, verifyTwoFaCode
)
import Network.HStratus.Internal.HttpErrors (AuthError (..))
import Network.HStratus.Internal.LoginFSM
( AfterAcctLogin (..)
, AfterArtifactDir (..)
, AfterCredentials (..)
, AfterLoadLastSession (..)
, AfterMkArtifactDir (..)
, AfterSrpComplete (..)
, AfterTwoFaVerify (..)
, AfterTwoSaVerify (..)
, AfterValidateSession (..)
, CompletionOutcome (..)
, LoginEvent (..)
, LoginFSM (..)
, LoginOutcome (..)
, TwoFaConfig (..)
, TwoSaConfig (..)
, loginProcess
, twoFaProcess
, twoSaProcess
)
import Network.HStratus.Internal.Session
( accountDataRequires2FA
, accountDataRequires2SA
, loadAccountData
, loadSavedHeaders
, pristine
, saveAccountData
, saveLoginMsg
, unknownAccountData
)
import Network.HStratus.Internal.Trust
( CodeStatus (..)
, TrustData (..)
, TrustedPhone
, pleaseReadCode
, selectSetupDevice
, selectTwoFaPhone
)
import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..))
import Network.HStratus.Trust (Setup2SADevice (..))
import System.Directory (createDirectoryIfMissing, doesDirectoryExist)
newtype LoginM a = LoginM {runLoginM :: ReaderT Api IO a}
deriving (Functor, Applicative, Monad, MonadIO)
ask :: LoginM Api
ask = LoginM Reader.ask
asks :: (Api -> b) -> LoginM b
asks = LoginM . Reader.asks
{- | The result of a login attempt.
'login' and 'loginWith' return only 'Authenticated'; 2FA and 2SA challenges
are resolved internally. 'Requires2FA' and 'Requires2SA' are only produced by
'completeTwoFactor', 'completeTwoFactorWith', 'complete2SA', and 'complete2SAWith'.
-}
data AuthState
= -- | Sign-in succeeded; the 'Session' is refreshed and 'AccountData' is available.
Authenticated Session AccountData
| -- | Sign-in requires a two-factor code; use 'completeTwoFactor' or 'completeTwoFactorWith' to proceed.
Requires2FA Session
| -- | Sign-in requires a legacy two-step code; use 'complete2SA' or 'complete2SAWith' to proceed.
Requires2SA Session (NonEmpty Setup2SADevice)
instance Show AuthState where
show (Authenticated _ ad) = "Authenticated <session> " ++ show ad
show (Requires2FA _) = "Requires2FA <session>"
show (Requires2SA _ ds) = "Requires2SA <session> " ++ show ds
-- | Logs into ICloud, completing any 2FA or 2SA challenge automatically
login :: Api -> IO AuthState
login = loginWith pleaseReadCode selectTwoFaPhone selectSetupDevice
-- | Like 'login' with injectable code prompt, phone selector, and device selector, for testing
loginWith
:: (Word8 -> IO AuthCode)
-> (TrustData -> IO (Maybe TrustedPhone))
-> (NonEmpty Setup2SADevice -> IO Setup2SADevice)
-> Api
-> IO AuthState
loginWith readCode pickPhone pickDevice api =
runReaderT (runLoginM loginProcess) api >>= \case
LoginAuthenticated (AuthComplete _ ad) -> pure $ Authenticated (apiSession api) ad
LoginNeedsTwoFa (NeedsTwoFa _) -> completeTwoFactorWith readCode pickPhone api
LoginNeedsTwoSa (TwoSaReady _ ds) -> complete2SAWith pickDevice (readCode 6) api ds
LoginHaltCreds _ -> throwIO CredentialsMissing
LoginHaltDir _ -> throwIO $ ArtifactDirCreationFailed (sessionTopDir (apiSession api))
LoginHaltSrp _ -> throwIO SrpProtocolError
LoginHaltTwoFaLocked _ -> throwIO TwoFactorLocked
instance LoginEvent LoginM where
type State LoginM = LoginFSM
initial = pure RatifyCredentials
ratifyCreds RatifyCredentials =
asks (GotCreds . RatifyArtifactDir . sessionCreds . apiSession)
ratifyArtifactDir (RatifyArtifactDir creds) = do
dir <- sessionTopDir . apiSession <$> ask
exists <- liftIO $ doesDirectoryExist dir
pure $
if exists
then DirPresent $ LoadLastSession creds
else DirAbsent $ MkArtifactDir creds
mkArtifactDir (MkArtifactDir creds) = do
dir <- sessionTopDir . apiSession <$> ask
ok <- liftIO $ (createDirectoryIfMissing True dir >> pure True) `catch` (\(_ :: IOException) -> pure False)
pure $
if ok
then DirMade $ LoadLastSession creds
else NotMade $ HaltCannotMkArtifactDir creds
loadSession (LoadLastSession creds) = do
savedHdrs <- ask >>= liftIO . loadSavedHeaders . apiSession
pure $
if savedHdrs == pristine
then HasClientId $ ReadyToAuth creds savedHdrs
else HasPriorSession $ HasSavedSession creds savedHdrs
validateSession (HasSavedSession creds savedHdrs) = do
valid <- ask >>= liftIO . validate
if not valid
then pure $ SessionStale $ ReadyToAuth creds savedHdrs
else do
mbAd <- ask >>= liftIO . loadAccountData . apiSession
pure $ case mbAd of
Just ad | accountDataRequires2FA ad -> SessionStale $ ReadyToAuth creds savedHdrs
Just ad | accountDataRequires2SA ad -> SessionStale $ ReadyToAuth creds savedHdrs
Just ad -> SessionStillValid $ AuthComplete creds ad
Nothing -> SessionStillValid $ AuthComplete creds unknownAccountData
srpInit (ReadyToAuth creds _) = do
api <- ask
let user = credAccountName creds
pass = credPassword creds
fc <- liftIO $ mkFromClient user pass (apiGroup api)
(fs, kd) <- liftIO $ runSigninInit api fc
pure $ SrpInitDone creds (SrpContext fc fs kd)
srpComplete (SrpInitDone creds ctx) = do
api <- ask
let SrpContext{srpFromClient = fc, srpFromServer = fs, srpKeyDeriver = kd} = ctx
case calcResults kd fc fs of
Nothing -> pure $ SrpCompleteInvalidKey $ HaltInvalidSrp creds
Just results -> do
liftIO $ runSigninComplete api kd results
pure $ SrpCompleteOk $ DoAccountLogin creds
acctLogin (DoAccountLogin creds) = do
api <- ask
loginReply <- liftIO $ accountLogin api
ad <- liftIO $ parseAccountData loginReply
liftIO $ saveLoginMsg (apiSession api) loginReply
liftIO $ saveAccountData (apiSession api) ad
pure $
if
| accountDataRequires2SA ad -> AcctLogin2SA $ NeedsTwoSa creds
| accountDataRequires2FA ad -> AcctLogin2FA $ NeedsTwoFa creds
| otherwise -> AcctLoginOk $ AuthComplete creds ad
listTwoSaDevices (NeedsTwoSa creds) = do
ask >>= fmap (TwoSaReady creds) . liftIO . listSetupDevices
beginTwoFa (ReadyForTwoFa creds td) TwoFaConfig{tfcPickPhone} = do
mbPhone <- liftIO $ tfcPickPhone td
case mbPhone of
Nothing -> ask >>= liftIO . triggerTwoFaPush
Just phone -> ask >>= liftIO . flip requestSmsCode phone
pure $ TwoFaVerifying creds td mbPhone
verifyTwoFa (TwoFaVerifying creds td mbPhone) TwoFaConfig{tfcReadCode} = do
api <- ask
code <- liftIO $ tfcReadCode (scLength (tdSecurityCode td))
ok <- liftIO $ case mbPhone of
Nothing -> verifyTwoFaCode api code
Just phone -> verifySmsCode api phone code
if ok
then pure $ TwoFaOk $ DoTrust creds
else do
freshTd <- liftIO $ fetchTrustData api
let cs = tdSecurityCode freshTd
pure $
if scTooManyCodesValidated cs || scSecurityCodeLocked cs || scSecurityCodeCooldown cs
then TwoFaLocked $ HaltTwoFaLocked creds
else TwoFaRetry $ ReadyForTwoFa creds freshTd
doTrust (DoTrust creds) = do
ask >>= liftIO . doTrustStep
pure $ DoAccountLogin creds
beginTwoSa (ReadyForTwoSa creds devices) TwoSaConfig{tscPickDevice} = do
api <- ask
device <- liftIO $ tscPickDevice devices
liftIO $ sendSetupVerification api device
pure $ TwoSaVerifying creds device devices
verifyTwoSa (TwoSaVerifying creds device devices) TwoSaConfig{tscReadCode} = do
api <- ask
code <- liftIO tscReadCode
ok <- liftIO $ validateSetupVerification api device code
pure $
if ok
then TwoSaOk $ DoAccountLogin creds
else TwoSaRetry $ ReadyForTwoSa creds devices
parseAccountData :: Value -> IO AccountData
parseAccountData v =
either (throwIO . UnexpectedResponse . Text.pack) pure $
parseEither parseJSON v
-- | Complete a pending 2FA (auth-endpoint) challenge
completeTwoFactor :: Api -> IO AuthState
completeTwoFactor = completeTwoFactorWith pleaseReadCode (\_ -> pure Nothing)
-- | Like 'completeTwoFactor' with an injectable code prompt and phone selector, for testing
completeTwoFactorWith :: (Word8 -> IO AuthCode) -> (TrustData -> IO (Maybe TrustedPhone)) -> Api -> IO AuthState
completeTwoFactorWith readCode pickPhone api = do
td <- fetchTrustData api
let start = ReadyForTwoFa (sessionCreds (apiSession api)) td
cfg = TwoFaConfig{tfcPickPhone = pickPhone, tfcReadCode = readCode}
runReaderT (runLoginM (twoFaProcess start cfg)) api >>= \case
CompletionAuthenticated (AuthComplete _ ad) -> pure $ Authenticated (apiSession api) ad
CompletionNeedsTwoFa _ -> throwIO TwoFactorStillRequired
CompletionNeedsTwoSa (TwoSaReady _ ds) -> pure $ Requires2SA (apiSession api) ds
CompletionTwoFaLocked _ -> throwIO TwoFactorLocked
-- | Used when already holding a 'Requires2SA' result from 'completeTwoFactor' or 'completeTwoFactorWith'
complete2SA :: Api -> NonEmpty Setup2SADevice -> IO AuthState
complete2SA = complete2SAWith selectSetupDevice (pleaseReadCode 6)
-- | Like 'complete2SA' with injectable device selector and code prompt, for testing
complete2SAWith
:: (NonEmpty Setup2SADevice -> IO Setup2SADevice)
-> IO AuthCode
-> Api
-> NonEmpty Setup2SADevice
-> IO AuthState
complete2SAWith pickDevice readCode api devices = do
let start = ReadyForTwoSa (sessionCreds (apiSession api)) devices
cfg = TwoSaConfig{tscPickDevice = pickDevice, tscReadCode = readCode}
runReaderT (runLoginM (twoSaProcess start cfg)) api >>= \case
CompletionAuthenticated (AuthComplete _ ad) -> pure $ Authenticated (apiSession api) ad
CompletionNeedsTwoFa _ -> throwIO TwoFactorStillRequired
CompletionNeedsTwoSa (TwoSaReady _ ds) -> pure $ Requires2SA (apiSession api) ds
CompletionTwoFaLocked _ -> throwIO TwoFactorLocked