shomei-postgres-0.2.0.0: src/Shomei/Account/PasswordReset/Postgres.hs
-- | PostgreSQL interpreter for the password-reset token store.
module Shomei.Account.PasswordReset.Postgres
( runPasswordResetTokenStorePostgres,
-- * Statements shared with the unit-of-work interpreter
-- | Exported so @Shomei.Session.UnitOfWork.Postgres@ can compose the store-owned CAS and
-- revocation statements inside a transaction without duplicating their SQL.
markConsumedStmt,
revokeUserTokensStmt,
)
where
import Contravariant.Extras (contrazip2, contrazip8)
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.Account.OneTimeToken.Domain (OneTimeTokenHash (..), OneTimeTokenStatus (..))
import Shomei.Account.PasswordReset.Domain (NewPasswordResetToken (..), PersistedPasswordResetToken (..))
import Shomei.Account.PasswordReset.Store (PasswordResetTokenStore (..))
import Shomei.Error (AuthError (..))
import Shomei.Id
( PasswordResetTokenId,
genPasswordResetTokenId,
passwordResetTokenIdFromUUID,
passwordResetTokenIdToUUID,
userIdFromUUID,
userIdToUUID,
)
import Shomei.Persistence.Codec.Postgres (oneTimeTokenStatusFromText, oneTimeTokenStatusToText)
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession)
import Shomei.Prelude
type TokenRow = (UUID, UUID, Text, Text, UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime)
runPasswordResetTokenStorePostgres ::
(Database :> es, IOE :> es, Error AuthError :> es) =>
Eff (PasswordResetTokenStore : es) a ->
Eff es a
runPasswordResetTokenStorePostgres = interpret_ \case
CreatePasswordResetToken nrt -> do
tid <- genPasswordResetTokenId
let persisted = mkPersisted tid nrt
row =
( passwordResetTokenIdToUUID tid,
userIdToUUID nrt.userId,
tokenHashText nrt.tokenHash,
oneTimeTokenStatusToText OneTimeTokenActive,
nrt.createdAt,
nrt.expiresAt,
Nothing,
Nothing
)
res <- runSession (Session.statement row insertTokenStmt)
either dbFail (const (pure persisted)) res
FindPasswordResetTokenByHash h -> do
res <- runSession (Session.statement (tokenHashText h) findByHashStmt)
row <- either dbFail pure res
traverse rebuild row
MarkPasswordResetTokenConsumed tid t -> do
res <- runSession (Session.statement (passwordResetTokenIdToUUID tid, t) markConsumedStmt)
either dbFail (pure . isJust) res
RevokeUserPasswordResetTokens uid t -> do
res <- runSession (Session.statement (userIdToUUID uid, t) revokeUserTokensStmt)
either dbFail (const (pure ())) res
where
dbFail = throwError . postgresUnavailable
rebuild r = either (throwError . InternalAuthError) pure (rebuildToken r)
tokenHashText :: OneTimeTokenHash -> Text
tokenHashText (OneTimeTokenHash t) = t
mkPersisted :: PasswordResetTokenId -> NewPasswordResetToken -> PersistedPasswordResetToken
mkPersisted tid nrt =
PersistedPasswordResetToken
{ passwordResetTokenId = tid,
userId = nrt.userId,
tokenHash = nrt.tokenHash,
status = OneTimeTokenActive,
createdAt = nrt.createdAt,
expiresAt = nrt.expiresAt,
consumedAt = Nothing,
revokedAt = Nothing
}
rebuildToken :: TokenRow -> Either Text PersistedPasswordResetToken
rebuildToken (tid, uid, h, st, c, e, consumed, revoked) = do
status <- oneTimeTokenStatusFromText st
pure
PersistedPasswordResetToken
{ passwordResetTokenId = passwordResetTokenIdFromUUID tid,
userId = userIdFromUUID uid,
tokenHash = OneTimeTokenHash h,
status = status,
createdAt = c,
expiresAt = e,
consumedAt = consumed,
revokedAt = revoked
}
tokenRowDecoder :: D.Row TokenRow
tokenRowDecoder =
(,,,,,,,)
<$> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.timestamptz)
<*> D.column (D.nonNullable D.timestamptz)
<*> D.column (D.nullable D.timestamptz)
<*> D.column (D.nullable D.timestamptz)
insertTokenStmt :: Statement TokenRow ()
insertTokenStmt =
preparable
"""
INSERT INTO shomei.shomei_password_reset_tokens
(password_reset_token_id, user_id, token_hash, status, created_at, expires_at,
consumed_at, revoked_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"""
( contrazip8
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.timestamptz))
(E.param (E.nonNullable E.timestamptz))
(E.param (E.nullable E.timestamptz))
(E.param (E.nullable E.timestamptz))
)
D.noResult
findByHashStmt :: Statement Text (Maybe TokenRow)
findByHashStmt =
preparable
"""
SELECT password_reset_token_id, user_id, token_hash, status, created_at, expires_at,
consumed_at, revoked_at
FROM shomei.shomei_password_reset_tokens
WHERE token_hash = $1
"""
(E.param (E.nonNullable E.text))
(D.rowMaybe tokenRowDecoder)
-- | Compare-and-swap: the @status = 'active'@ guard and the write are one statement, so two
-- concurrent confirmations of the same one-time token cannot both consume it. The loser
-- matches zero rows and returns no @RETURNING@ row.
markConsumedStmt :: Statement (UUID, UTCTime) (Maybe UUID)
markConsumedStmt =
preparable
"""
UPDATE shomei.shomei_password_reset_tokens
SET status = 'consumed', consumed_at = $2
WHERE password_reset_token_id = $1
AND status = 'active'
RETURNING password_reset_token_id
"""
(contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.timestamptz)))
(D.rowMaybe (D.column (D.nonNullable D.uuid)))
revokeUserTokensStmt :: Statement (UUID, UTCTime) ()
revokeUserTokensStmt =
preparable
"""
UPDATE shomei.shomei_password_reset_tokens
SET status = 'revoked', revoked_at = $2
WHERE user_id = $1
AND status = 'active'
"""
(contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.timestamptz)))
D.noResult