shomei-postgres-0.2.0.0: src/Shomei/Mfa/RecoveryCode/Postgres.hs
-- | PostgreSQL interpreter for the EP-7 recovery-code store.
--
-- Codes are stored only as SHA-256 hex hashes. 'ConsumeRecoveryCode' is the compare-and-set
-- @UPDATE … WHERE used_at IS NULL … RETURNING@ that makes a double-spend impossible even under a
-- race; 'ReplaceRecoveryCodes' deletes the user's set and inserts the new one in one 'Session'
-- so they land together.
module Shomei.Mfa.RecoveryCode.Postgres
( runRecoveryCodeStorePostgres,
)
where
import Contravariant.Extras (contrazip3, contrazip4)
import Data.Int (Int64)
import Data.Maybe (isJust)
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 Shomei.Error (AuthError (..))
import Shomei.Id (recoveryCodeIdToUUID, userIdToUUID)
import Shomei.Mfa.RecoveryCode.Store (RecoveryCodeStore (..))
import Shomei.Mfa.Totp.Domain (NewRecoveryCode (..))
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession)
import Shomei.Prelude
runRecoveryCodeStorePostgres ::
(Database :> es, IOE :> es, Error AuthError :> es) =>
Eff (RecoveryCodeStore : es) a ->
Eff es a
runRecoveryCodeStorePostgres = interpret_ \case
ReplaceRecoveryCodes uid newCodes -> do
let uidU = userIdToUUID uid
rows =
[ (recoveryCodeIdToUUID nc.recoveryCodeId, uidU, nc.codeHash, nc.createdAt)
| nc <- newCodes
]
res <- runSession do
Session.statement uidU deleteForUserStmt
mapM_ (`Session.statement` insertStmt) rows
either dbFail (const (pure ())) res
ConsumeRecoveryCode uid h t -> do
res <- runSession (Session.statement (userIdToUUID uid, h, t) consumeStmt)
either dbFail (pure . isJust) res
CountUnusedRecoveryCodes uid -> do
res <- runSession (Session.statement (userIdToUUID uid) countUnusedStmt)
n <- either dbFail pure res
pure (fromIntegral n)
where
dbFail = throwError . postgresUnavailable
-- | The four columns an INSERT writes; @used_at@ is always NULL on a fresh row.
type InsertRow = (UUID, UUID, Text, UTCTime)
deleteForUserStmt :: Statement UUID ()
deleteForUserStmt =
preparable
"DELETE FROM shomei.shomei_recovery_codes WHERE user_id = $1"
(E.param (E.nonNullable E.uuid))
D.noResult
insertStmt :: Statement InsertRow ()
insertStmt =
preparable
"""
INSERT INTO shomei.shomei_recovery_codes
(recovery_code_id, user_id, code_hash, created_at, used_at)
VALUES ($1, $2, $3, $4, NULL)
"""
( contrazip4
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.timestamptz))
)
D.noResult
-- | Spend one unused code matching @(user_id, code_hash)@. The @RETURNING recovery_code_id@ sits
-- on its own line: a 'MultilineString' drops its trailing newline, so keeping @RETURNING@ apart
-- from the column avoids concatenating into @RETURNINGrecovery_code_id@ (EP-5 discovery).
consumeStmt :: Statement (UUID, Text, UTCTime) (Maybe UUID)
consumeStmt =
preparable
"""
UPDATE shomei.shomei_recovery_codes
SET used_at = $3
WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL
RETURNING recovery_code_id
"""
( contrazip3
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.timestamptz))
)
(D.rowMaybe (D.column (D.nonNullable D.uuid)))
countUnusedStmt :: Statement UUID Int64
countUnusedStmt =
preparable
"SELECT count(*) FROM shomei.shomei_recovery_codes WHERE user_id = $1 AND used_at IS NULL"
(E.param (E.nonNullable E.uuid))
(D.singleRow (D.column (D.nonNullable D.int8)))