steid

@jamesgill /

14223e7feat: claim, sign in, and sign out through the browser1mo
1//! First-run claim and sign-in.
2
3use serde::Deserialize;
4use topcoat::{
5 Result,
6 context::Cx,
7 router::{
3dd7293feat: ship Steid as an installable binary8d
8 IntoResponse, Response,
14223e7feat: claim, sign in, and sign out through the browser1mo
9 content::Form,
10 error::{SeeOther, redirect, see_other},
11 page, route,
12 },
13 session,
14 view::view,
15};
16
17use crate::{
18 application::{OwnerSpec, claim_instance, end_session, login, record_session},
19 domain::Actor,
20 infrastructure::{
21 password::Argon2Hasher,
22 repository::{SqliteMembershipRepo, SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo},
23 },
24};
25
3dd7293feat: ship Steid as an installable binary8d
26use super::{
27 context::{claimed, hex, pool, setup_token},
5d3dfa5feat: a global top bar, and pages choose their own width1d
28 layout::narrow,
3dd7293feat: ship Steid as an installable binary8d
29 rate_limit::throttled,
30};
14223e7feat: claim, sign in, and sign out through the browser1mo
31
32#[derive(Debug, Deserialize)]
33struct ClaimForm {
34 token: String,
35 handle: String,
36 email: String,
37 password: String,
38}
39
40#[derive(Debug, Deserialize)]
41struct LoginForm {
42 email: String,
43 password: String,
44}
45
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
46#[page("/auth/setup")]
14223e7feat: claim, sign in, and sign out through the browser1mo
47async fn setup_page(cx: &Cx) -> Result {
48 if claimed(cx).await? {
49 return Err(redirect("/").into());
50 }
51
52 view! {
5d3dfa5feat: a global top bar, and pages choose their own width1d
53 narrow(
54 <h1>"Claim this instance"</h1>
55 <p>"The setup token was printed to the server log at startup."</p>
56 <form method="post" action="/auth/setup">
57 <p><label>"Setup token " <input type="text" name="token" required="true" /></label></p>
58 <p><label>"Handle " <input type="text" name="handle" required="true" /></label></p>
59 <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
60 <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
61 <button type="submit">"Claim"</button>
62 </form>
63 )
14223e7feat: claim, sign in, and sign out through the browser1mo
64 }
65}
66
3dd7293feat: ship Steid as an installable binary8d
67/// Claims the instance.
68///
69/// Rate limited before anything else happens: the setup token is the only thing
70/// standing between a stranger and ownership of an unclaimed instance, and unlike a
71/// personal access token it may have been chosen by a person.
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
72#[route(POST "/auth/setup")]
3dd7293feat: ship Steid as an installable binary8d
73async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<Response> {
74 if let Some(response) = throttled(cx) {
75 return Ok(response);
76 }
77
14223e7feat: claim, sign in, and sign out through the browser1mo
78 // Absent once claimed, so a claimed instance cannot be re-claimed even if the
79 // use case were somehow reached.
80 let token = setup_token(cx).ok_or_else(|| redirect("/"))?;
81
82 let pool = pool(cx).clone();
83 let users = SqliteUserRepo::new(pool.clone());
84 let orgs = SqliteOrgRepo::new(pool.clone());
85 let memberships = SqliteMembershipRepo::new(pool.clone());
86 let sessions = SqliteSessionRepo::new(pool);
87
88 let actor = claim_instance(
89 &form.token,
90 token,
91 &OwnerSpec {
92 handle: form.handle,
93 email: form.email,
94 password: form.password,
95 },
96 &users,
97 &orgs,
98 &memberships,
99 &Argon2Hasher::new(),
100 )
101 .await
102 .map_err(|error| {
103 eprintln!("steid: claim rejected: {error}");
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
104 redirect("/auth/setup")
14223e7feat: claim, sign in, and sign out through the browser1mo
105 })?;
106
107 sign_in(cx, &actor, &sessions).await?;
108
3dd7293feat: ship Steid as an installable binary8d
109 see_other("/").into_response(cx)
14223e7feat: claim, sign in, and sign out through the browser1mo
110}
111
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
112#[page("/auth/login")]
14223e7feat: claim, sign in, and sign out through the browser1mo
113async fn login_page(cx: &Cx) -> Result {
114 if !claimed(cx).await? {
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
115 return Err(redirect("/auth/setup").into());
14223e7feat: claim, sign in, and sign out through the browser1mo
116 }
117
118 view! {
5d3dfa5feat: a global top bar, and pages choose their own width1d
119 narrow(
14223e7feat: claim, sign in, and sign out through the browser1mo
120 <h1>"Sign in"</h1>
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
121 <form method="post" action="/auth/login">
14223e7feat: claim, sign in, and sign out through the browser1mo
122 <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
123 <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
124 <button type="submit">"Sign in"</button>
125 </form>
5d3dfa5feat: a global top bar, and pages choose their own width1d
126 )
14223e7feat: claim, sign in, and sign out through the browser1mo
127 }
128}
129
3dd7293feat: ship Steid as an installable binary8d
130/// Signs in.
131///
132/// Rate limited first, before the password is hashed: a human-chosen password is the
133/// one secret here that guessing can reach, and Argon2 is deliberately expensive
134/// enough that an unlimited endpoint is also a way to burn the server's CPU.
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
135#[route(POST "/auth/login")]
3dd7293feat: ship Steid as an installable binary8d
136async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<Response> {
137 if let Some(response) = throttled(cx) {
138 return Ok(response);
139 }
140
14223e7feat: claim, sign in, and sign out through the browser1mo
141 let pool = pool(cx).clone();
142 let users = SqliteUserRepo::new(pool.clone());
143 let sessions = SqliteSessionRepo::new(pool);
144
145 let actor = login(&form.email, &form.password, &users, &Argon2Hasher::new())
146 .await
147 .map_err(|error| {
148 eprintln!("steid: login rejected: {error}");
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
149 redirect("/auth/login")
14223e7feat: claim, sign in, and sign out through the browser1mo
150 })?;
151
152 sign_in(cx, &actor, &sessions).await?;
153
3dd7293feat: ship Steid as an installable binary8d
154 see_other("/").into_response(cx)
14223e7feat: claim, sign in, and sign out through the browser1mo
155}
156
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
157#[route(POST "/auth/logout")]
14223e7feat: claim, sign in, and sign out through the browser1mo
158async fn logout(cx: &Cx) -> Result<SeeOther> {
159 // Both halves: the client discards its token, and the record goes. Doing only the
160 // first leaves the session valid server-side.
161 if let Some(hash) = session::stop(cx).await? {
162 let sessions = SqliteSessionRepo::new(pool(cx).clone());
163 end_session(
164 &crate::domain::SessionTokenHash::from_trusted(hex(&hash)),
165 &sessions,
166 )
167 .await
168 .map_err(topcoat::Error::from)?;
169 }
170
171 Ok(see_other("/"))
172}
173
174/// Issues a session for an authenticated actor and records it.
175async fn sign_in(cx: &Cx, actor: &Actor, sessions: &SqliteSessionRepo) -> Result<()> {
176 let Some(user_id) = actor.user_id() else {
177 return Ok(());
178 };
179
180 let session = session::start(cx).await?;
181
182 record_session(
183 crate::domain::SessionTokenHash::from_trusted(hex(&session.token_hash)),
184 user_id.clone(),
185 session.expires_at,
186 sessions,
187 )
188 .await
189 .map_err(topcoat::Error::from)?;
190
191 Ok(())
192}