Skip to main content

sofie_identity/
lib.rs

1mod account;
2#[cfg(feature = "account-data")]
3pub mod account_data;
4#[cfg(feature = "account-links")]
5mod account_link;
6mod actions;
7#[cfg(feature = "impersonation")]
8pub mod impersonation;
9#[cfg(feature = "shield")]
10mod lock;
11#[cfg(feature = "links")]
12mod mailed;
13mod membership;
14#[cfg(any(feature = "shield", feature = "links"))]
15mod notice;
16#[cfg(feature = "oauth")]
17pub mod oauth;
18mod pages;
19#[cfg(feature = "passkeys")]
20pub mod passkeys;
21mod password;
22#[cfg(feature = "permissions")]
23mod permitted;
24#[cfg(feature = "permissions")]
25mod role;
26#[cfg(feature = "permissions")]
27mod role_grant;
28mod route_table;
29#[cfg(feature = "sessions")]
30pub mod sessions;
31mod signing_in;
32#[cfg(feature = "testing")]
33pub mod testing;
34#[cfg(feature = "tokens")]
35pub mod tokens;
36#[cfg(feature = "totp")]
37pub mod two_factor;
38
39use sofie::Module;
40use sofie::db::{File, Migrations};
41use sofie::http::{App, Area, AreaPrincipal, Form, Mount, Rate, Routes, Web};
42use sofie::worker::Worker;
43
44pub use account::Account;
45#[cfg(feature = "shield")]
46pub use lock::SignInLocked;
47pub use membership::{
48    AddingMember, MemberAdded, MembershipRemoved, Memberships, add_member, add_member_task,
49    remove_membership,
50};
51pub use password::{Password, PasswordCheck, PasswordHash};
52#[cfg(feature = "permissions")]
53pub use permitted::*;
54pub use route_table::ROUTES;
55pub use signing_in::{SignedIn, sign_in_for};
56
57pub type AccountId = sofie::db::Id<Account>;
58
59const NAME: &str = "sofie_identity";
60
61macro_rules! screen {
62    ($name:literal) => {
63        (
64            concat!("sofie_identity/web/", $name),
65            sofie::db::embed!(
66                concat!("src/screens/", $name, ".html"),
67                compiled = concat!("screens/sofie_identity/web/", $name, ".sft")
68            ),
69        )
70    };
71}
72
73const SCREENS: &[(&str, File)] = &[
74    screen!("sign_in"),
75    screen!("sign_up"),
76    #[cfg(feature = "links")]
77    screen!("link_sent"),
78    #[cfg(feature = "password-reset")]
79    screen!("forgot_password"),
80    #[cfg(feature = "password-reset")]
81    screen!("reset_password"),
82    #[cfg(feature = "email-verification")]
83    screen!("ask_verification"),
84    #[cfg(feature = "email-verification")]
85    screen!("verify_email"),
86    #[cfg(feature = "links")]
87    screen!("join"),
88    #[cfg(feature = "sign-in-links")]
89    screen!("ask_sign_in_link"),
90    #[cfg(feature = "sign-in-links")]
91    screen!("sign_in_link"),
92    #[cfg(feature = "totp")]
93    screen!("second_factor"),
94    #[cfg(feature = "oauth")]
95    screen!("oauth_return"),
96];
97
98pub const FORMS: &[Form] = &[
99    Form::of::<actions::SignInCredentials>("sofie_identity/web/sign_in", "sign-in"),
100    Form::of::<actions::Credentials>("sofie_identity/web/sign_up", "sign-up"),
101    #[cfg(feature = "links")]
102    Form::of::<actions::SignInCredentials>("sofie_identity/web/join", "join"),
103    #[cfg(feature = "password-reset")]
104    Form::of::<mailed::AskedLink>("sofie_identity/web/forgot_password", "forgot-password"),
105    #[cfg(feature = "password-reset")]
106    Form::of::<mailed::NewPassword>("sofie_identity/web/reset_password", "reset-password"),
107    #[cfg(feature = "email-verification")]
108    Form::of::<mailed::AskedLink>("sofie_identity/web/ask_verification", "ask-verification"),
109    #[cfg(feature = "sign-in-links")]
110    Form::of::<mailed::AskedLink>("sofie_identity/web/ask_sign_in_link", "ask-sign-in-link"),
111    #[cfg(feature = "totp")]
112    Form::of::<two_factor::FactorCode>("sofie_identity/web/second_factor", "second-factor"),
113];
114
115pub const TRANSLATIONS: &[File] = sofie::db::embed!("lang", ["de.toml", "en.toml", "tr.toml"]);
116
117/// Each file has a SQLite one of the same name, as `Migrations::sqlite` matches them by name.
118macro_rules! migrations {
119    ($($file:literal),+ $(,)?) => {
120        Migrations::new(NAME, sofie::db::embed!("migrations", [$($file),+]))
121            .sqlite(sofie::db::embed!("migrations/sqlite", [$($file),+]))
122    };
123}
124
125#[must_use]
126pub const fn module(members: &'static [Mount]) -> Module {
127    Module {
128        migrations: Some(migrations()),
129        screens: SCREENS,
130        forms: FORMS,
131        areas: members,
132        jobs: worker,
133        ..Module::new(NAME)
134    }
135}
136
137#[must_use]
138pub fn member<A: MemberArea>(app: App, module: &'static str) -> App {
139    app.membership(Memberships).area::<Web>(module, routes::<A>)
140}
141
142#[must_use]
143pub const fn migrations() -> Migrations {
144    migrations![
145        "0000_module_role.sql",
146        "0001_create_account.sql",
147        "0002_timestamps.sql",
148        "0003_roles.sql",
149        "0004_role_grant_index.sql",
150        "0005_access_tokens.sql",
151        "0006_account_links.sql",
152        "0007_account_sessions.sql",
153        "0008_sign_in_links.sql",
154        "0009_second_factor.sql",
155        "0010_passkeys.sql",
156        "0011_account_identities.sql",
157        "0012_impersonations.sql",
158        "0013_passkey_credential_live.sql",
159        "0014_account_session_principal.sql",
160        "0015_join_links.sql",
161        "0016_impersonation_granted.sql",
162    ]
163    .aggregates(AGGREGATES)
164}
165
166const AGGREGATES: &[sofie::db::AggregateTable] = &[
167    sofie::db::AggregateTable::of::<Account>(),
168    sofie::db::AggregateTable::of::<membership::Membership>(),
169    #[cfg(feature = "account-links")]
170    sofie::db::AggregateTable::of::<account_link::AccountLink>(),
171    #[cfg(feature = "permissions")]
172    sofie::db::AggregateTable::of::<role::Role>(),
173    #[cfg(feature = "permissions")]
174    sofie::db::AggregateTable::of::<role_grant::RoleGrant>(),
175    #[cfg(feature = "tokens")]
176    sofie::db::AggregateTable::of::<tokens::AccessToken>(),
177    #[cfg(feature = "sessions")]
178    sofie::db::AggregateTable::of::<sessions::AccountSession>(),
179    #[cfg(feature = "totp")]
180    sofie::db::AggregateTable::of::<two_factor::SecondFactor>(),
181    #[cfg(feature = "passkeys")]
182    sofie::db::AggregateTable::of::<passkeys::Passkey>(),
183    #[cfg(feature = "oauth")]
184    sofie::db::AggregateTable::of::<oauth::AccountIdentity>(),
185    #[cfg(feature = "impersonation")]
186    sofie::db::AggregateTable::of::<impersonation::Impersonation>(),
187];
188
189pub trait MemberArea: Area<Principal: AreaPrincipal<Record = Account>> {
190    const HOME: &'static str;
191    /// Signing up there signs nobody in and answers alike for a new address and a taken one.
192    const SIGN_UP: Option<&'static str> = None;
193    /// Without it an unconfirmed account signs in, so signing in with the password one just
194    /// signed up with tells a new address from a taken one.
195    #[cfg(feature = "email-verification")]
196    const VERIFIED_EMAIL: bool = false;
197}
198
199pub trait SignUp: Area {
200    const PATH: &'static str;
201}
202
203/// Mounted in `Web`; sign-ins and sign-ups are rate-limited per address.
204pub fn routes<A: MemberArea>(routes: &mut Routes<Web>) {
205    routes
206        .screen(A::SIGN_IN, "sign_in", pages::sign_in_page::<A>)
207        .name_for::<A>("sign_in_page");
208    routes
209        .post(A::SIGN_IN, actions::sign_in::<A>)
210        .name_for::<A>("sign_in")
211        .throttle(Rate::per_minute(10))
212        .then_redirect_or_follow(A::HOME);
213    routes
214        .post(&format!("{}/sign-out", A::PREFIX), actions::sign_out)
215        .name_for::<A>("sign_out")
216        .then_redirect(A::SIGN_IN);
217    if let Some(sign_up) = A::SIGN_UP {
218        routes
219            .screen(sign_up, "sign_up", pages::sign_up_page::<A>)
220            .name_for::<A>("sign_up_page");
221        routes
222            .post(sign_up, actions::sign_up::<A>)
223            .name_for::<A>("sign_up")
224            .throttle(Rate::per_minute(10))
225            .then_redirect(&after_sign_up::<A>());
226    }
227    #[cfg(feature = "links")]
228    mailed::routes::<A>(routes);
229    #[cfg(feature = "totp")]
230    two_factor::challenge_routes::<A>(routes);
231    #[cfg(feature = "passkeys")]
232    passkeys::sign_in_routes::<A>(routes);
233    #[cfg(feature = "oauth")]
234    oauth::sign_in_routes::<A>(routes);
235}
236
237/// Where a sign-up lands, whether or not its address was free: told to check its mail when a
238/// link is on its way, else at signing in.
239fn after_sign_up<A: MemberArea>() -> String {
240    #[cfg(feature = "email-verification")]
241    return mailed::link_sent::<A>();
242    #[cfg(not(feature = "email-verification"))]
243    A::SIGN_IN.to_owned()
244}
245
246#[must_use]
247pub fn worker(worker: Worker) -> Worker {
248    #[cfg(feature = "links")]
249    let worker = worker.on::<mailed::LinkAsked, _>("sofie_identity.mail_link", mailed::mail_link);
250    #[cfg(feature = "sessions")]
251    let worker = worker.every(
252        "sofie_identity.prune_sessions",
253        sessions::PRUNE_EVERY,
254        sessions::prune,
255    );
256    worker
257}
258
259#[cfg(any(
260    feature = "session-screens",
261    feature = "token-screens",
262    feature = "totp",
263    feature = "passkeys",
264    feature = "oauth",
265    feature = "account-data",
266    feature = "impersonation"
267))]
268fn account_of<A: Area>(principal: &sofie::http::Principal<A>) -> AccountId {
269    AccountId::from_uuid(principal.id().uuid())
270}