packages feed

shomei-postgres-0.2.0.0: src/Shomei/Session/LoginAttempt/Postgres.hs

-- | PostgreSQL interpreter for the 'LoginAttemptStore' port (EP-2 brute-force protection).
--
-- Attempts are appended to @shomei_login_attempts@ (an append-only forensic log); the
-- per-account lockout state lives in @shomei_account_lockouts@. Windowed failure counting is
-- asymmetric: the per-account count only counts failures since the most recent success (so a
-- successful login resets the account's brute-force progress), while the per-IP count is a
-- plain windowed count (so an attacker cannot reset the IP throttle by logging into their own
-- account). Both are still bounded by the caller-supplied window cutoff.
module Shomei.Session.LoginAttempt.Postgres
  ( runLoginAttemptStorePostgres,
  )
where

import Contravariant.Extras (contrazip2, contrazip4, contrazip6)
import Data.Int (Int32, Int64)
import Data.UUID (UUID)
import Effectful (Eff, IOE, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Effectful.Error.Static (Error, throwError)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Hasql.Transaction qualified as Tx
import Shomei.Error (AuthError (..))
import Shomei.Id (genLoginAttemptId, loginAttemptIdToUUID)
import Shomei.Persistence.Codec.Postgres (attemptFactorToText, loginOutcomeToText)
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession, runTransaction)
import Shomei.Prelude
import Shomei.Session.LoginAttempt.Domain
  ( AccountKey (..),
    AccountLockout (..),
    ClientIp (..),
    FailureOutcome (..),
    LockPolicy (..),
    NewLoginAttempt (..),
  )
import Shomei.Session.LoginAttempt.Store (LoginAttemptStore (..))

runLoginAttemptStorePostgres ::
  (Database :> es, IOE :> es, Error AuthError :> es) =>
  Eff (LoginAttemptStore : es) a ->
  Eff es a
runLoginAttemptStorePostgres = interpret_ \case
  RecordLoginFailure na windowStart policy -> do
    aid <- genLoginAttemptId
    let AccountKey k = na.accountKey
        ClientIp ip = na.clientIp
        row = (loginAttemptIdToUUID aid, k, ip, loginOutcomeToText na.outcome, na.occurredAt, attemptFactorToText na.factor)
    res <- runTransaction do
      _ <- Tx.statement k lockAccountKeyStmt
      Tx.statement row insertAttemptStmt
      n64 <- Tx.statement (k, windowStart) countByAccountStmt
      prior <- Tx.statement k findLockoutStmt
      let failures = fromIntegral n64
          stillLocked = maybe False (maybe False (> na.occurredAt) . secondOf3) prior
      lockedNow <- case policy of
        Just p
          | failures >= p.maxFailures && not stillLocked -> do
              Tx.statement (k, fromIntegral failures, Just p.lockUntil, na.occurredAt) upsertLockoutStmt
              pure True
        _ -> pure False
      pure
        FailureOutcome
          { attemptId = aid,
            failures,
            priorLockout = rebuildLockout na.accountKey <$> prior,
            lockedNow
          }
    either dbFail pure res
  ConvertLoginAttemptToSuccess aid -> do
    res <- runSession (Session.statement (loginAttemptIdToUUID aid) convertAttemptStmt)
    either dbFail (const (pure ())) res
  DiscardLoginAttempt aid -> do
    res <- runSession (Session.statement (loginAttemptIdToUUID aid) discardAttemptStmt)
    either dbFail (const (pure ())) res
  CountRecentFailuresByAccount (AccountKey k) cutoff -> do
    res <- runSession (Session.statement (k, cutoff) countByAccountStmt)
    either dbFail (pure . fromIntegral) res
  CountRecentFailuresByIp (ClientIp ip) cutoff -> do
    res <- runSession (Session.statement (ip, cutoff) countByIpStmt)
    either dbFail (pure . fromIntegral) res
  GetAccountLockout k@(AccountKey kt) -> do
    res <- runSession (Session.statement kt findLockoutStmt)
    row <- either dbFail pure res
    pure (fmap (rebuildLockout k) row)
  SetAccountLockout lo -> do
    let AccountKey k = lo.accountKey
        row = (k, fromIntegral lo.failedCount :: Int32, lo.lockedUntil, lo.updatedAt)
    res <- runSession (Session.statement row upsertLockoutStmt)
    either dbFail (const (pure ())) res
  ClearAccountLockout (AccountKey k) -> do
    res <- runSession (Session.statement k deleteLockoutStmt)
    either dbFail (const (pure ())) res
  where
    dbFail = throwError . postgresUnavailable
    secondOf3 (_, value, _) = value

rebuildLockout :: AccountKey -> (Int32, Maybe UTCTime, UTCTime) -> AccountLockout
rebuildLockout k (fc, lu, ua) =
  AccountLockout
    { accountKey = k,
      failedCount = fromIntegral fc,
      lockedUntil = lu,
      updatedAt = ua
    }

type AttemptRow = (UUID, Text, Text, Text, UTCTime, Text)

insertAttemptStmt :: Statement AttemptRow ()
insertAttemptStmt =
  preparable
    """
    INSERT INTO shomei.shomei_login_attempts
      (attempt_id, account_key, client_ip, outcome, occurred_at, factor)
    VALUES ($1, $2, $3, $4, $5, $6)
    """
    ( contrazip6
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
        (E.param (E.nonNullable E.text))
    )
    D.noResult

-- | Serialize failure accounting for one opaque account key until the surrounding transaction
-- commits or rolls back. The query deliberately returns one row so Hasql proves the lock was
-- acquired before the insert and count run.
lockAccountKeyStmt :: Statement Text Int32
lockAccountKeyStmt =
  preparable
    "SELECT 1::int4 FROM pg_advisory_xact_lock(hashtextextended($1, 0))"
    (E.param (E.nonNullable E.text))
    (D.singleRow (D.column (D.nonNullable D.int4)))

convertAttemptStmt :: Statement UUID ()
convertAttemptStmt =
  preparable
    "UPDATE shomei.shomei_login_attempts SET outcome = 'success' WHERE attempt_id = $1"
    (E.param (E.nonNullable E.uuid))
    D.noResult

discardAttemptStmt :: Statement UUID ()
discardAttemptStmt =
  preparable
    "DELETE FROM shomei.shomei_login_attempts WHERE attempt_id = $1 AND outcome = 'failure'"
    (E.param (E.nonNullable E.uuid))
    D.noResult

-- Per-account failures in the window AND strictly after the most recent success.
countByAccountStmt :: Statement (Text, UTCTime) Int64
countByAccountStmt =
  preparable
    """
    SELECT count(*) FROM shomei.shomei_login_attempts
    WHERE account_key = $1 AND outcome = 'failure' AND occurred_at >= $2
      AND occurred_at > COALESCE(
            (SELECT max(occurred_at) FROM shomei.shomei_login_attempts
             WHERE account_key = $1 AND outcome = 'success'),
            '-infinity'::timestamptz)
    """
    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))
    (D.singleRow (D.column (D.nonNullable D.int8)))

-- Per-IP failures in the window (plain windowed count; no success reset).
countByIpStmt :: Statement (Text, UTCTime) Int64
countByIpStmt =
  preparable
    """
    SELECT count(*) FROM shomei.shomei_login_attempts
    WHERE client_ip = $1 AND outcome = 'failure' AND occurred_at >= $2
    """
    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))
    (D.singleRow (D.column (D.nonNullable D.int8)))

findLockoutStmt :: Statement Text (Maybe (Int32, Maybe UTCTime, UTCTime))
findLockoutStmt =
  preparable
    """
    SELECT failed_count, locked_until, updated_at
    FROM shomei.shomei_account_lockouts
    WHERE account_key = $1
    """
    (E.param (E.nonNullable E.text))
    (D.rowMaybe lockoutRowDecoder)

lockoutRowDecoder :: D.Row (Int32, Maybe UTCTime, UTCTime)
lockoutRowDecoder =
  (,,)
    <$> D.column (D.nonNullable D.int4)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nonNullable D.timestamptz)

type LockoutRow = (Text, Int32, Maybe UTCTime, UTCTime)

upsertLockoutStmt :: Statement LockoutRow ()
upsertLockoutStmt =
  preparable
    """
    INSERT INTO shomei.shomei_account_lockouts
      (account_key, failed_count, locked_until, updated_at)
    VALUES ($1, $2, $3, $4)
    ON CONFLICT (account_key) DO UPDATE
      SET failed_count = EXCLUDED.failed_count,
          locked_until = EXCLUDED.locked_until,
          updated_at = EXCLUDED.updated_at
    """
    ( contrazip4
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int4))
        (E.param (E.nullable E.timestamptz))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.noResult

deleteLockoutStmt :: Statement Text ()
deleteLockoutStmt =
  preparable
    """
    DELETE FROM shomei.shomei_account_lockouts WHERE account_key = $1
    """
    (E.param (E.nonNullable E.text))
    D.noResult