shomei-core-0.2.0.0: src/Shomei/Account/Lifecycle/Workflow.hs
-- | Account lifecycle workflows for email verification and password management.
module Shomei.Account.Lifecycle.Workflow
( RequestEmailVerification (..),
ConfirmEmailVerification (..),
RequestPasswordReset (..),
ConfirmPasswordReset (..),
ChangePassword (..),
requestEmailVerification,
confirmEmailVerification,
requestPasswordReset,
confirmPasswordReset,
changePassword,
)
where
import Data.Time (addUTCTime)
import Effectful (Eff, (:>))
import Effectful.Error.Static (runErrorNoCallStack, throwError)
import Shomei.Account.Credential.Domain (Credential (..))
import Shomei.Account.Credential.Store (CredentialStore, findPasswordCredentialByLoginId)
import Shomei.Account.Email.Domain (Email, emailText)
import Shomei.Account.LoginId.Domain (loginIdText)
import Shomei.Account.Notification.Domain (Notification (..))
import Shomei.Account.Notification.Store (Notifier, sendNotification)
import Shomei.Account.OneTimeToken.Domain (OneTimeToken (..), OneTimeTokenHash (..), OneTimeTokenStatus (..))
import Shomei.Account.Password.Breach.Store (PasswordBreachChecker)
import Shomei.Account.Password.Breach.Workflow (enforceBreachPolicy)
import Shomei.Account.Password.Domain (PasswordContext (..), PlainPassword, validatePassword)
import Shomei.Account.Password.Hash.Store (PasswordHasher, hashPassword, verifyPassword, verifyPasswordDummy)
import Shomei.Account.PasswordReset.Domain (NewPasswordResetToken (..), PersistedPasswordResetToken (..))
import Shomei.Account.PasswordReset.Store
( PasswordResetTokenStore,
createPasswordResetToken,
findPasswordResetTokenByHash,
)
import Shomei.Account.User.Domain (User (..), UserStatus (UserActive))
import Shomei.Account.User.Store (UserStore, findUserByEmail, findUserById, markUserEmailVerified)
import Shomei.Account.Verification.Domain (NewVerificationToken (..), PersistedVerificationToken (..))
import Shomei.Account.Verification.Store
( VerificationTokenStore,
createVerificationToken,
findVerificationTokenByHash,
markVerificationTokenConsumed,
revokeUserVerificationTokens,
)
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)
import Shomei.Config (NotifierConfig (..), ShomeiConfig (..))
import Shomei.Error (AuthError (..))
import Shomei.Id (UserId)
import Shomei.Prelude
import Shomei.Session.Command (ProofContext, proofContextFor)
import Shomei.Session.LoginAttempt.Domain (AttemptFactor (FactorPasswordChange))
import Shomei.Session.LoginAttempt.Store (LoginAttemptStore)
import Shomei.Session.LoginAttempt.Workflow (AbuseGate (..), guardAbuse, recordProofFailure, recordProofSuccess)
import Shomei.Session.RefreshToken.Domain (RefreshToken (..), RefreshTokenHash (..))
import Shomei.Session.Token.Generator (TokenGen, generateOpaqueToken, hashRefreshToken)
import Shomei.Session.UnitOfWork.Store (AuthUnitOfWork, completePasswordChange, completePasswordReset)
import Shomei.Time.Store (Clock, now)
newtype RequestEmailVerification = RequestEmailVerification {email :: Email}
deriving stock (Generic, Show)
newtype ConfirmEmailVerification = ConfirmEmailVerification {token :: OneTimeToken}
deriving stock (Generic, Show)
newtype RequestPasswordReset = RequestPasswordReset {email :: Email}
deriving stock (Generic, Show)
data ConfirmPasswordReset = ConfirmPasswordReset
{ token :: !OneTimeToken,
newPassword :: !PlainPassword
}
deriving stock (Generic, Show)
data ChangePassword = ChangePassword
{ userId :: !UserId,
currentPassword :: !PlainPassword,
newPassword :: !PlainPassword
}
deriving stock (Generic, Show)
requestEmailVerification ::
( UserStore :> es,
VerificationTokenStore :> es,
Notifier :> es,
AuthEventPublisher :> es,
Clock :> es,
TokenGen :> es
) =>
ShomeiConfig ->
RequestEmailVerification ->
Eff es (Either AuthError ())
requestEmailVerification cfg cmd = do
ts <- now
mUser <- findUserByEmail cmd.email
forM_ mUser \user ->
forM_ user.email \email ->
when (user.status == UserActive && isNothing user.emailVerifiedAt) do
let expires = addUTCTime cfg.notifierConfig.verificationTokenTTL ts
(raw, h) <- generateOneTimeToken
_ <-
createVerificationToken
NewVerificationToken
{ userId = user.userId,
tokenHash = h,
createdAt = ts,
expiresAt = expires
}
sendNotification (EmailVerificationRequested email raw expires)
publishAuthEvent (Event.EmailVerificationRequested (Event.EmailVerificationRequestedData user.userId email ts))
pure (Right ())
confirmEmailVerification ::
( VerificationTokenStore :> es,
UserStore :> es,
AuthEventPublisher :> es,
Clock :> es,
TokenGen :> es
) =>
ShomeiConfig ->
ConfirmEmailVerification ->
Eff es (Either AuthError ())
confirmEmailVerification _cfg cmd = runErrorNoCallStack do
ts <- now
h <- hashOneTimeToken cmd.token
tok <- maybe (throwError VerificationTokenInvalid) pure =<< findVerificationTokenByHash h
either throwError pure (ensureUsableVerification tok ts)
user <- maybe (throwError VerificationTokenInvalid) pure =<< findUserById tok.userId
-- A verification token only ever exists for an account that had an email; a missing
-- email here means the token cannot belong to a verifiable account.
email <- maybe (throwError VerificationTokenInvalid) pure user.email
when (isJust user.emailVerifiedAt) (throwError EmailAlreadyVerified)
-- Consume before acting: the compare-and-swap is the linearization point, so of two
-- concurrent confirmations of one token exactly one proceeds. The loser sees precisely what
-- a stale-token presenter sees.
won <- markVerificationTokenConsumed tok.verificationTokenId ts
unless won (throwError VerificationTokenInvalid)
markUserEmailVerified user.userId ts
revokeUserVerificationTokens user.userId ts
publishAuthEvent (Event.EmailVerified (Event.EmailVerifiedData user.userId email ts))
requestPasswordReset ::
( UserStore :> es,
PasswordResetTokenStore :> es,
Notifier :> es,
AuthEventPublisher :> es,
Clock :> es,
TokenGen :> es
) =>
ShomeiConfig ->
RequestPasswordReset ->
Eff es (Either AuthError ())
requestPasswordReset cfg cmd = do
ts <- now
mUser <- findUserByEmail cmd.email
forM_ mUser \user ->
forM_ user.email \email ->
when (user.status == UserActive) do
let expires = addUTCTime cfg.notifierConfig.passwordResetTokenTTL ts
(raw, h) <- generateOneTimeToken
_ <-
createPasswordResetToken
NewPasswordResetToken
{ userId = user.userId,
tokenHash = h,
createdAt = ts,
expiresAt = expires
}
sendNotification (PasswordResetRequested email raw expires)
publishAuthEvent (Event.PasswordResetRequested (Event.PasswordResetRequestedData user.userId email ts))
pure (Right ())
confirmPasswordReset ::
( UserStore :> es,
PasswordResetTokenStore :> es,
PasswordHasher :> es,
PasswordBreachChecker :> es,
AuthUnitOfWork :> es,
Clock :> es,
TokenGen :> es
) =>
ShomeiConfig ->
ConfirmPasswordReset ->
Eff es (Either AuthError ())
confirmPasswordReset cfg cmd = runErrorNoCallStack do
ts <- now
h <- hashOneTimeToken cmd.token
tok <- maybe (throwError PasswordResetTokenInvalid) pure =<< findPasswordResetTokenByHash h
either throwError pure (ensureUsableReset tok ts)
user <- maybe (throwError PasswordResetTokenInvalid) pure =<< findUserById tok.userId
let pwContext =
PasswordContext
{ contextEmail = emailText <$> user.email,
contextDisplayName = user.displayName
}
either (throwError . WeakPassword) pure (validatePassword cfg.passwordPolicy pwContext cmd.newPassword)
enforceBreachPolicy cfg.passwordPolicy cmd.newPassword
newHash <- hashPassword cmd.newPassword
-- Consume before acting, but after validating the new password: the compare-and-swap is the
-- linearization point (exactly one of two concurrent confirmations proceeds), while a
-- pure-read policy check ahead of it cannot widen the race and spares the user's token when
-- the new password is merely too weak.
won <-
completePasswordReset
tok.passwordResetTokenId
tok.userId
newHash
ts
[Event.PasswordResetCompleted (Event.PasswordResetCompletedData tok.userId ts)]
unless won (throwError PasswordResetTokenInvalid)
changePassword ::
( UserStore :> es,
CredentialStore :> es,
PasswordHasher :> es,
PasswordBreachChecker :> es,
AuthUnitOfWork :> es,
AuthEventPublisher :> es,
LoginAttemptStore :> es,
Clock :> es
) =>
ShomeiConfig ->
ProofContext ->
ChangePassword ->
Eff es (Either AuthError ())
changePassword cfg pctx cmd = runErrorNoCallStack do
user <- maybe (throwError InvalidCredentials) pure =<< findUserById cmd.userId
ts <- now
let ctx = proofContextFor pctx (loginIdText user.loginId)
gate <- guardAbuse cfg.rateLimitConfig ctx ts
when gate.locked do
verifyPasswordDummy cmd.currentPassword
throwError InvalidCredentials
let pwContext =
PasswordContext
{ contextEmail = emailText <$> user.email,
contextDisplayName = user.displayName
}
either (throwError . WeakPassword) pure (validatePassword cfg.passwordPolicy pwContext cmd.newPassword)
enforceBreachPolicy cfg.passwordPolicy cmd.newPassword
cred <- maybe (throwError InvalidCredentials) pure =<< findPasswordCredentialByLoginId user.loginId
ok <- verifyPassword cmd.currentPassword cred.passwordHash
unless ok do
recordProofFailure cfg.rateLimitConfig ctx FactorPasswordChange ts
publishAuthEvent (Event.PasswordChangeFailed (Event.PasswordChangeFailedData user.userId ts))
throwError InvalidCredentials
recordProofSuccess ctx FactorPasswordChange gate.standingLockout ts
newHash <- hashPassword cmd.newPassword
completePasswordChange
user.userId
newHash
ts
[Event.PasswordChanged (Event.PasswordChangedData user.userId ts)]
generateOneTimeToken :: (TokenGen :> es) => Eff es (OneTimeToken, OneTimeTokenHash)
generateOneTimeToken = do
raw@(RefreshToken t) <- generateOpaqueToken
RefreshTokenHash h <- hashRefreshToken raw
pure (OneTimeToken t, OneTimeTokenHash h)
hashOneTimeToken :: (TokenGen :> es) => OneTimeToken -> Eff es OneTimeTokenHash
hashOneTimeToken (OneTimeToken t) = do
RefreshTokenHash h <- hashRefreshToken (RefreshToken t)
pure (OneTimeTokenHash h)
ensureUsableVerification :: PersistedVerificationToken -> UTCTime -> Either AuthError ()
ensureUsableVerification tok ts =
if tok.status == OneTimeTokenActive && tok.expiresAt > ts
then Right ()
else Left VerificationTokenInvalid
ensureUsableReset :: PersistedPasswordResetToken -> UTCTime -> Either AuthError ()
ensureUsableReset tok ts =
if tok.status == OneTimeTokenActive && tok.expiresAt > ts
then Right ()
else Left PasswordResetTokenInvalid