Skip to main content

sofie_identity/
lib.rs

1mod account;
2#[cfg(feature = "account-data")]
3pub mod account_data;
4#[cfg(feature = "account-links")]
5#[cfg_attr(
6    not(any(feature = "links", feature = "totp")),
7    expect(
8        dead_code,
9        reason = "an internal feature, used through `links` and `totp`"
10    )
11)]
12mod account_link;
13mod actions;
14#[cfg(feature = "impersonation")]
15pub mod impersonation;
16#[cfg(feature = "shield")]
17mod lock;
18#[cfg(feature = "links")]
19#[cfg_attr(
20    not(any(
21        feature = "password-reset",
22        feature = "email-verification",
23        feature = "sign-in-links"
24    )),
25    expect(
26        dead_code,
27        reason = "an internal feature, used through the mailed links"
28    )
29)]
30mod mailed;
31mod membership;
32#[cfg(any(feature = "shield", feature = "links"))]
33mod notice;
34#[cfg(feature = "oauth")]
35pub mod oauth;
36mod pages;
37#[cfg(feature = "passkeys")]
38pub mod passkeys;
39mod password;
40#[cfg(feature = "permissions")]
41mod permitted;
42#[cfg(feature = "permissions")]
43mod role;
44#[cfg(feature = "permissions")]
45mod role_grant;
46mod route_table;
47#[cfg(feature = "sessions")]
48pub mod sessions;
49mod signing_in;
50#[cfg(feature = "testing")]
51pub mod testing;
52#[cfg(feature = "tokens")]
53pub mod tokens;
54#[cfg(feature = "totp")]
55pub mod two_factor;
56
57use sofie::Module;
58use sofie::db::{File, Migrations};
59use sofie::http::{App, Area, AreaPrincipal, Form, Mount, Rate, Routes, Web};
60use sofie::worker::Worker;
61
62pub use account::Account;
63#[cfg(feature = "shield")]
64pub use lock::SignInLocked;
65pub use membership::{
66    AddingMember, MemberAdded, MembershipRemoved, Memberships, add_member, add_member_task,
67    remove_membership,
68};
69pub use password::{Password, PasswordCheck, PasswordHash};
70#[cfg(feature = "permissions")]
71pub use permitted::*;
72pub use route_table::ROUTES;
73pub use signing_in::{SignedIn, sign_in_for};
74
75pub type AccountId = sofie::db::Id<Account>;
76
77/// The whole database, for records beyond the request's reach, such as other accounts'.
78#[doc(hidden)]
79pub struct Unscoped(pub sofie::db::Db);
80
81impl<C: sofie::db::Context> sofie::db::Resolve<C> for Unscoped {
82    fn resolve(context: &mut C) -> impl Future<Output = sofie::core::Result<Self>> + Send {
83        std::future::ready(Ok(Self(context.db().clone())))
84    }
85}
86
87const NAME: &str = "sofie_identity";
88
89macro_rules! screen {
90    ($name:literal) => {
91        (
92            concat!("sofie_identity/web/", $name),
93            sofie::db::embed!(
94                concat!("src/screens/", $name, ".html"),
95                compiled = concat!("screens/sofie_identity/web/", $name, ".sft")
96            ),
97        )
98    };
99}
100
101const SCREENS: &[(&str, File)] = &[
102    screen!("sign_in"),
103    screen!("sign_up"),
104    #[cfg(feature = "links")]
105    screen!("link_sent"),
106    #[cfg(feature = "password-reset")]
107    screen!("forgot_password"),
108    #[cfg(feature = "password-reset")]
109    screen!("reset_password"),
110    #[cfg(feature = "email-verification")]
111    screen!("ask_verification"),
112    #[cfg(feature = "email-verification")]
113    screen!("verify_email"),
114    #[cfg(feature = "links")]
115    screen!("join"),
116    #[cfg(feature = "sign-in-links")]
117    screen!("ask_sign_in_link"),
118    #[cfg(feature = "sign-in-links")]
119    screen!("sign_in_link"),
120    #[cfg(feature = "totp")]
121    screen!("second_factor"),
122    #[cfg(feature = "oauth")]
123    screen!("oauth_return"),
124];
125
126pub const FORMS: &[Form] = &[
127    Form::of::<actions::SignInCredentials>("sofie_identity/web/sign_in", "sign-in"),
128    Form::of::<actions::Credentials>("sofie_identity/web/sign_up", "sign-up"),
129    #[cfg(feature = "links")]
130    Form::of::<actions::SignInCredentials>("sofie_identity/web/join", "join"),
131    #[cfg(feature = "password-reset")]
132    Form::of::<mailed::AskedLink>("sofie_identity/web/forgot_password", "forgot-password"),
133    #[cfg(feature = "password-reset")]
134    Form::of::<mailed::NewPassword>("sofie_identity/web/reset_password", "reset-password"),
135    #[cfg(feature = "email-verification")]
136    Form::of::<mailed::AskedLink>("sofie_identity/web/ask_verification", "ask-verification"),
137    #[cfg(feature = "sign-in-links")]
138    Form::of::<mailed::AskedLink>("sofie_identity/web/ask_sign_in_link", "ask-sign-in-link"),
139    #[cfg(feature = "totp")]
140    Form::of::<two_factor::FactorCode>("sofie_identity/web/second_factor", "second-factor"),
141];
142
143pub const TRANSLATIONS: &[File] = sofie::db::embed!("lang", ["de.toml", "en.toml", "tr.toml"]);
144
145/// Each file has a SQLite one of the same name, as `Migrations::sqlite` matches them by name.
146macro_rules! migrations {
147    ($($file:literal),+ $(,)?) => {
148        Migrations::new(NAME, sofie::db::embed!("migrations", [$($file),+]))
149            .sqlite(sofie::db::embed!("migrations/sqlite", [$($file),+]))
150    };
151}
152
153#[must_use]
154pub const fn module(members: &'static [Mount]) -> Module {
155    Module {
156        migrations: Some(migrations()),
157        screens: SCREENS,
158        forms: FORMS,
159        areas: members,
160        jobs: worker,
161        ..Module::new(NAME)
162    }
163}
164
165#[must_use]
166pub fn member<A: MemberArea>(app: App, module: &'static str) -> App {
167    app.membership(Memberships).area::<Web>(module, routes::<A>)
168}
169
170#[must_use]
171pub const fn migrations() -> Migrations {
172    migrations![
173        "0000_module_role.sql",
174        "0001_create_account.sql",
175        "0002_timestamps.sql",
176        "0003_roles.sql",
177        "0004_role_grant_index.sql",
178        "0005_access_tokens.sql",
179        "0006_account_links.sql",
180        "0007_account_sessions.sql",
181        "0008_sign_in_links.sql",
182        "0009_second_factor.sql",
183        "0010_passkeys.sql",
184        "0011_account_identities.sql",
185        "0012_impersonations.sql",
186        "0013_passkey_credential_live.sql",
187        "0014_account_session_principal.sql",
188        "0015_join_links.sql",
189        "0016_impersonation_granted.sql",
190        "0017_impersonation_started_from.sql",
191        "0018_impersonation_impersonator.sql",
192        "0019_account_link_expires.sql",
193        "0020_impersonator_granted.sql",
194        "0021_access_token_revealing.sql",
195        "0022_account_locale.sql",
196    ]
197    .aggregates(AGGREGATES)
198}
199
200const AGGREGATES: &[sofie::db::AggregateTable] = &[
201    sofie::db::AggregateTable::of::<Account>(),
202    sofie::db::AggregateTable::of::<membership::Membership>(),
203    #[cfg(feature = "account-links")]
204    sofie::db::AggregateTable::of::<account_link::AccountLink>(),
205    #[cfg(feature = "permissions")]
206    sofie::db::AggregateTable::of::<role::Role>(),
207    #[cfg(feature = "permissions")]
208    sofie::db::AggregateTable::of::<role_grant::RoleGrant>(),
209    #[cfg(feature = "tokens")]
210    sofie::db::AggregateTable::of::<tokens::AccessToken>(),
211    #[cfg(feature = "sessions")]
212    sofie::db::AggregateTable::of::<sessions::AccountSession>(),
213    #[cfg(feature = "totp")]
214    sofie::db::AggregateTable::of::<two_factor::SecondFactor>(),
215    #[cfg(feature = "passkeys")]
216    sofie::db::AggregateTable::of::<passkeys::Passkey>(),
217    #[cfg(feature = "oauth")]
218    sofie::db::AggregateTable::of::<oauth::AccountIdentity>(),
219    #[cfg(feature = "impersonation")]
220    sofie::db::AggregateTable::of::<impersonation::Impersonation>(),
221];
222
223/// An area whose principal is an account itself, not a record it acts for such as an
224/// organization, so its account screens act on that account.
225pub trait AccountArea: Area<Principal: AreaPrincipal<Record = Account>> {}
226
227impl<A: Area<Principal: AreaPrincipal<Record = Account>>> AccountArea for A {}
228
229pub trait MemberArea: AccountArea {
230    const HOME: &'static str;
231    /// Signing up there signs nobody in and answers alike for a new address and a taken one.
232    const SIGN_UP: Option<&'static str> = None;
233    /// Without it an unconfirmed account signs in, so signing in with the password one just
234    /// signed up with tells a new address from a taken one.
235    #[cfg(feature = "email-verification")]
236    const VERIFIED_EMAIL: bool = false;
237}
238
239pub trait SignUp: Area {
240    const PATH: &'static str;
241}
242
243/// Mounted in `Web`; sign-ins and sign-ups are rate-limited per address.
244pub fn routes<A: MemberArea>(routes: &mut Routes<Web>) {
245    routes
246        .screen(A::SIGN_IN, "sign_in", pages::sign_in_page::<A>)
247        .name_for::<A>("sign_in_page");
248    routes
249        .post(A::SIGN_IN, actions::sign_in::<A>)
250        .name_for::<A>("sign_in")
251        .throttle(Rate::per_minute(10))
252        .then_redirect_or_follow(A::HOME);
253    routes
254        .post(&format!("{}/sign-out", A::PREFIX), actions::sign_out)
255        .name_for::<A>("sign_out")
256        .then_redirect(A::SIGN_IN);
257    if let Some(sign_up) = A::SIGN_UP {
258        routes
259            .screen(sign_up, "sign_up", pages::sign_up_page::<A>)
260            .name_for::<A>("sign_up_page");
261        routes
262            .post(sign_up, actions::sign_up::<A>)
263            .name_for::<A>("sign_up")
264            .throttle(Rate::per_minute(10))
265            .then_redirect(&after_sign_up::<A>());
266    }
267    #[cfg(feature = "links")]
268    mailed::routes::<A>(routes);
269    #[cfg(feature = "totp")]
270    two_factor::challenge_routes::<A>(routes);
271    #[cfg(feature = "passkeys")]
272    passkeys::sign_in_routes::<A>(routes);
273    #[cfg(feature = "oauth")]
274    oauth::sign_in_routes::<A>(routes);
275}
276
277/// Where a sign-up lands, whether or not its address was free: told to check its mail when a
278/// link is on its way, else at signing in.
279fn after_sign_up<A: MemberArea>() -> String {
280    #[cfg(feature = "email-verification")]
281    return mailed::link_sent::<A>();
282    #[cfg(not(feature = "email-verification"))]
283    A::SIGN_IN.to_owned()
284}
285
286#[must_use]
287pub fn worker(worker: Worker) -> Worker {
288    #[cfg(feature = "links")]
289    let worker = worker.on::<mailed::LinkAsked, _>("sofie_identity.mail_link", mailed::mail_link);
290    #[cfg(feature = "sessions")]
291    let worker = worker.every("sofie_identity.prune_sessions", PRUNE_EVERY, prune);
292    #[cfg(all(feature = "account-links", not(feature = "sessions")))]
293    let worker = worker.every("sofie_identity.prune_account_links", PRUNE_EVERY, prune);
294    #[cfg(all(
295        feature = "token-screens",
296        not(any(feature = "sessions", feature = "account-links"))
297    ))]
298    let worker = worker.every("sofie_identity.prune_tokens", PRUNE_EVERY, prune);
299    worker
300}
301
302#[cfg(any(
303    feature = "sessions",
304    feature = "account-links",
305    feature = "token-screens"
306))]
307const PRUNE_EVERY: std::time::Duration = std::time::Duration::from_hours(1);
308
309/// One schedule whatever the features, so turning one on adds no job to an app's worker.
310#[cfg(any(
311    feature = "sessions",
312    feature = "account-links",
313    feature = "token-screens"
314))]
315async fn prune(
316    #[cfg(any(feature = "sessions", feature = "account-links"))] prune: sofie::db::Prune,
317    clock: sofie::prelude::Clock,
318    #[cfg(feature = "token-screens")] unscoped: Unscoped,
319) -> sofie::prelude::Result<()> {
320    #[cfg(feature = "sessions")]
321    sessions::prune(&prune, &clock).await?;
322    #[cfg(feature = "account-links")]
323    account_link::prune(&prune, &clock).await?;
324    #[cfg(feature = "token-screens")]
325    tokens::screen::forget_revealing(&unscoped, &clock).await?;
326    Ok(())
327}
328
329#[cfg(any(
330    feature = "session-screens",
331    feature = "token-screens",
332    feature = "totp",
333    feature = "passkeys",
334    feature = "oauth",
335    feature = "account-data",
336    feature = "impersonation"
337))]
338fn account_of<A: AccountArea>(principal: &sofie::http::Principal<A>) -> AccountId {
339    principal.id()
340}