ezidam: apps: view, update, new secret

This commit is contained in:
Philippe Loctaux 2023-04-02 00:52:16 +02:00
parent 4b99905ee0
commit 2caf584cb7
12 changed files with 407 additions and 9 deletions

View file

@ -78,4 +78,29 @@ impl App {
.await? .await?
.map(Self::from)) .map(Self::from))
} }
pub async fn update(
conn: impl SqliteExecutor<'_>,
id: &AppID,
label: &str,
redirect_uri: &Url,
is_confidential: bool,
) -> Result<Option<()>, Error> {
Ok(DatabaseApps::update(
conn,
id.as_ref(),
label,
redirect_uri.as_str(),
is_confidential,
)
.await?)
}
pub async fn new_secret(
&self,
conn: impl SqliteExecutor<'_>,
secret: &Secret,
) -> Result<Option<()>, Error> {
Ok(DatabaseApps::new_secret(conn, self.id.as_ref(), secret.hash()).await?)
}
} }

View file

@ -0,0 +1,5 @@
update apps
set secret = ?
where id is ?

View file

@ -0,0 +1,7 @@
update apps
set label = ?,
redirect_uri = ?,
is_confidential = ?
where id is ?

View file

@ -80,6 +80,26 @@
}, },
"query": "select id,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n label,\n redirect_uri,\n secret,\n is_confidential as \"is_confidential: bool\",\n is_archived as \"is_archived: bool\"\nfrom apps\n\nwhere is_archived is 0\norder by created_at desc" "query": "select id,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n label,\n redirect_uri,\n secret,\n is_confidential as \"is_confidential: bool\",\n is_archived as \"is_archived: bool\"\nfrom apps\n\nwhere is_archived is 0\norder by created_at desc"
}, },
"184d704e75f00513082dd2c6cc3ae5c3f58b57b222ba4333216b5c50c3c58c71": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "update apps\n\nset label = ?,\n redirect_uri = ?,\n is_confidential = ?\n\nwhere id is ?"
},
"1e2edc8cf28832344dbfa0878ac01361b6f97c552d6af8477da12cddb03d4865": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 2
}
},
"query": "update apps\n\nset secret = ?\n\nwhere id is ?"
},
"3c8e31ffa5cbfd4dded8a272777cb320fb51fd2e53ed25054d24e9801df0c358": { "3c8e31ffa5cbfd4dded8a272777cb320fb51fd2e53ed25054d24e9801df0c358": {
"describe": { "describe": {
"columns": [], "columns": [],

View file

@ -102,4 +102,38 @@ impl Apps {
.await .await
.map_err(handle_error) .map_err(handle_error)
} }
pub async fn update(
conn: impl SqliteExecutor<'_>,
id: &str,
label: &str,
redirect_uri: &str,
is_confidential: bool,
) -> Result<Option<()>, Error> {
let query: SqliteQueryResult = sqlx::query_file!(
"queries/apps/update.sql",
label,
redirect_uri,
is_confidential,
id
)
.execute(conn)
.await
.map_err(handle_error)?;
Ok((query.rows_affected() == 1).then_some(()))
}
pub async fn new_secret(
conn: impl SqliteExecutor<'_>,
id: &str,
secret: &str,
) -> Result<Option<()>, Error> {
let query: SqliteQueryResult = sqlx::query_file!("queries/apps/new_secret.sql", secret, id)
.execute(conn)
.await
.map_err(handle_error)?;
Ok((query.rows_affected() == 1).then_some(()))
}
} }

View file

@ -1,6 +1,9 @@
use id::UserID; use id::{AppID, Error, UserID};
use rocket::http::impl_from_uri_param_identity;
use rocket::http::uri::fmt::{Formatter, Path, UriDisplay};
use rocket::request::FromParam; use rocket::request::FromParam;
use rocket::serde::{Deserialize, Serialize}; use rocket::serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr; use std::str::FromStr;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -8,9 +11,32 @@ use std::str::FromStr;
pub struct RocketUserID(pub UserID); pub struct RocketUserID(pub UserID);
impl<'r> FromParam<'r> for RocketUserID { impl<'r> FromParam<'r> for RocketUserID {
type Error = id::Error; type Error = Error;
fn from_param(param: &'r str) -> Result<Self, Self::Error> { fn from_param(param: &'r str) -> Result<Self, Self::Error> {
UserID::from_str(param).map(RocketUserID) UserID::from_str(param).map(RocketUserID)
} }
} }
#[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct RocketAppID(pub AppID);
impl<'r> FromParam<'r> for RocketAppID {
type Error = Error;
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
if param == "ezidam" {
return Err(Error::Invalid("App"));
}
AppID::from_str(param).map(RocketAppID)
}
}
impl UriDisplay<Path> for RocketAppID {
fn fmt(&self, f: &mut Formatter<Path>) -> fmt::Result {
UriDisplay::fmt(&self.0 .0, f)
}
}
impl_from_uri_param_identity!([Path] RocketAppID);

View file

@ -20,6 +20,7 @@ pub enum Page {
AdminSettingsSecurity(AdminSettingsSecurity), AdminSettingsSecurity(AdminSettingsSecurity),
AdminAppsList(AdminAppsList), AdminAppsList(AdminAppsList),
AdminAppsNew(AdminAppsNew), AdminAppsNew(AdminAppsNew),
AdminAppsView(AdminAppsView),
} }
impl Page { impl Page {
@ -36,6 +37,7 @@ impl Page {
Page::AdminSettingsSecurity(_) => "pages/admin/settings/security", Page::AdminSettingsSecurity(_) => "pages/admin/settings/security",
Page::AdminAppsList(_) => "pages/admin/apps/list", Page::AdminAppsList(_) => "pages/admin/apps/list",
Page::AdminAppsNew(_) => "pages/admin/apps/new", Page::AdminAppsNew(_) => "pages/admin/apps/new",
Page::AdminAppsView(_) => "pages/admin/apps/view",
} }
} }
@ -52,6 +54,7 @@ impl Page {
Page::AdminSettingsSecurity(_) => "Server security", Page::AdminSettingsSecurity(_) => "Server security",
Page::AdminAppsList(_) => "Applications", Page::AdminAppsList(_) => "Applications",
Page::AdminAppsNew(_) => "New application", Page::AdminAppsNew(_) => "New application",
Page::AdminAppsView(_) => "Application info",
} }
} }
@ -70,6 +73,7 @@ impl Page {
Page::AdminSettingsSecurity(_) => Some(AdminMenu::Settings.into()), Page::AdminSettingsSecurity(_) => Some(AdminMenu::Settings.into()),
Page::AdminAppsList(_) => Some(AdminMenu::Apps.into()), Page::AdminAppsList(_) => Some(AdminMenu::Apps.into()),
Page::AdminAppsNew(_) => Some(AdminMenu::Apps.into()), Page::AdminAppsNew(_) => Some(AdminMenu::Apps.into()),
Page::AdminAppsView(_) => Some(AdminMenu::Apps.into()),
} }
} }
@ -86,6 +90,7 @@ impl Page {
Page::AdminSettingsSecurity(security) => Box::new(security), Page::AdminSettingsSecurity(security) => Box::new(security),
Page::AdminAppsList(list) => Box::new(list), Page::AdminAppsList(list) => Box::new(list),
Page::AdminAppsNew(new) => Box::new(new), Page::AdminAppsNew(new) => Box::new(new),
Page::AdminAppsView(view) => Box::new(view),
} }
} }
} }

View file

@ -17,6 +17,9 @@ pub fn routes() -> Vec<Route> {
admin_apps_list, admin_apps_list,
admin_apps_new, admin_apps_new,
admin_apps_new_form, admin_apps_new_form,
admin_apps_view,
admin_apps_view_form,
admin_apps_new_secret,
] ]
} }
@ -62,4 +65,12 @@ pub mod content {
pub struct AdminAppsNew { pub struct AdminAppsNew {
pub user: JwtClaims, pub user: JwtClaims,
} }
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone)]
pub struct AdminAppsView {
pub user: JwtClaims,
pub app: App,
}
} }

View file

@ -33,7 +33,7 @@ pub async fn admin_apps_new(admin: JwtAdmin) -> Result<Page> {
} }
#[derive(Debug, FromForm)] #[derive(Debug, FromForm)]
pub struct NewApp<'r> { pub struct AppForm<'r> {
pub label: &'r str, pub label: &'r str,
pub redirect_uri: &'r str, pub redirect_uri: &'r str,
pub is_confidential: bool, pub is_confidential: bool,
@ -43,7 +43,7 @@ pub struct NewApp<'r> {
pub async fn admin_apps_new_form( pub async fn admin_apps_new_form(
mut db: Connection<Database>, mut db: Connection<Database>,
_admin: JwtAdmin, _admin: JwtAdmin,
form: Form<NewApp<'_>>, form: Form<AppForm<'_>>,
) -> Result<Flash<Redirect>> { ) -> Result<Flash<Redirect>> {
// Generate app id // Generate app id
let app_id = task::spawn_blocking(AppID::default).await?; let app_id = task::spawn_blocking(AppID::default).await?;
@ -73,3 +73,96 @@ pub async fn admin_apps_new_form(
format!("Application \"{}\" has been created", form.label), format!("Application \"{}\" has been created", form.label),
)) ))
} }
#[get("/admin/apps/<id>")]
pub async fn admin_apps_view(
mut db: Connection<Database>,
id: RocketAppID,
admin: JwtAdmin,
flash: Option<FlashMessage<'_>>,
) -> Result<Template> {
let app_id = id.0;
let app = App::get_one_by_id(&mut *db, app_id.as_ref())
.await?
.ok_or_else(|| Error::not_found(app_id.to_string()))?;
let page = Page::AdminAppsView(super::content::AdminAppsView { user: admin.0, app });
Ok(flash
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into()))
}
#[post("/admin/apps/<id>", data = "<form>")]
pub async fn admin_apps_view_form(
mut db: Connection<Database>,
id: RocketAppID,
_admin: JwtAdmin,
form: Form<AppForm<'_>>,
) -> Result<Flash<Redirect>> {
// Parse redirect uri
let redirect_uri = Url::parse(form.redirect_uri)?;
// Update app
App::update(
&mut *db,
&id.0,
form.label,
&redirect_uri,
form.is_confidential,
)
.await?;
Ok(Flash::new(
Redirect::to(uri!(admin_apps_list)),
FlashKind::Success,
format!("Application \"{}\" has been updated", form.label),
))
}
#[derive(Debug, FromForm)]
pub struct NewSecretForm {
pub regenerate_secret: bool,
}
#[post("/admin/apps/<id>/secret", data = "<form>")]
pub async fn admin_apps_new_secret(
mut db: Connection<Database>,
id: RocketAppID,
_admin: JwtAdmin,
form: Form<NewSecretForm>,
) -> Result<Flash<Redirect>> {
if !form.regenerate_secret {
return Err(Error::bad_request("Not generating a new secret"));
}
let mut transaction = db.begin().await?;
// Get app
let app_id = &id.0;
let app = App::get_one_by_id(&mut transaction, app_id.as_ref())
.await?
.ok_or_else(|| Error::not_found(app_id.to_string()))?;
// Generate secret
let app_secret = task::spawn_blocking(SecretString::default).await?;
let app_secret_string = app_secret.to_string();
// Hash secret
let app_secret_hash = task::spawn_blocking(move || Secret::new(app_secret)).await??;
// Save new secret
app.new_secret(&mut transaction, &app_secret_hash).await?;
transaction.commit().await?;
Ok(Flash::new(
Redirect::to(uri!(admin_apps_view(id))),
FlashKind::Success,
format!(
"Secret for application \"{}\" has been updated. It will only be shown once!\
<div class=\"mt-1 user-select-all\">{app_secret_string}</div>",
app.label(),
),
))
}

View file

@ -68,9 +68,9 @@
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="table-tbody">
<!-- Table content --> <!-- Table content -->
<tbody class="table-tbody">
{% for app in apps %} {% for app in apps %}
<tr> <tr>
<td class="sort-name">{{ app.label }}</td> <td class="sort-name">{{ app.label }}</td>
@ -81,18 +81,18 @@
<span class="badge bg-success me-1"></span> Active <span class="badge bg-success me-1"></span> Active
{% endif %} {% endif %}
</td> </td>
<td class="sort-creation-date" data-date="{{ app.created_at | date(timestamp=true) }}"> <td class="sort-creation-date" data-date='{{ app.created_at | date(format="%s") }}'>
{{ app.created_at | date() }} {{ app.created_at | date() }}
</td> </td>
<td class="sort-id">{{ app.id }}</td> <td class="sort-id">{{ app.id }}</td>
<td class="sort-redirect-uri">{{ app.redirect_uri }}</td> <td class="sort-redirect-uri">{{ app.redirect_uri }}</td>
<td> <td>
<a href="#">Details</a> <a href="apps/{{ app.id }}">Details</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
@ -127,7 +127,7 @@
sortClass: 'table-sort', sortClass: 'table-sort',
listClass: 'table-tbody', listClass: 'table-tbody',
valueNames: ['sort-name', 'sort-status', valueNames: ['sort-name', 'sort-status',
{attr: 'data-date', name: 'sort-date'}, {attr: 'data-date', name: 'sort-creation-date'},
'sort-id', 'sort-redirect-uri', 'sort-id', 'sort-redirect-uri',
] ]
}); });

View file

@ -0,0 +1,166 @@
{% extends "shell" %}
{% block content %}
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">
Admin dashboard
</div>
<h2 class="page-title">
Applications
</h2>
</div>
</div>
</div>
</div>
<!-- Page body -->
<div class="page-body">
<div class="container-xl">
{% if flash %}
<div class="alert alert-{{flash.0}}" role="alert">
<h4 class="alert-title">{{ flash.1 | safe }}</h4>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Application IDs</h3>
</div>
<div class="card-body">
<!-- App id -->
<div class="mb-3">
<label class="form-label">App ID</label>
<input type="text" value="{{ app.id }}" class="form-control" readonly>
</div>
<!-- App secret -->
<div class="mb-3">
<label class="form-label">App secret</label>
<input type="password" class="form-control" value="super_secure_useless_app_secret" readonly>
</div>
<!-- Regenerate secret -->
<div>
<form action="{{ app.id }}/secret" method="post">
<div class="mb-2">Lost your secret?</div>
<button type="submit" name="regenerate_secret" value="true" class="btn btn-danger">
Regenerate secret
</button>
</form>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Edit Application</h3>
</div>
<form action="" method="post">
<div class="card-body">
<!-- App name -->
<div class="mb-3">
<label class="form-label required" for="label">Application name</label>
<div>
<input name="label" id="label" type="text" value="{{ app.label }}" placeholder="Enter name"
class="form-control"
required>
</div>
</div>
<!-- Redirect URI -->
<div class="mb-3">
<label class="form-label required" for="redirect_uri">Redirect URI</label>
<div>
<input name="redirect_uri" id="redirect_uri" type="url" value="{{ app.redirect_uri }}"
placeholder="Enter redirect URI"
class="form-control" required>
</div>
</div>
<!-- Confidential app -->
<div class="mb-3">
<label class="form-label required">Confidentiality</label>
<div class="form-selectgroup-boxes row mb-3">
<!-- Private apps -->
<div class="col-lg-6">
<label class="form-selectgroup-item">
{% if app.is_confidential == true %}
<input name="is_confidential" type="radio" value="true"
class="form-selectgroup-input"
required checked>
{% else %}
<input name="is_confidential" type="radio" value="true"
class="form-selectgroup-input"
required>
{% endif %}
<span class="form-selectgroup-label d-flex align-items-center p-3">
<span class="me-3">
<span class="form-selectgroup-check"></span>
</span>
<span class="form-selectgroup-label-content">
<span class="form-selectgroup-title strong mb-1">Private application</span>
<span class="d-block text-muted">
For applications with a backend, where the application secret can be securely stored.
</span>
</span>
</span>
</label>
</div>
<!-- Public apps -->
<div class="col-lg-6">
<label class="form-selectgroup-item">
{% if app.is_confidential == false %}
<input name="is_confidential" type="radio" value="false"
class="form-selectgroup-input"
required checked>
{% else %}
<input name="is_confidential" type="radio" value="false"
class="form-selectgroup-input"
required>
{% endif %}
<span class="form-selectgroup-label d-flex align-items-center p-3">
<span class="me-3">
<span class="form-selectgroup-check"></span>
</span>
<span class="form-selectgroup-label-content">
<span class="form-selectgroup-title strong mb-1">Public application</span>
<span class="d-block text-muted">
For public applications (PWAs, desktop, mobile). The application secret can not be securely stored.
</span>
</span>
</span>
</label>
</div>
</div>
</div>
</div>
<div class="card-footer text-end">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}
{% block libs_js %}
{% endblock lib_js %}
{% block additional_js %}
{% endblock additional_js %}

View file

@ -2,6 +2,7 @@ use crate::error::Error;
use crate::hash::{hash, Hash}; use crate::hash::{hash, Hash};
use nanoid::nanoid; use nanoid::nanoid;
use nanoid_dictionary::ALPHANUMERIC; use nanoid_dictionary::ALPHANUMERIC;
use std::fmt::{Display, Formatter};
// Struct to generate the secret // Struct to generate the secret
pub struct SecretString(String); pub struct SecretString(String);
@ -20,6 +21,11 @@ impl AsRef<str> for SecretString {
self.0.as_ref() self.0.as_ref()
} }
} }
impl Display for SecretString {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct Secret(Hash); pub struct Secret(Hash);