Skip to content

Commit b77b774

Browse files
committed
feat: token creation notification email
When a new token is created for an account, send a notification email to the account owner.
1 parent e80b035 commit b77b774

File tree

2 files changed

+60
-9
lines changed

2 files changed

+60
-9
lines changed

src/controllers/token.rs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use super::frontend_prelude::*;
22

33
use crate::models::ApiToken;
4-
use crate::schema::api_tokens;
54
use crate::util::rfc3339;
65
use crate::views::EncodableApiTokenWithToken;
6+
use crate::{models::Email, schema::api_tokens};
77

88
use crate::auth::AuthCheck;
99
use crate::models::token::{CrateScope, EndpointScope};
@@ -130,6 +130,8 @@ pub async fn new(app: AppState, req: BytesRequest) -> AppResult<Json<Value>> {
130130
.transpose()
131131
.map_err(|_err| bad_request("invalid endpoint scope"))?;
132132

133+
let recipient = user.email(conn)?;
134+
133135
let api_token = ApiToken::insert_with_scopes(
134136
conn,
135137
user.id,
@@ -138,9 +140,26 @@ pub async fn new(app: AppState, req: BytesRequest) -> AppResult<Json<Value>> {
138140
endpoint_scopes,
139141
new.api_token.expired_at,
140142
)?;
141-
let api_token = EncodableApiTokenWithToken::from(api_token);
142143

143-
Ok(Json(json!({ "api_token": api_token })))
144+
if let Some(recipient) = recipient {
145+
// At this point the token has been created so failing to send the
146+
// email should not cause an error response to be returned to the
147+
// caller.
148+
let email_ret = app.emails.send(
149+
&recipient,
150+
NewTokenEmail {
151+
user_name: &user.gh_login,
152+
domain: &app.emails.domain,
153+
},
154+
);
155+
if let Err(e) = email_ret {
156+
error!("Failed to send token creation email: {e}")
157+
}
158+
}
159+
160+
Ok(Json(
161+
json!({ "api_token": EncodableApiTokenWithToken::from(api_token) }),
162+
))
144163
})
145164
.await
146165
}
@@ -199,3 +218,25 @@ pub async fn revoke_current(app: AppState, req: Parts) -> AppResult<Response> {
199218
})
200219
.await
201220
}
221+
222+
struct NewTokenEmail<'a> {
223+
user_name: &'a str,
224+
domain: &'a str,
225+
}
226+
227+
impl<'a> crate::email::Email for NewTokenEmail<'a> {
228+
const SUBJECT: &'static str = "New API token created";
229+
230+
fn body(&self) -> String {
231+
format!(
232+
"\
233+
Hello {user_name}!
234+
235+
A new API token was recently added to your crates.io account.
236+
237+
If this wasn't you, you should revoke the token immediately: https://{domain}/settings/tokens",
238+
user_name = self.user_name,
239+
domain = self.domain,
240+
)
241+
}
242+
}

src/tests/routes/me/tokens/create.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,28 @@ async fn create_token_logged_out() {
1919

2020
#[tokio::test(flavor = "multi_thread")]
2121
async fn create_token_invalid_request() {
22-
let (_, _, user) = TestApp::init().with_user();
22+
let (app, _, user) = TestApp::init().with_user();
2323
let invalid: &[u8] = br#"{ "name": "" }"#;
2424
let response = user.put::<()>("/api/v1/me/tokens", invalid).await;
2525
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2626
assert_eq!(
2727
response.json(),
2828
json!({ "errors": [{ "detail": "invalid new token request: Error(\"missing field `api_token`\", line: 1, column: 14)" }] })
2929
);
30+
assert!(app.as_inner().emails.mails_in_memory().unwrap().is_empty());
3031
}
3132

3233
#[tokio::test(flavor = "multi_thread")]
3334
async fn create_token_no_name() {
34-
let (_, _, user) = TestApp::init().with_user();
35+
let (app, _, user) = TestApp::init().with_user();
3536
let empty_name: &[u8] = br#"{ "api_token": { "name": "" } }"#;
3637
let response = user.put::<()>("/api/v1/me/tokens", empty_name).await;
3738
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3839
assert_eq!(
3940
response.json(),
4041
json!({ "errors": [{ "detail": "name must have a value" }] })
4142
);
43+
assert!(app.as_inner().emails.mails_in_memory().unwrap().is_empty());
4244
}
4345

4446
#[tokio::test(flavor = "multi_thread")]
@@ -56,6 +58,7 @@ async fn create_token_exceeded_tokens_per_user() {
5658
response.json(),
5759
json!({ "errors": [{ "detail": "maximum tokens per user is: 500" }] })
5860
);
61+
assert!(app.as_inner().emails.mails_in_memory().unwrap().is_empty());
5962
}
6063

6164
#[tokio::test(flavor = "multi_thread")]
@@ -82,6 +85,7 @@ async fn create_token_success() {
8285
assert_eq!(tokens[0].last_used_at, None);
8386
assert_eq!(tokens[0].crate_scopes, None);
8487
assert_eq!(tokens[0].endpoint_scopes, None);
88+
assert_eq!(app.as_inner().emails.mails_in_memory().unwrap().len(), 1);
8589
}
8690

8791
#[tokio::test(flavor = "multi_thread")]
@@ -107,7 +111,7 @@ async fn create_token_multiple_users_have_different_values() {
107111

108112
#[tokio::test(flavor = "multi_thread")]
109113
async fn cannot_create_token_with_token() {
110-
let (_, _, _, token) = TestApp::init().with_token();
114+
let (app, _, _, token) = TestApp::init().with_token();
111115
let response = token
112116
.put::<()>(
113117
"/api/v1/me/tokens",
@@ -119,6 +123,7 @@ async fn cannot_create_token_with_token() {
119123
response.json(),
120124
json!({ "errors": [{ "detail": "cannot use an API token to create a new API token" }] })
121125
);
126+
assert!(app.as_inner().emails.mails_in_memory().unwrap().is_empty());
122127
}
123128

124129
#[tokio::test(flavor = "multi_thread")]
@@ -164,6 +169,7 @@ async fn create_token_with_scopes() {
164169
tokens[0].endpoint_scopes,
165170
Some(vec![EndpointScope::PublishUpdate])
166171
);
172+
assert_eq!(app.as_inner().emails.mails_in_memory().unwrap().len(), 1);
167173
}
168174

169175
#[tokio::test(flavor = "multi_thread")]
@@ -200,11 +206,12 @@ async fn create_token_with_null_scopes() {
200206
assert_eq!(tokens[0].last_used_at, None);
201207
assert_eq!(tokens[0].crate_scopes, None);
202208
assert_eq!(tokens[0].endpoint_scopes, None);
209+
assert_eq!(app.as_inner().emails.mails_in_memory().unwrap().len(), 1);
203210
}
204211

205212
#[tokio::test(flavor = "multi_thread")]
206213
async fn create_token_with_empty_crate_scope() {
207-
let (_, _, user) = TestApp::init().with_user();
214+
let (app, _, user) = TestApp::init().with_user();
208215

209216
let json = json!({
210217
"api_token": {
@@ -222,11 +229,12 @@ async fn create_token_with_empty_crate_scope() {
222229
response.json(),
223230
json!({ "errors": [{ "detail": "invalid crate scope" }] })
224231
);
232+
assert!(app.as_inner().emails.mails_in_memory().unwrap().is_empty());
225233
}
226234

227235
#[tokio::test(flavor = "multi_thread")]
228236
async fn create_token_with_invalid_endpoint_scope() {
229-
let (_, _, user) = TestApp::init().with_user();
237+
let (app, _, user) = TestApp::init().with_user();
230238

231239
let json = json!({
232240
"api_token": {
@@ -244,11 +252,12 @@ async fn create_token_with_invalid_endpoint_scope() {
244252
response.json(),
245253
json!({ "errors": [{ "detail": "invalid endpoint scope" }] })
246254
);
255+
assert!(app.as_inner().emails.mails_in_memory().unwrap().is_empty());
247256
}
248257

249258
#[tokio::test(flavor = "multi_thread")]
250259
async fn create_token_with_expiry_date() {
251-
let (_app, _, user) = TestApp::init().with_user();
260+
let (app, _, user) = TestApp::init().with_user();
252261

253262
let json = json!({
254263
"api_token": {
@@ -269,4 +278,5 @@ async fn create_token_with_expiry_date() {
269278
".api_token.last_used_at" => "[datetime]",
270279
".api_token.token" => insta::api_token_redaction(),
271280
});
281+
assert_eq!(app.as_inner().emails.mails_in_memory().unwrap().len(), 1);
272282
}

0 commit comments

Comments
 (0)