34beb1a7 Andy McCall 1 month ago ↶
1
2
3
use super :: { Error, Page, Shell} ;
4
use crate :: auth:: { self , Viewer} ;
5
use crate :: db:: { self , accounts} ;
6
use crate :: Shared;
7
use askama:: Template;
8
use axum:: extract:: { Form, Path, State} ;
9
use axum:: response:: { IntoResponse, Redirect, Response} ;
10
use serde:: Deserialize;
11
12
const MINUTE : i64 = 60_000 ;
13
pub const VERIFY_TTL_MS : i64 = 48 * 60 * MINUTE ;
14
pub const RESET_TTL_MS : i64 = 60 * MINUTE ;
15
pub const ADMIN_RESET_TTL_MS : i64 = 24 * 60 * MINUTE ;
16
17
pub const MAIL_EVERY_MS : i64 = 2 * MINUTE ;
18
19
pub const VERIFY_PER_HOUR : i64 = 10 ;
20
21
# [ derive ( Template ) ]
22
# [ template ( path = " reset.html" ) ]
23
pub struct RecoveryPage {
24
shell : Shell,
25
26
mode : & 'static str ,
27
mail : bool ,
28
error : Option< String> ,
29
username : String,
30
secret : String,
31
}
32
33
impl RecoveryPage {
34
fn new ( app : & Shared, shell : Shell, mode : & 'static str ) -> RecoveryPage {
35
RecoveryPage { shell, mode, mail: crate :: mail:: enabled( app) , error: None , username: String :: new( ) , secret: String :: new( ) }
36
}
37
}
38
39
40
pub async fn send_verification ( app : & Shared, account : & accounts:: Account) -> Result< ( ) , String> {
41
if account.email.is_empty ( ) | | account.email_verified {
42
return Ok ( ( ) ) ;
43
}
44
if ! crate :: mail:: enabled( app) {
45
return Err ( " This hub cannot send mail, so addresses cannot be verified yet." .into ( ) ) ;
46
}
47
let now = db:: now_ms( ) ;
48
let recent = accounts:: last_link_ms( & app.db, account.id, " verify" , Some ( & account.email) ) .await.map_err ( | e | e.to_string ( ) ) ? ;
49
if recent.is_some_and ( | t | now - t < MAIL_EVERY_MS ) {
50
return Err ( " A verification link was sent less than two minutes ago." .into ( ) ) ;
51
}
52
if accounts:: links_since( & app.db, account.id, " verify" , now - 60 * MINUTE ) .await.map_err ( | e | e.to_string ( ) ) ? > = VERIFY_PER_HOUR {
53
return Err ( " Too many verification links this hour; try again later." .into ( ) ) ;
54
}
55
let secret =
56
accounts:: create_link( & app.db, account.id, " verify" , & account.email, None , VERIFY_TTL_MS ) .await.map_err ( | e | e.to_string ( ) ) ? ;
57
let url = format! ( " {} /verify/{secret} " , app.config.base_url ( ) ) ;
58
let body = format! (
59
" Confirm that {} is the email address of {} on {} :\n \n {url} \n \n The link works for 48 hours. If you did not ask for this, ignore it.\n " ,
60
account.email,
61
account.username,
62
app.config.site.name
63
) ;
cd46e56b Andy McCall 22 days ago ↶
64
let action = crate :: mail:: Action { label: " Confirm my email address" , url: & url } ;
b91e2d81 Andy McCall 22 days ago ↶
65
crate :: mail:: send_letter( app, account.name ( ) , & account.email, " ✉️ Confirm your email address" , & body, Some ( action) , None ) .await
34beb1a7 Andy McCall 1 month ago ↶
66
}
67
68
69
pub async fn verify ( State( app ) : State< Shared> , viewer : Viewer, Path( secret ) : Path< String> ) -> Result< Page< RecoveryPage> , Error> {
70
let shell = Shell:: new( & app, " /verify" , & viewer) ;
71
let e = | e | Error:: from_db( shell.clone ( ) , e) ;
72
let link = accounts:: live_link( & app.db, " verify" , & secret) .await.map_err ( e) ? ;
73
let account = match & link {
74
Some ( l) => accounts:: account_by_id( & app.db, l.account_id) .await.map_err ( e) ? ,
75
None => None ,
76
} ;
77
match ( link, account) {
78
79
( Some ( l) , Some ( a) ) if a.email = = l.email & & accounts:: use_link( & app.db, l.id) .await.map_err ( e) ? => {
80
accounts:: set_email_verified( & app.db, a.id, true ) .await.map_err ( e) ? ;
81
accounts:: audit( & app.db, Some ( & a) , " email.verified" , & a.username, & a.email) .await.ok ( ) ;
82
let mut page = RecoveryPage:: new( & app, shell, " verified" ) ;
83
page.username = a.email;
84
Ok ( Page( page) )
85
}
86
_ => Ok ( Page( RecoveryPage:: new( & app, shell, " verify-invalid" ) ) ) ,
87
}
88
}
89
90
91
pub async fn request_form ( State( app ) : State< Shared> , viewer : Viewer) -> Page< RecoveryPage> {
92
Page( RecoveryPage:: new( & app, Shell:: new( & app, " /login/reset" , & viewer) , " request" ) )
93
}
94
95
# [ derive ( Deserialize ) ]
96
pub struct RequestForm {
97
who : String,
98
}
99
100
aa0bfbd1 Andy McCall 20 days ago ↶
101
pub async fn request (
102
State( app ) : State< Shared> ,
103
viewer : Viewer,
104
headers : axum:: http:: HeaderMap,
105
extensions : axum:: http:: Extensions,
106
Form( f ) : Form< RequestForm> ,
107
) -> Result< Page< RecoveryPage> , Error> {
34beb1a7 Andy McCall 1 month ago ↶
108
let shell = Shell:: new( & app, " /login/reset" , & viewer) ;
109
if ! crate :: mail:: enabled( & app) {
110
return Ok ( Page( RecoveryPage:: new( & app, shell, " request" ) ) ) ;
aa0bfbd1 Andy McCall 20 days ago ↶
111
}
112
113
114
let addr = crate :: api:: client_addr( & app, & headers, & extensions) ;
115
let keys = [ crate :: throttle:: Key:: address( " reset" , & addr) ] ;
116
let now = db:: now_ms( ) ;
117
let waiting = app.throttle.lock ( ) .unwrap ( ) .wait ( & keys, now) ;
118
if let Some ( wait) = waiting {
119
let mut page = RecoveryPage:: new( & app, shell, " request" ) ;
120
page.error = Some ( crate :: throttle:: message( wait) ) ;
121
return Ok ( Page( page) ) ;
122
}
123
let started = app.throttle.lock ( ) .unwrap ( ) .failed ( & keys, now) ;
124
if let Some ( wait) = started {
125
accounts:: audit( & app.db, None , " password.reset_throttled" , & addr, & format! ( " for {} s" , wait / 1000 ) ) .await.ok ( ) ;
34beb1a7 Andy McCall 1 month ago ↶
126
}
127
let who = f.who.trim ( ) ;
128
let e = | e | Error:: from_db( shell.clone ( ) , e) ;
129
let account = if who.contains ( ' @' ) {
130
accounts:: account_by_verified_email( & app.db, who) .await.map_err ( e) ?
131
} else {
132
accounts:: account_by_name( & app.db, who) .await.map_err ( e) ?
133
} ;
6a178055 Andy McCall 1 month ago ↶
134
if let Some ( a) = account.filter ( | a | a.email_verified & & ! a.email.is_empty ( ) & & ! a.blocked ( ) ) {
34beb1a7 Andy McCall 1 month ago ↶
135
let recent = accounts:: last_link_ms( & app.db, a.id, " reset" , None ) .await.map_err ( e) ? ;
136
if ! recent.is_some_and ( | t | db:: now_ms( ) - t < MAIL_EVERY_MS ) {
137
let secret = accounts:: create_link( & app.db, a.id, " reset" , " " , None , RESET_TTL_MS ) .await.map_err ( e) ? ;
138
let url = format! ( " {} /login/reset/{secret} " , app.config.base_url ( ) ) ;
139
let body = format! (
140
" Someone asked to reset the password of {} on {} . To choose a new password, open:\n \n {url} \n \n The link works once, for one hour. If it was not you, ignore this; your password stays as it is.\n " ,
141
a.username, app.config.site.name
142
) ;
143
144
let ( mailer, name, addr, user) = ( app.clone ( ) , a.name ( ) .to_string ( ) , a.email.clone ( ) , a.username.clone ( ) ) ;
145
tokio:: spawn( async move {
cd46e56b Andy McCall 22 days ago ↶
146
let action = crate :: mail:: Action { label: " Choose a new password" , url: & url } ;
2bbdc026 Andy McCall 22 days ago ↶
147
if let Err ( err) = crate :: mail:: send_letter( & mailer, & name, & addr, " 🔑 Reset your password" , & body, Some ( action) , None ) .await
148
{
34beb1a7 Andy McCall 1 month ago ↶
149
tracing:: warn! ( " reset mail for {user}: {err}" ) ;
150
}
151
} ) ;
152
accounts:: audit( & app.db, Some ( & a) , " password.reset_requested" , & a.username, " " ) .await.ok ( ) ;
153
}
154
}
155
Ok ( Page( RecoveryPage:: new( & app, shell, " sent" ) ) )
156
}
157
158
async fn reset_target ( app : & Shared, secret : & str ) -> sqlx:: Result< Option< ( accounts:: AccountLink, accounts:: Account) > > {
159
let Some ( link) = accounts:: live_link( & app.db, " reset" , secret) .await? else { return Ok ( None ) } ;
160
let account = accounts:: account_by_id( & app.db, link.account_id) .await? ;
6a178055 Andy McCall 1 month ago ↶
161
Ok ( account.filter ( | a | ! a.blocked ( ) ) .map ( | a | ( link, a) ) )
34beb1a7 Andy McCall 1 month ago ↶
162
}
163
164
165
pub async fn new_password_form ( State( app ) : State< Shared> , viewer : Viewer, Path( secret ) : Path< String> ) -> Result< Page< RecoveryPage> , Error> {
166
let shell = Shell:: new( & app, " /login/reset" , & viewer) ;
167
match reset_target ( & app, & secret) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? {
168
Some ( ( _ , a) ) => {
169
let mut page = RecoveryPage:: new( & app, shell, " new" ) ;
170
page.username = a.username;
171
page.secret = secret;
172
Ok ( Page( page) )
173
}
174
None => Ok ( Page( RecoveryPage:: new( & app, shell, " invalid" ) ) ) ,
175
}
176
}
177
178
# [ derive ( Deserialize ) ]
179
pub struct NewPasswordForm {
180
password : String,
181
confirm : String,
182
}
183
184
185
pub async fn set_new_password (
186
State( app ) : State< Shared> ,
187
viewer : Viewer,
188
Path( secret ) : Path< String> ,
189
Form( f ) : Form< NewPasswordForm> ,
190
) -> Result< Response, Error> {
191
let shell = Shell:: new( & app, " /login/reset" , & viewer) ;
192
let e = | e | Error:: from_db( shell.clone ( ) , e) ;
193
let Some ( ( link, account) ) = reset_target ( & app, & secret) .await.map_err ( e) ? else {
194
return Ok ( Page( RecoveryPage:: new( & app, shell, " invalid" ) ) .into_response ( ) ) ;
195
} ;
196
let problem = if f.password ! = f.confirm {
197
Some ( " The two passwords differ." .to_string ( ) )
198
} else {
199
auth:: password_ok( & f.password) .err ( ) .map ( String :: from)
200
} ;
201
if let Some ( msg) = problem {
202
let mut page = RecoveryPage:: new( & app, shell, " new" ) ;
203
page.username = account.username;
204
page.secret = secret;
205
page.error = Some ( msg) ;
206
return Ok ( Page( page) .into_response ( ) ) ;
207
}
208
if ! accounts:: use_link( & app.db, link.id) .await.map_err ( e) ? {
209
return Ok ( Page( RecoveryPage:: new( & app, shell, " invalid" ) ) .into_response ( ) ) ;
210
}
211
let hash = auth:: hash_password( & f.password) .map_err ( | err | Error:: bad( shell.clone ( ) , err.to_string ( ) ) ) ? ;
212
213
accounts:: set_password( & app.db, account.id, & hash, & auth:: random_hex( 16 ) ) .await.map_err ( e) ? ;
214
accounts:: void_links( & app.db, account.id, " reset" ) .await.map_err ( e) ? ;
215
let by = if link.created_by.is_some ( ) { " administrator's link" } else { " emailed link" } ;
216
accounts:: audit( & app.db, Some ( & account) , " password.reset" , & account.username, by) .await.ok ( ) ;
217
Ok ( Redirect:: to( " /login?reset=1" ) .into_response ( ) )
218
}
219
220
221
pub async fn admin_reset_link ( app : & Shared, admin : & accounts:: Account, target : & accounts:: Account) -> sqlx:: Result< String> {
222
let secret = accounts:: create_link( & app.db, target.id, " reset" , " " , Some ( admin.id) , ADMIN_RESET_TTL_MS ) .await? ;
223
accounts:: audit( & app.db, Some ( admin) , " admin.account.reset_link" , & target.username, " valid 24 hours" ) .await.ok ( ) ;
224
Ok ( format! ( " {} /login/reset/{secret} " , app.config.base_url ( ) ) )
225
}