Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: access tokens (`/settings/tokens`). Tokens are shown once, at creation. |
| 2 | |
| 3 | use std::time::Duration; |
| 4 | |
| 5 | use forge_core::time::stamp_after; |
| 6 | use forge_core::token::{generate_token, hash_token}; |
| 7 | use forge_core::{AccessToken, AuditAction}; |
| 8 | use http::HeaderValue; |
| 9 | use http::header::CACHE_CONTROL; |
| 10 | use serde::Deserialize; |
| 11 | use topcoat::Result; |
| 12 | use topcoat::context::{Cx, app_context}; |
| 13 | use topcoat::router::content::Form; |
| 14 | use topcoat::router::error::{SeeOther, see_other}; |
| 15 | use topcoat::router::response::response_headers; |
| 16 | use topcoat::router::{page, route}; |
| 17 | use topcoat::view::{View, component, view}; |
| 18 | |
| 19 | use crate::audit::{self, Actor, Target}; |
| 20 | use crate::auth::{require_csrf, require_user}; |
| 21 | use crate::i18n::{self, Key, Lang, t, token_meta, token_name_length}; |
| 22 | use crate::state::AppState; |
| 23 | use crate::views::{alert, shell}; |
| 24 | |
| 25 | /// Scopes are fixed to `repo` for M1. |
| 26 | const SCOPES: &[&str] = &["repo"]; |
| 27 | const MAX_NAME_CHARS: usize = 64; |
| 28 | |
| 29 | #[derive(Deserialize)] |
| 30 | struct CreateTokenForm { |
| 31 | #[serde(default)] |
| 32 | csrf: String, |
| 33 | #[serde(default)] |
| 34 | name: String, |
| 35 | #[serde(default)] |
| 36 | expires: String, |
| 37 | } |
| 38 | |
| 39 | #[derive(Deserialize)] |
| 40 | struct DeleteTokenForm { |
| 41 | #[serde(default)] |
| 42 | csrf: String, |
| 43 | #[serde(default)] |
| 44 | id: String, |
| 45 | } |
| 46 | |
| 47 | /// Maps the form's expiry choice to a lifetime. `None` means the token never expires. |
| 48 | /// An unrecognised value falls back to the 90-day default. |
| 49 | fn expiry_days(choice: &str) -> Option<u64> { |
| 50 | match choice { |
| 51 | "30" => Some(30), |
| 52 | "365" => Some(365), |
| 53 | "never" => None, |
| 54 | _ => Some(90), |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | #[component] |
| 59 | async fn tokens_view( |
| 60 | lang: Lang, |
| 61 | csrf: &str, |
| 62 | tokens: Vec<AccessToken>, |
| 63 | created: Option<String>, |
| 64 | error: Option<String>, |
| 65 | ) -> Result<impl View> { |
| 66 | Ok(view! { |
| 67 | <section class="page-head"> |
| 68 | <h1>(t(lang, Key::AccessTokens))</h1> |
| 69 | </section> |
| 70 | if let Some(token) = created { |
| 71 | <section class="card notice" role="status"> |
| 72 | <h2>(t(lang, Key::YourNewToken))</h2> |
| 73 | <p>(t(lang, Key::CopyNow))</p> |
| 74 | <pre class="code"><code>(token)</code></pre> |
| 75 | <p class="muted small">(t(lang, Key::UseAsPassword))</p> |
| 76 | </section> |
| 77 | } |
| 78 | <section class="card"> |
| 79 | if tokens.is_empty() { |
| 80 | <p class="muted">(t(lang, Key::NoAccessTokens))</p> |
| 81 | } else { |
| 82 | <ul class="item-list"> |
| 83 | for token in tokens.into_iter() { |
| 84 | <li class="item"> |
| 85 | <div class="item-main"> |
| 86 | <strong>(token.name.clone())</strong> |
| 87 | <span class="muted small">(token_meta(lang, token.created_at.as_str(), token.last_used_at.as_deref(), token.expires_at.as_deref()))</span> |
| 88 | </div> |
| 89 | <form method="post" action="/settings/tokens/delete"> |
| 90 | <input type="hidden" name="csrf" value=(csrf)> |
| 91 | <input type="hidden" name="id" value=(token.id.clone())> |
| 92 | <button type="submit" class="danger">(t(lang, Key::Delete))</button> |
| 93 | </form> |
| 94 | </li> |
| 95 | } |
| 96 | </ul> |
| 97 | } |
| 98 | </section> |
| 99 | <section class="card"> |
| 100 | <h2>(t(lang, Key::NewToken))</h2> |
| 101 | if let Some(message) = error { |
| 102 | alert(message: message.as_str()) |
| 103 | } |
| 104 | <form method="post" action="/settings/tokens" class="stack"> |
| 105 | <input type="hidden" name="csrf" value=(csrf)> |
| 106 | <label> |
| 107 | <span>(t(lang, Key::Name))</span> |
| 108 | <input type="text" name="name" maxlength="64" required=(true) autocomplete="off"> |
| 109 | </label> |
| 110 | <label> |
| 111 | <span>(t(lang, Key::Expires))</span> |
| 112 | <select name="expires"> |
| 113 | <option value="30">(t(lang, Key::ExpiresIn30))</option> |
| 114 | <option value="90" selected=(true)>(t(lang, Key::ExpiresIn90))</option> |
| 115 | <option value="365">(t(lang, Key::ExpiresIn365))</option> |
| 116 | <option value="never">(t(lang, Key::ExpiresNever))</option> |
| 117 | </select> |
| 118 | </label> |
| 119 | <p class="muted small">(t(lang, Key::ScopeNote))</p> |
| 120 | <button type="submit" class="primary">(t(lang, Key::CreateToken))</button> |
| 121 | </form> |
| 122 | </section> |
| 123 | }) |
| 124 | } |
| 125 | |
| 126 | #[page("/settings/tokens")] |
| 127 | async fn tokens_page(cx: &Cx) -> Result<impl View> { |
| 128 | let viewer = require_user(cx).await?; |
| 129 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 130 | let tokens = app_context::<AppState>(cx) |
| 131 | .store |
| 132 | .list_tokens(&viewer.0.id) |
| 133 | .await?; |
| 134 | let csrf = viewer.1.csrf_token.clone(); |
| 135 | Ok(view! { |
| 136 | shell(title: t(lang, Key::AccessTokens), viewer: Some(&viewer), tokens_view(lang: lang, csrf: csrf.as_str(), tokens: tokens, created: None, error: None)) |
| 137 | }) |
| 138 | } |
| 139 | |
| 140 | #[page(POST "/settings/tokens")] |
| 141 | async fn tokens_create(cx: &Cx, Form(form): Form<CreateTokenForm>) -> Result<impl View> { |
| 142 | let viewer = require_csrf(cx, &form.csrf).await?; |
| 143 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 144 | let store = &app_context::<AppState>(cx).store; |
| 145 | let name = form.name.trim(); |
| 146 | |
| 147 | // Some(message) on invalid input; otherwise the new token to show once. |
| 148 | let (created, error) = if name.is_empty() || name.chars().count() > MAX_NAME_CHARS { |
| 149 | (None, Some(token_name_length(lang, MAX_NAME_CHARS))) |
| 150 | } else { |
| 151 | let token = generate_token(); |
| 152 | let expires_at = expiry_days(&form.expires) |
| 153 | .map(|days| stamp_after(Duration::from_secs(days * 24 * 60 * 60))); |
| 154 | let scopes: Vec<String> = SCOPES.iter().map(|s| (*s).to_owned()).collect(); |
| 155 | let row = store |
| 156 | .create_token( |
| 157 | &viewer.0.id, |
| 158 | name, |
| 159 | &hash_token(&token), |
| 160 | &scopes, |
| 161 | expires_at.as_deref(), |
| 162 | ) |
| 163 | .await?; |
| 164 | audit::record( |
| 165 | store, |
| 166 | Some(cx), |
| 167 | Actor::User(&viewer.0), |
| 168 | AuditAction::TokenCreated, |
| 169 | Target { |
| 170 | kind: "token", |
| 171 | id: &row.id, |
| 172 | name: &row.name, |
| 173 | }, |
| 174 | serde_json::Value::Null, |
| 175 | ) |
| 176 | .await; |
| 177 | (Some(token), None) |
| 178 | }; |
| 179 | |
| 180 | // The response may carry the plaintext token, so keep it out of caches. |
| 181 | response_headers(cx).append(CACHE_CONTROL, HeaderValue::from_static("no-store")); |
| 182 | let tokens = store.list_tokens(&viewer.0.id).await?; |
| 183 | let csrf = viewer.1.csrf_token.clone(); |
| 184 | Ok(view! { |
| 185 | shell(title: t(lang, Key::AccessTokens), viewer: Some(&viewer), tokens_view(lang: lang, csrf: csrf.as_str(), tokens: tokens, created: created, error: error)) |
| 186 | }) |
| 187 | } |
| 188 | |
| 189 | #[route(POST "/settings/tokens/delete")] |
| 190 | async fn tokens_delete(cx: &Cx, Form(form): Form<DeleteTokenForm>) -> Result<SeeOther> { |
| 191 | let (user, _) = require_csrf(cx, &form.csrf).await?; |
| 192 | let state = app_context::<AppState>(cx); |
| 193 | if state.store.delete_token(&user.id, &form.id).await? { |
| 194 | audit::record( |
| 195 | &state.store, |
| 196 | Some(cx), |
| 197 | Actor::User(&user), |
| 198 | AuditAction::TokenRevoked, |
| 199 | Target { |
| 200 | kind: "token", |
| 201 | id: &form.id, |
| 202 | name: "", |
| 203 | }, |
| 204 | serde_json::Value::Null, |
| 205 | ) |
| 206 | .await; |
| 207 | } |
| 208 | Ok(see_other("/settings/tokens")) |
| 209 | } |
| 210 | |
| 211 | #[cfg(test)] |
| 212 | mod tests { |
| 213 | use super::*; |
| 214 | |
| 215 | #[test] |
| 216 | fn expiry_choices_map_to_days_or_none() { |
| 217 | assert_eq!(expiry_days("30"), Some(30)); |
| 218 | assert_eq!(expiry_days("90"), Some(90)); |
| 219 | assert_eq!(expiry_days("365"), Some(365)); |
| 220 | assert_eq!(expiry_days("never"), None); |
| 221 | assert_eq!(expiry_days("7"), Some(90)); |
| 222 | } |
| 223 | } |