1use sofie::db::{Outcome, Plan, Reply, Resolve, Stamp};
2use sofie::http::{PrincipalRecord, RequestContext, SignInRight};
3use sofie::prelude::*;
4use sofie::serde_json::json;
5
6use crate::{AccountId, MemberArea};
7
8struct Identity;
9
10impl sofie::http::Admitter for Identity {}
11
12const RIGHT: SignInRight = SignInRight::of::<Identity>();
13#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
14const SIGN_IN_AGAIN: &str = "Sign in again first: this needs a sign-in from the last ten minutes.";
15#[cfg(feature = "email-verification")]
16const UNCONFIRMED: &str = "Confirm your email address first: follow the link mailed to it.";
17
18pub struct SignedIn {
19 #[cfg(feature = "impersonation")]
20 session: sofie::db::Uuid,
21 #[cfg(feature = "sessions")]
22 record: crate::sessions::AccountSession,
23}
24
25pub(crate) fn sign_in<A: Area>(
27 session: &Session,
28 account: AccountId,
29 clock: &Clock,
30) -> Result<SignedIn> {
31 sign_in_for::<A>(
32 session,
33 RIGHT,
34 Id::from_uuid(account.uuid()),
35 account,
36 clock,
37 )
38}
39
40pub fn sign_in_for<A: Area>(
44 session: &Session,
45 right: SignInRight,
46 principal: Id<PrincipalRecord<A>>,
47 account: AccountId,
48 clock: &Clock,
49) -> Result<SignedIn> {
50 let id = session.sign_in::<A>(right, principal)?;
51 #[cfg(not(feature = "sessions"))]
52 let _ = (id, account, clock);
53 Ok(SignedIn {
54 #[cfg(feature = "impersonation")]
55 session: id,
56 #[cfg(feature = "sessions")]
57 record: crate::sessions::started(
58 id,
59 account,
60 A::NAME,
61 principal.uuid(),
62 session.user_agent().as_deref(),
63 clock.now(),
64 )?,
65 })
66}
67
68#[cfg(feature = "impersonation")]
69impl SignedIn {
70 pub(crate) fn session(&self) -> sofie::db::Uuid {
71 self.session
72 }
73
74 pub(crate) fn impersonated(mut self, by: &Email, until: sofie::core::Instant) -> Self {
75 self.record.impersonated(by, until);
76 self
77 }
78}
79
80impl Outcome for SignedIn {
81 #[cfg(feature = "sessions")]
82 fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
83 self.record.plan(plan)
84 }
85
86 #[cfg(feature = "sessions")]
87 fn committed(&mut self, stamp: Stamp) {
88 self.record.committed(stamp);
89 }
90}
91
92#[cfg(any(
94 feature = "passkeys",
95 feature = "oauth",
96 feature = "totp",
97 feature = "account-data",
98 feature = "session-screens"
99))]
100pub(crate) struct OwnSession;
101
102#[cfg(any(
103 feature = "passkeys",
104 feature = "oauth",
105 feature = "totp",
106 feature = "account-data",
107 feature = "session-screens"
108))]
109impl<A: Area, M> Resolve<RequestContext<A, M>> for OwnSession {
110 #[cfg_attr(
111 not(feature = "impersonation"),
112 expect(clippy::unused_async_trait_impl)
113 )]
114 async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
115 #[cfg(feature = "impersonation")]
116 if crate::impersonation::Impersonating::resolve(context)
117 .await?
118 .by()
119 .is_some()
120 {
121 return Err(Error::Rule(
122 "An impersonation cannot change how this account signs in, end its sessions, export or close it."
123 .to_owned(),
124 ));
125 }
126 #[cfg(not(feature = "impersonation"))]
127 let _ = context;
128 Ok(Self)
129 }
130}
131
132#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
134pub(crate) struct RecentSignIn;
135
136#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
137impl<A: Area> Resolve<RequestContext<A>> for RecentSignIn {
138 async fn resolve(context: &mut RequestContext<A>) -> Result<Self> {
139 if !SignedInRecently::resolve(context).await?.0 {
140 return Err(Error::Rule(SIGN_IN_AGAIN.to_owned()));
141 }
142 Ok(Self)
143 }
144}
145
146#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
148pub(crate) struct SignedInRecently(pub(crate) bool);
149
150#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
151impl<A: Area, M> Resolve<RequestContext<A, M>> for SignedInRecently {
152 async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
153 OwnSession::resolve(context).await?;
154 let current = sofie::http::CurrentSession::resolve(context).await?.id();
155 let now = Clock::resolve(context).await?.now();
156 let read = Read::resolve(context).await?;
157 let Some(current) = current else {
158 return Ok(Self(false));
159 };
160 crate::sessions::signed_in_recently(&read, current, now)
161 .await
162 .map(Self)
163 }
164}
165
166pub(crate) struct Door {
167 session: Session,
168 read: Read,
169 clock: Clock,
170 #[cfg(feature = "totp")]
171 urls: sofie::http::SignedUrls,
172 #[cfg(feature = "totp")]
173 next: sofie::http::Next,
174}
175
176impl Resolve<RequestContext<Web>> for Door {
177 async fn resolve(context: &mut RequestContext<Web>) -> Result<Self> {
178 Ok(Self {
179 session: Session::resolve(context).await?,
180 read: Read::resolve(context).await?,
181 clock: Clock::resolve(context).await?,
182 #[cfg(feature = "totp")]
183 urls: sofie::http::SignedUrls::resolve(context).await?,
184 #[cfg(feature = "totp")]
185 next: sofie::http::Next::resolve(context).await?,
186 })
187 }
188}
189
190pub(crate) struct Entered<T = ()> {
193 signed_in: Option<SignedIn>,
194 #[cfg(feature = "totp")]
195 pending: Option<crate::account_link::AccountLink>,
196 location: Option<String>,
197 saved: T,
198}
199
200impl Door {
201 pub(crate) fn read(&self) -> &Read {
202 &self.read
203 }
204
205 #[cfg(any(feature = "links", feature = "passkeys", feature = "oauth"))]
206 pub(crate) fn clock(&self) -> &Clock {
207 &self.clock
208 }
209
210 #[cfg(any(feature = "passkeys", feature = "oauth"))]
211 pub(crate) fn session(&self) -> &Session {
212 &self.session
213 }
214
215 pub(crate) async fn enter<A: MemberArea>(&self, account: AccountId) -> Result<Entered> {
216 self.enter_after::<A>(account, false).await
217 }
218
219 pub(crate) async fn enter_after<A: MemberArea>(
222 &self,
223 account: AccountId,
224 second_proved: bool,
225 ) -> Result<Entered> {
226 #[cfg(feature = "email-verification")]
227 if A::VERIFIED_EMAIL
228 && crate::account::Account::stored(&self.read, account)
229 .await?
230 .verified_at()
231 .is_none()
232 {
233 return Err(Error::Rule(UNCONFIRMED.to_owned()));
234 }
235 self.admit::<A>(account, second_proved).await
236 }
237
238 #[cfg(any(feature = "links", feature = "oauth"))]
241 pub(crate) async fn enter_confirming<A: MemberArea>(
242 &self,
243 account: AccountId,
244 distrusted: bool,
245 ) -> Result<Entered> {
246 self.admit::<A>(account, distrusted).await
247 }
248
249 #[cfg_attr(not(feature = "totp"), expect(clippy::unused_async_trait_impl))]
250 async fn admit<A: MemberArea>(
251 &self,
252 account: AccountId,
253 second_proved: bool,
254 ) -> Result<Entered> {
255 #[cfg(not(feature = "totp"))]
256 let _ = second_proved;
257 #[cfg(feature = "totp")]
258 if !second_proved
259 && crate::two_factor::required(&self.read, account)
260 .await?
261 .is_some()
262 {
263 let (pending, location) =
264 crate::two_factor::asking::<A>(account, &self.urls, &self.clock, &self.next)?;
265 return Ok(Entered {
266 signed_in: None,
267 pending: Some(pending),
268 location: Some(location),
269 saved: (),
270 });
271 }
272 Ok(Entered {
273 signed_in: Some(sign_in::<A>(&self.session, account, &self.clock)?),
274 #[cfg(feature = "totp")]
275 pending: None,
276 location: None,
277 saved: (),
278 })
279 }
280}
281
282impl Entered {
283 #[cfg(feature = "passkeys")]
285 pub(crate) fn located<A: MemberArea>(self) -> Self {
286 Self {
287 location: self.location.or_else(|| Some(A::HOME.to_owned())),
288 ..self
289 }
290 }
291
292 pub(crate) fn saving<T: Outcome>(self, saved: T) -> Entered<T> {
293 Entered {
294 signed_in: self.signed_in,
295 #[cfg(feature = "totp")]
296 pending: self.pending,
297 location: self.location,
298 saved,
299 }
300 }
301}
302
303impl<T: Outcome> Outcome for Entered<T> {
304 fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
305 self.saved.plan(plan)?;
306 if let Some(signed_in) = &self.signed_in {
307 signed_in.plan(plan)?;
308 }
309 #[cfg(feature = "totp")]
310 if let Some(pending) = &self.pending {
311 pending.plan(plan)?;
312 }
313 Ok(())
314 }
315
316 fn committed(&mut self, stamp: Stamp) {
317 self.saved.committed(stamp);
318 if let Some(signed_in) = &mut self.signed_in {
319 signed_in.committed(stamp);
320 }
321 #[cfg(feature = "totp")]
322 if let Some(pending) = &mut self.pending {
323 pending.committed(stamp);
324 }
325 }
326
327 fn reply(self) -> Result<Reply> {
328 match self.location {
329 Some(location) => Ok(Reply::Json(json!({
330 "location": location,
331 "second_factor": self.signed_in.is_none(),
332 }))),
333 None => Ok(Reply::Empty),
334 }
335 }
336}