@jpgilldev / steid

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