cover/Elixir.WalletAuth.Commands.LoginWithPassword.html

1 defmodule WalletAuth.Commands.LoginWithPassword do
2 @moduledoc """
3 Login command: credential validation, token issuance, session creation.
4
5 Per ADR 0006 and checklist Track A:
6 - Rate-limited per identifier (email/phone).
7 - Credential validation via CredentialPolicy (timing-safe).
8 - JWT access token issued on success.
9 - Refresh token stored in TokenStore with session binding.
10 - Audit events emitted for success and failure.
11 - Suspicious activity event emitted on rate-limit hit.
12
13 Credential lookup is injected to keep wallet_auth decoupled from
14 wallet_accounts (which is a Phase 3 app). The caller provides:
15
16 credential_lookup_fn :: (identifier) -> {:ok, {user_id, hashed_password}} | {:error, :not_found}
17
18 ## Error codes (ADR 0005)
19 - `UNAUTHORIZED` — invalid credentials.
20 - `RATE_LIMITED` → `OTP_REQUIRED` — if rate limited, surface as OTP step-up.
21 """
22
23 alias WalletAuth.Credentials.CredentialPolicy
24 alias WalletAuth.RateLimiter
25 alias WalletAuth.Session.{Session, SessionStore}
26 alias WalletAuth.Token.{AccessToken, RefreshToken, TokenStore}
27 alias WalletAuth.Events.{UserAuthenticated, AuthSessionStarted, LoginFailed, SuspiciousActivityDetected}
28 alias WalletObservability.{AuditEvent, Telemetry}
29
30 @type credential_lookup_fn :: (String.t() -> {:ok, {String.t(), String.t()}} | {:error, :not_found})
31
32 @type result :: %{
33 access_token: String.t(),
34 refresh_token: String.t(),
35 session_id: String.t(),
36 user_id: String.t()
37 }
38
39 @doc """
40 Executes the login command.
41
42 Options:
43 - `device_id` — bind session to device.
44 - `user_agent` — for session metadata.
45 - `ip_address` — for session metadata and audit.
46 - `correlation_id` — propagate into events.
47 - `token_opts` — passed to AccessToken.issue/2.
48
49 Returns `{:ok, result()}` or `{:error, :invalid_credentials | :rate_limited}`.
50 """
51 @spec execute(
52 identifier :: String.t(),
53 password :: String.t(),
54 credential_lookup_fn(),
55 keyword()
56 ) :: {:ok, result()} | {:error, :invalid_credentials | :rate_limited}
57 6 def execute(identifier, password, credential_lookup_fn, opts \\ []) do
58 6 correlation_id = Keyword.get(opts, :correlation_id, WalletSharedKernel.Correlation.new_correlation_id())
59 6 ip = Keyword.get(opts, :ip_address)
60
61 6 Telemetry.span([:wallet_auth, :login, :execute], %{identifier: identifier}, fn ->
62 6 with :ok <- check_rate_limit(identifier, ip, correlation_id),
63 5 {:ok, {user_id, hashed_password}} <- lookup_credentials(identifier, credential_lookup_fn),
64 2 :ok <- verify_credentials(password, hashed_password, identifier, correlation_id) do
65 1 session = Session.new(user_id, Keyword.take(opts, [:device_id, :user_agent, :ip_address]))
66 1 refresh_token = RefreshToken.generate()
67
68 1 :ok = SessionStore.store(session)
69 1 :ok = TokenStore.store(refresh_token, user_id, session.session_id)
70
71 1 {:ok, access_token, _claims} =
72 AccessToken.issue(user_id, Keyword.get(opts, :token_opts, []) ++ [correlation_id: correlation_id])
73
74 # Emit auth audit events
75 1 emit_success_audit(user_id, session.session_id, ip, correlation_id)
76 1 emit_event(UserAuthenticated.build(user_id, session.session_id, correlation_id: correlation_id, ip_address: ip))
77 1 emit_event(AuthSessionStarted.build(session.session_id, user_id, correlation_id: correlation_id, ip_address: ip, device_id: Keyword.get(opts, :device_id)))
78
79 # Reset rate limiter on success
80 1 RateLimiter.reset(:login, identifier)
81
82 {:ok,
83 %{
84 access_token: access_token,
85 refresh_token: refresh_token,
86 1 session_id: session.session_id,
87 user_id: user_id
88 }}
89 else
90 {:error, reason} ->
91 5 emit_failure_audit(identifier, reason, ip, correlation_id)
92 5 emit_event(LoginFailed.build(identifier, reason, correlation_id: correlation_id, ip_address: ip))
93 {:error, reason}
94 end
95 end)
96 end
97
98 # --- Private ---
99
100 defp check_rate_limit(identifier, ip, correlation_id) do
101 6 case RateLimiter.check_and_increment(:login, identifier) do
102 5 :ok ->
103 :ok
104
105 {:error, :rate_limited} ->
106 1 emit_event(SuspiciousActivityDetected.build(identifier, :login_rate_limited,
107 correlation_id: correlation_id,
108 ip_address: ip
109 ))
110 {:error, :rate_limited}
111 end
112 end
113
114 defp lookup_credentials(identifier, credential_lookup_fn) do
115 5 case credential_lookup_fn.(identifier) do
116 2 {:ok, {user_id, hashed_password}} -> {:ok, {user_id, hashed_password}}
117 3 {:error, :not_found} -> {:error, :invalid_credentials}
118 end
119 end
120
121 defp verify_credentials(password, hashed_password, _identifier, _correlation_id) do
122 2 if CredentialPolicy.verify(password, hashed_password) do
123 :ok
124 else
125 {:error, :invalid_credentials}
126 end
127 end
128
129 defp emit_success_audit(user_id, session_id, ip, correlation_id) do
130 1 event = AuditEvent.build(:auth, "login_success", "session", session_id, :success,
131 actor_id: user_id,
132 correlation_id: correlation_id,
133 metadata: %{ip_address: ip}
134 )
135 1 :telemetry.execute([:wallet_auth, :audit], %{}, event)
136 end
137
138 defp emit_failure_audit(identifier, reason, ip, correlation_id) do
139 5 event = AuditEvent.build(:auth, "login_failure", "user", identifier, :failure,
140 correlation_id: correlation_id,
141 metadata: %{reason: reason, ip_address: ip}
142 )
143 5 :telemetry.execute([:wallet_auth, :audit], %{}, event)
144 end
145
146 8 defp emit_event(event) do
147 8 pubsub = Application.get_env(:wallet_auth, :pubsub, WalletWeb.PubSub)
148 8 apply(Phoenix.PubSub, :broadcast, [pubsub, "wallet_auth:events", {:domain_event, event}])
149 rescue
150
:-(
_ -> :ok
151 end
152 end
Line Hits Source