Add some extra info to ssh keys such as name & last used
Diff
Cargo.lock | 1 +
chartered-frontend/package-lock.json | 31 +++++++++++++++++++++++++++++++
chartered-frontend/package.json | 1 +
chartered-git/Cargo.toml | 1 +
chartered-db/src/schema.rs | 3 +++
chartered-db/src/users.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
chartered-git/src/main.rs | 9 ++++++++-
migrations/2021-08-31-214501_create_crates_table/up.sql | 3 +++
chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx | 12 ++++++++++--
chartered-web/src/endpoints/web_api/login.rs | 2 +-
chartered-web/src/endpoints/web_api/ssh_key.rs | 16 ++++++++++++++++
11 files changed, 124 insertions(+), 43 deletions(-)
@@ -239,6 +239,7 @@
"futures",
"hex",
"itoa",
"log",
"serde",
"serde_json",
"sha-1",
@@ -14,6 +14,7 @@
"react-bootstrap": "^2.0.0-beta.6",
"react-bootstrap-icons": "^1.5.0",
"react-dom": "^17.0.2",
"react-human-time": "^1.2.0",
"react-markdown": "^7.0.1",
"react-router-dom": "^5.3.0",
"react-syntax-highlighter": "^15.4.4",
@@ -5416,6 +5417,11 @@
"integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=",
"dev": true
},
"node_modules/human-time": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/human-time/-/human-time-0.0.1.tgz",
"integrity": "sha1-KA0DNjeRmTBrLhUY49X2OBy4UH0="
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -8880,6 +8886,16 @@
},
"peerDependencies": {
"react": "17.0.2"
}
},
"node_modules/react-human-time": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-human-time/-/react-human-time-1.2.0.tgz",
"integrity": "sha512-4pKOGL4eswR9O4K+TD9uhjglCH6Tv96nN4y9Mpgx317gY013ld3f6dxml8hZoj2iedcfHgFYrh26uo5l99vpIw==",
"dependencies": {
"@types/react": ">=16.0.0",
"human-time": "0.0.1",
"react": ">=16.0.0"
}
},
"node_modules/react-is": {
@@ -15821,6 +15837,11 @@
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
"integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=",
"dev": true
},
"human-time": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/human-time/-/human-time-0.0.1.tgz",
"integrity": "sha1-KA0DNjeRmTBrLhUY49X2OBy4UH0="
},
"iconv-lite": {
"version": "0.4.24",
@@ -18452,6 +18473,16 @@
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"scheduler": "^0.20.2"
}
},
"react-human-time": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-human-time/-/react-human-time-1.2.0.tgz",
"integrity": "sha512-4pKOGL4eswR9O4K+TD9uhjglCH6Tv96nN4y9Mpgx317gY013ld3f6dxml8hZoj2iedcfHgFYrh26uo5l99vpIw==",
"requires": {
"@types/react": ">=16.0.0",
"human-time": "0.0.1",
"react": ">=16.0.0"
}
},
"react-is": {
@@ -25,6 +25,7 @@
"react-bootstrap": "^2.0.0-beta.6",
"react-bootstrap-icons": "^1.5.0",
"react-dom": "^17.0.2",
"react-human-time": "^1.2.0",
"react-markdown": "^7.0.1",
"react-router-dom": "^5.3.0",
"react-syntax-highlighter": "^15.4.4",
@@ -21,6 +21,7 @@
futures = "0.3"
hex = "0.4"
itoa = "0.4"
log = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha-1 = "0.9"
@@ -48,8 +48,11 @@
table! {
user_ssh_keys (id) {
id -> Integer,
name -> Text,
user_id -> Integer,
ssh_key -> Binary,
created_at -> Timestamp,
last_used_at -> Nullable<Timestamp>,
}
}
@@ -1,10 +1,10 @@
use super::{
schema::{user_crate_permissions, user_sessions, user_ssh_keys, users},
ConnectionPool, Result,
};
use diesel::{insert_into, prelude::*, Associations, Identifiable, Queryable};
use rand::{thread_rng, Rng};
use std::{collections::HashMap, sync::Arc};
use std::sync::Arc;
use thrussh_keys::PublicKeyBase64;
#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
@@ -89,14 +89,16 @@
};
let parsed_key = thrussh_keys::parse_public_key_base64(key)?;
let parsed_name = split.next().unwrap_or("(none)").to_string();
tokio::task::spawn_blocking(move || {
use crate::schema::user_ssh_keys::dsl::{ssh_key, user_id};
use crate::schema::user_ssh_keys::dsl::{name, ssh_key, user_id};
let conn = conn.get()?;
insert_into(crate::schema::user_ssh_keys::dsl::user_ssh_keys)
.values((
name.eq(parsed_name),
ssh_key.eq(parsed_key.public_key_bytes()),
user_id.eq(self.id),
))
@@ -129,51 +131,20 @@
.await?
}
pub async fn list_ssh_keys(
self: Arc<Self>,
conn: ConnectionPool,
) -> Result<HashMap<i32, String>> {
pub async fn list_ssh_keys(self: Arc<Self>, conn: ConnectionPool) -> Result<Vec<UserSshKey>> {
tokio::task::spawn_blocking(move || {
use crate::schema::user_ssh_keys::dsl::user_id;
let conn = conn.get()?;
let selected: Vec<(i32, Vec<u8>)> = crate::schema::user_ssh_keys::table
let selected = crate::schema::user_ssh_keys::table
.filter(user_id.eq(self.id))
.inner_join(users::table)
.select((user_ssh_keys::id, user_ssh_keys::ssh_key))
.select(user_ssh_keys::all_columns)
.load(&conn)?;
Ok(selected
.into_iter()
.map(|(id, key)| {
(
id,
thrussh_keys::key::parse_public_key(&key)
.map_err(|e| e.into())
.and_then(|v| {
let raw_hex = hex::encode(
base64::decode(&v.fingerprint())
.map_err(|_| thrussh_keys::Error::CouldNotReadKey)?,
);
let mut hex =
String::with_capacity(raw_hex.len() + (raw_hex.len() / 2 - 1));
for (i, c) in raw_hex.chars().enumerate() {
if i != 0 && i % 2 == 0 {
hex.push(':');
}
hex.push(c);
}
Ok::<_, crate::Error>(hex)
})
.unwrap_or_else(|e| format!("INVALID: {}", e)),
)
})
.collect())
Ok(selected)
})
.await?
}
@@ -319,8 +290,11 @@
#[belongs_to(User)]
pub struct UserSshKey {
pub id: i32,
pub name: String,
pub user_id: i32,
pub ssh_key: Vec<u8>,
pub created_at: chrono::NaiveDateTime,
pub last_used_at: Option<chrono::NaiveDateTime>,
}
impl UserSshKey {
@@ -359,6 +333,44 @@
Ok(res)
} else {
UserSession::generate(conn, self.user_id, Some(self.id), None, None, ip).await
}
}
pub async fn update_last_used(self: Arc<Self>, conn: ConnectionPool) -> Result<()> {
use crate::schema::user_ssh_keys::dsl::{id, last_used_at, user_ssh_keys};
tokio::task::spawn_blocking(move || {
let conn = conn.get()?;
diesel::update(user_ssh_keys.filter(id.eq(self.id)))
.set(last_used_at.eq(diesel::dsl::now))
.execute(&conn)
.map(|_| ())
.map_err(Into::into)
})
.await?
}
pub fn fingerprint(&self) -> Result<String> {
let key = thrussh_keys::key::parse_public_key(&self.ssh_key)?;
let raw_hex = hex::encode(
base64::decode(&key.fingerprint()).map_err(|_| thrussh_keys::Error::CouldNotReadKey)?,
);
let mut hex = String::with_capacity(raw_hex.len() + (raw_hex.len() / 2 - 1));
for (i, c) in raw_hex.chars().enumerate() {
if i != 0 && i % 2 == 0 {
hex.push(':');
}
hex.push(c);
}
Ok(hex)
}
}
@@ -19,6 +19,7 @@
};
use thrussh_keys::{key, PublicKeyBase64};
use tokio_util::codec::{Decoder, Encoder as TokioEncoder};
use log::warn;
#[tokio::main]
#[allow(clippy::semicolon_if_nothing_returned)]
@@ -183,9 +184,15 @@
Some(user) => user,
None => return self.finished_auth(server::Auth::Reject).await,
};
let ssh_key = Arc::new(ssh_key);
if let Err(e) = ssh_key.clone().update_last_used(self.db.clone()).await {
warn!("Failed to update last used key: {:?}", e);
}
self.user = Some(login_user);
self.user_ssh_key = Some(Arc::new(ssh_key));
self.user_ssh_key = Some(ssh_key);
self.finished_auth(server::Auth::Accept).await
})
}
@@ -29,8 +29,11 @@
CREATE TABLE user_ssh_keys (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
user_id INTEGER NOT NULL,
ssh_key BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users (id)
);
@@ -8,6 +8,7 @@
import { Plus, Trash } from "react-bootstrap-icons";
import { Button, Modal } from "react-bootstrap";
import HumanTime from "react-human-time";
export default function ListSshKeys() {
const auth = useAuth();
@@ -50,6 +51,9 @@
}
};
const dateMonthAgo = new Date();
dateMonthAgo.setMonth(dateMonthAgo.getMonth() - 1);
return (
<div className="text-white">
<Nav />
@@ -71,10 +75,14 @@
{sshKeys.keys.map(key => (
<tr key={key.id}>
<td className="align-middle">
<h6 className="m-0 lh-sm">{key.name}</h6>
<pre className="m-0">{key.fingerprint}</pre>
<div className="lh-sm" style={{ fontSize: '.75rem' }}>
<div>Added on 10 May 2016</div>
<div className="text-success">Last used within the last week</div>
<span className="text-muted">Added <HumanTime time={key.created_at} /></span>
<span className="mx-2"></span>
<span className={`text-${key.last_used_at ? (new Date(key.last_used_at) > dateMonthAgo ? 'success' : 'danger') : 'muted'}`}>
Last used {key.last_used_at ? <HumanTime time={key.last_used_at} /> : <>never</>}
</span>
</div>
</td>
@@ -31,7 +31,7 @@
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Json(req): extract::Json<Request>,
user_agent: Option<extract::TypedHeader<headers::UserAgent>>,
extract::ConnectInfo(addr) : extract::ConnectInfo<std::net::SocketAddr>,
extract::ConnectInfo(addr): extract::ConnectInfo<std::net::SocketAddr>,
) -> Result<Json<Response>, Error> {
let user = User::find_by_username(db.clone(), req.username)
@@ -1,6 +1,8 @@
use chartered_db::{users::User, ConnectionPool};
use axum::{extract, Json};
use chrono::NaiveDateTime;
use log::warn;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use thiserror::Error;
@@ -13,7 +15,10 @@
#[derive(Serialize)]
pub struct GetResponseKey {
id: i32,
name: String,
fingerprint: String,
created_at: NaiveDateTime,
last_used_at: Option<NaiveDateTime>,
}
pub async fn handle_get(
@@ -24,7 +29,16 @@
.list_ssh_keys(db)
.await?
.into_iter()
.map(|(id, fingerprint)| GetResponseKey { id, fingerprint })
.map(|key| GetResponseKey {
id: key.id,
fingerprint: key.fingerprint().unwrap_or_else(|e| {
warn!("Failed to parse key with id {}: {}", key.id, e);
"INVALID".to_string()
}),
name: key.name,
created_at: key.created_at,
last_used_at: key.last_used_at,
})
.collect();
Ok(Json(GetResponse { keys }))