packages feed

shomei-core-0.2.0.0: src/Shomei/Account/Password/Domain.hs

-- | Password types and the pure password-policy validator.
--
-- 'PlainPassword' is the user-supplied secret. It has a redacting 'Show' instance and
-- deliberately no JSON instances, so it is never logged, serialized, or persisted.
-- 'PasswordHash' is the opaque hash produced by the 'Shomei.Account.Password.Hash.Store' port
-- (Argon2id in production, EP-3).
module Shomei.Account.Password.Domain
  ( PlainPassword (..),
    PasswordHash (..),
    PasswordPolicy (..),
    PasswordContext (..),
    emptyPasswordContext,
    defaultPasswordPolicy,
    validatePassword,
  )
where

import Data.Text qualified as Text
import Shomei.Account.Password.Common.Domain (isCommonPassword)
import Shomei.Error (PasswordPolicyViolation (..))
import Shomei.Prelude

-- | Never logged, serialized, or persisted: redacting 'Show', no 'FromJSON'/'ToJSON'.
newtype PlainPassword = PlainPassword Text
  deriving stock (Generic)

instance Show PlainPassword where
  show _ = "PlainPassword <redacted>"

newtype PasswordHash = PasswordHash Text
  deriving stock (Generic)
  deriving newtype (Eq, Show, FromJSON, ToJSON)

data PasswordPolicy = PasswordPolicy
  { minLength :: !Int,
    maxLength :: !Int,
    rejectCommonPasswords :: !Bool, -- consumed by EP-2 (docs/plans/21-...)
    rejectContextualPasswords :: !Bool, -- consumed by EP-2 (docs/plans/21-...)
    breachCheckEnabled :: !Bool, -- consumed by EP-3 (docs/plans/22-...)
    breachCheckFailClosed :: !Bool, -- consumed by EP-3 (docs/plans/22-...)
    breachCheckTimeoutMs :: !Int -- consumed by EP-3 (docs/plans/22-...)
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

defaultPasswordPolicy :: PasswordPolicy
defaultPasswordPolicy =
  PasswordPolicy
    { minLength = 12,
      maxLength = 256,
      rejectCommonPasswords = True,
      rejectContextualPasswords = True,
      breachCheckEnabled = False,
      breachCheckFailClosed = False,
      breachCheckTimeoutMs = 1000
    }

-- | The identity context a password is checked against (for the contextual check).
data PasswordContext = PasswordContext
  { -- | the user's email address (raw text), if known
    contextEmail :: !(Maybe Text),
    -- | the user's display name, if any
    contextDisplayName :: !(Maybe Text)
  }
  deriving stock (Generic, Eq, Show)

-- | No identity context (length and common-password checks still apply).
emptyPasswordContext :: PasswordContext
emptyPasswordContext = PasswordContext {contextEmail = Nothing, contextDisplayName = Nothing}

-- | Validate a password against the policy and the user's identity context. Check order:
-- length (cheap) first, then the common-password dictionary (if 'rejectCommonPasswords'),
-- then the contextual identity check (if 'rejectContextualPasswords').
validatePassword ::
  PasswordPolicy -> PasswordContext -> PlainPassword -> Either PasswordPolicyViolation ()
validatePassword policy context (PlainPassword pw)
  | Text.length pw < policy.minLength = Left (PasswordTooShort policy.minLength)
  | Text.length pw > policy.maxLength = Left (PasswordTooLong policy.maxLength)
  | policy.rejectCommonPasswords && isCommonPassword pw = Left PasswordTooCommon
  | policy.rejectContextualPasswords && resemblesIdentity context pw = Left PasswordResemblesIdentity
  | otherwise = Right ()

-- | Does the password (trimmed, lowercased) exactly equal the user's email local-part,
-- full email, or display name (each trimmed, lowercased)? Exact equality only — no
-- substring rule, to avoid rejecting long passphrases that merely contain a short name.
resemblesIdentity :: PasswordContext -> Text -> Bool
resemblesIdentity ctx pw =
  let p = Text.toLower (Text.strip pw)
      norm = Text.toLower . Text.strip
      emailCandidates = case ctx.contextEmail of
        Nothing -> []
        Just e -> let e' = norm e in [e', Text.takeWhile (/= '@') e']
      nameCandidates = maybe [] (\n -> [norm n]) ctx.contextDisplayName
   in not (Text.null p) && p `elem` (emailCandidates <> nameCandidates)