Skip to content

Commit d8c147a

Browse files
committed
feat(api): manage keys without ids
1 parent f6198ae commit d8c147a

File tree

5 files changed

+83
-67
lines changed

5 files changed

+83
-67
lines changed

hurl/api_key.hurl

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ X-Api-Key: {{created_key}}
3838
HTTP 200
3939

4040
# Rotate key
41-
POST http://localhost:8080/api_key/{{created_key}}/rotate
41+
POST http://localhost:8080/api_key/rotate
4242
[Cookies]
4343
session_id: {{session}}
4444
[Form]
45-
service: example
45+
key: {{created_key}}
4646
HTTP 200
4747
[Captures]
4848
rotated_key: xpath "//code[@id='api-key']/text()"
@@ -62,9 +62,11 @@ X-Api-Key: {{rotated_key}}
6262
HTTP 200
6363

6464
# Delete new key
65-
POST http://localhost:8080/api_key/{{rotated_key}}/delete
65+
POST http://localhost:8080/api_key/delete
6666
[Cookies]
6767
session_id: {{session}}
68+
[Form]
69+
key: {{rotated_key}}
6870
HTTP 302
6971
Location: /
7072

src/handle_api_key.rs

Lines changed: 12 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ use std::collections::BTreeMap;
22

33
use actix_web::http::header::ContentType;
44
use actix_web::{post, web, HttpResponse};
5-
use chrono::{Duration, Utc};
5+
use chrono::Duration;
66
use serde::Deserialize;
77

88
use crate::database::Database;
9-
use crate::error::{AppErrorKind, Response};
9+
use crate::error::Response;
1010
use crate::secret::{ApiKeySecret, BrowserSessionSecret};
1111
use crate::utils::get_partial;
1212

@@ -36,61 +36,40 @@ async fn create(
3636
.await?;
3737
let mut data = BTreeMap::new();
3838
data.insert("key", key.code().to_str_that_i_wont_print().to_string());
39-
data.insert("id", key.code().to_str_that_i_wont_print().to_string());
4039
let page = get_partial::<()>("api_key", data, None)?;
4140
Ok(HttpResponse::Ok()
4241
.content_type(ContentType::html())
4342
.body(page))
4443
}
4544

4645
#[derive(Deserialize)]
47-
struct IdPath {
48-
id: String,
46+
struct KeyForm {
47+
key: String,
4948
}
5049

51-
#[post("/api_key/{id}/delete")]
50+
#[post("/api_key/delete")]
5251
async fn delete(
5352
browser_session: BrowserSessionSecret,
54-
path: web::Path<IdPath>,
53+
form: web::Form<KeyForm>,
5554
db: web::Data<Database>,
5655
) -> Response {
57-
let key = ApiKeySecret::try_from_string(path.id.clone(), db.get_ref()).await?;
58-
if key.user() != browser_session.user() {
59-
return Err(AppErrorKind::Unauthorized.into());
60-
}
61-
key.delete(db.get_ref()).await?;
56+
ApiKeySecret::delete_with_user(form.key.clone(), browser_session.user(), db.get_ref()).await?;
6257
Ok(HttpResponse::Found()
6358
.append_header(("Location", "/"))
6459
.finish())
6560
}
6661

67-
#[post("/api_key/{id}/rotate")]
62+
#[post("/api_key/rotate")]
6863
async fn rotate(
6964
browser_session: BrowserSessionSecret,
70-
path: web::Path<IdPath>,
71-
form: web::Form<CreateForm>,
65+
form: web::Form<KeyForm>,
7266
db: web::Data<Database>,
7367
) -> Response {
74-
let old = ApiKeySecret::try_from_string(path.id.clone(), db.get_ref()).await?;
75-
if old.user() != browser_session.user() {
76-
return Err(AppErrorKind::Unauthorized.into());
77-
}
78-
let remaining = if old.expires_at() == chrono::NaiveDateTime::MAX {
79-
chrono::Duration::seconds(0)
80-
} else {
81-
old.expires_at() - Utc::now().naive_utc()
82-
};
83-
let new_key = ApiKeySecret::new_with_expiration(
84-
browser_session.user().clone(),
85-
form.service.clone(),
86-
remaining,
87-
db.get_ref(),
88-
)
89-
.await?;
90-
old.delete(db.get_ref()).await?;
68+
let new_key =
69+
ApiKeySecret::rotate_with_user(form.key.clone(), browser_session.user(), db.get_ref())
70+
.await?;
9171
let mut data = BTreeMap::new();
9272
data.insert("key", new_key.code().to_str_that_i_wont_print().to_string());
93-
data.insert("id", new_key.code().to_str_that_i_wont_print().to_string());
9473
let page = get_partial::<()>("api_key", data, None)?;
9574
Ok(HttpResponse::Ok()
9675
.content_type(ContentType::html())

src/handle_index.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ async fn index(browser_session: BrowserSessionSecret, db: web::Data<Database>) -
2929
let mut index_data = BTreeMap::new();
3030
index_data.insert("email", browser_session.user().email.clone());
3131
let realmed_services = config.services.from_user(&browser_session.user());
32+
let auth_email_header = config.auth_url_email_header.clone();
33+
let auth_user_header = config.auth_url_user_header.clone();
34+
let auth_name_header = config.auth_url_name_header.clone();
35+
let auth_realms_header = config.auth_url_realms_header.clone();
3236
drop(config);
3337

3438
let mut services_with_keys = Vec::new();
@@ -45,19 +49,19 @@ async fn index(browser_session: BrowserSessionSecret, db: web::Data<Database>) -
4549
// Respond with the index page and set the X-Remote headers as configured
4650
Ok(HttpResponse::Ok()
4751
.append_header((
48-
config.auth_url_email_header.as_str(),
52+
auth_email_header.as_str(),
4953
browser_session.user().email.clone(),
5054
))
5155
.append_header((
52-
config.auth_url_user_header.as_str(),
56+
auth_user_header.as_str(),
5357
browser_session.user().username.clone(),
5458
))
5559
.append_header((
56-
config.auth_url_name_header.as_str(),
60+
auth_name_header.as_str(),
5761
browser_session.user().name.clone(),
5862
))
5963
.append_header((
60-
config.auth_url_realms_header.as_str(),
64+
auth_realms_header.as_str(),
6165
browser_session.user().realms.join(","),
6266
))
6367
.content_type(ContentType::html())

src/secret/api_key.rs

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::error::{AppErrorKind, Result};
66
use crate::user::User;
77
use crate::{CONFIG, PROXY_ORIGIN_HEADER};
88

9-
use super::primitive::{InternalUserSecret, UserSecret, UserSecretKind};
9+
use super::primitive::{UserSecret, UserSecretKind};
1010
use super::{MetadataKind, SecretString};
1111
use crate::database::{Database, UserSecretRow};
1212

@@ -64,14 +64,20 @@ impl ApiKeySecret {
6464
.checked_add_signed(final_duration)
6565
.ok_or(AppErrorKind::InvalidDuration)?
6666
};
67-
let internal = InternalUserSecret {
68-
code: SecretString::new(ApiKeySecretKind::PREFIX),
69-
user,
67+
let code = SecretString::new(ApiKeySecretKind::PREFIX);
68+
let metadata = ApiKeyMetadata { service };
69+
let user_str = serde_json::to_string(&user)?;
70+
let metadata_str = serde_json::to_string(&metadata)?;
71+
let row = UserSecretRow {
72+
id: code.to_str_that_i_wont_print().to_string(),
73+
secret_type: ApiKeySecretKind::PREFIX.to_string(),
74+
user_data: user_str,
7075
expires_at,
71-
metadata: ApiKeyMetadata { service },
76+
metadata: metadata_str,
77+
created_at: None,
7278
};
73-
internal.save(db).await?;
74-
Ok(Self(internal))
79+
row.save(db).await?;
80+
ApiKeySecret::try_from_string(row.id, db).await
7581
}
7682

7783
/// List API keys for a user and service.
@@ -91,14 +97,20 @@ impl ApiKeySecret {
9197
if meta.service != service {
9298
continue;
9399
}
94-
let display = format!("{}…", &row.id[..row.id.len().min(8)]);
100+
let prefix = crate::secret::get_prefix(ApiKeySecretKind::PREFIX);
101+
let bare = row.id.strip_prefix(&prefix).unwrap_or(&row.id);
102+
let display = if bare.len() <= 8 {
103+
bare.to_string()
104+
} else {
105+
format!("{}…{}", &bare[..4], &bare[bare.len().saturating_sub(4)..],)
106+
};
95107
let expires_at = if row.expires_at == NaiveDateTime::MAX {
96108
None
97109
} else {
98110
Some(row.expires_at)
99111
};
100112
keys.push(ApiKeyInfo {
101-
id: row.id,
113+
key: row.id,
102114
display,
103115
expires_at,
104116
});
@@ -108,14 +120,41 @@ impl ApiKeySecret {
108120

109121
/// Get the associated service name.
110122
pub fn service(&self) -> &str {
111-
&self.0.metadata.service
123+
&self.metadata().service
124+
}
125+
126+
/// Rotate an API key if it belongs to `user`.
127+
pub async fn rotate_with_user(code: String, user: &User, db: &Database) -> Result<Self> {
128+
let old = Self::try_from_string(code, db).await?;
129+
if old.user() != user {
130+
return Err(AppErrorKind::Unauthorized.into());
131+
}
132+
let remaining = if old.expires_at() == NaiveDateTime::MAX {
133+
Duration::seconds(0)
134+
} else {
135+
old.expires_at() - Utc::now().naive_utc()
136+
};
137+
let new_key =
138+
Self::new_with_expiration(user.clone(), old.service().to_string(), remaining, db)
139+
.await?;
140+
old.delete(db).await?;
141+
Ok(new_key)
142+
}
143+
144+
/// Delete an API key if it belongs to `user`.
145+
pub async fn delete_with_user(code: String, user: &User, db: &Database) -> Result<()> {
146+
let key = Self::try_from_string(code, db).await?;
147+
if key.user() != user {
148+
return Err(AppErrorKind::Unauthorized.into());
149+
}
150+
key.delete(db).await
112151
}
113152
}
114153

115154
/// Public representation of an API key for listing purposes.
116155
#[derive(Debug, Clone, Serialize)]
117156
pub struct ApiKeyInfo {
118-
pub id: String,
157+
pub key: String,
119158
pub display: String,
120159
pub expires_at: Option<NaiveDateTime>,
121160
}
@@ -174,25 +213,16 @@ mod tests {
174213
let id = key.code().to_str_that_i_wont_print().to_string();
175214
let list = ApiKeySecret::list(&user, "example", &db).await.unwrap();
176215
assert_eq!(list.len(), 1);
177-
let fetched = ApiKeySecret::try_from_string(id.clone(), &db)
216+
let rotated = ApiKeySecret::rotate_with_user(id.clone(), &user, &db)
178217
.await
179218
.unwrap();
180-
let new_key = ApiKeySecret::new_with_expiration(
181-
user.clone(),
182-
"example".to_string(),
183-
Duration::try_seconds(60).unwrap(),
219+
ApiKeySecret::delete_with_user(
220+
rotated.code().to_str_that_i_wont_print().to_string(),
221+
&user,
184222
&db,
185223
)
186224
.await
187225
.unwrap();
188-
fetched.delete(&db).await.unwrap();
189-
let new_id = new_key.code().to_str_that_i_wont_print().to_string();
190-
let list = ApiKeySecret::list(&user, "example", &db).await.unwrap();
191-
assert_eq!(list.len(), 1);
192-
let fetched_new = ApiKeySecret::try_from_string(new_id.clone(), &db)
193-
.await
194-
.unwrap();
195-
fetched_new.delete(&db).await.unwrap();
196226
let list = ApiKeySecret::list(&user, "example", &db).await.unwrap();
197227
assert!(list.is_empty());
198228
}

static/templates/index.html.hbs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@
5151
{{#each api_keys}}
5252
<li class="my-2">
5353
{{this.display}} - {{#if this.expires_at}}{{this.expires_at}}{{else}}never{{/if}}
54-
<form method="post" action="/api_key/{{this.id}}/rotate" class="inline">
55-
<input type="hidden" name="service" value="{{../name}}" />
54+
<form method="post" action="/api_key/rotate" class="inline">
55+
<input type="hidden" name="key" value="{{this.key}}" />
5656
<button class="text-sm">Rotate</button>
5757
</form>
58-
<form method="post" action="/api_key/{{this.id}}/delete" class="inline">
58+
<form method="post" action="/api_key/delete" class="inline">
59+
<input type="hidden" name="key" value="{{this.key}}" />
5960
<button class="text-sm">Delete</button>
6061
</form>
6162
</li>

0 commit comments

Comments
 (0)