use std::ops::Deref;
use std::sync::Arc;
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use super::tokens::sha256_hex;
use crate::Result;
use crate::crypto::{constant_time_eq, random_token};
use crate::db::{DateTime, Db, now};
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct DeviceToken {
pub id: i64,
pub device: String,
pub name: String,
pub abilities: Option<Vec<String>>,
pub last_used_at: Option<DateTime>,
pub expires_at: Option<DateTime>,
pub created_at: Option<DateTime>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct NewDeviceToken {
pub token: DeviceToken,
pub plain: String,
}
const COLUMNS: &str = "id, device, name, abilities, last_used_at, expires_at, created_at";
fn from_row(row: &crate::db::Row) -> std::result::Result<DeviceToken, crate::db::DbError> {
let abilities: Option<String> = row.try_get("abilities")?;
Ok(DeviceToken {
id: row.try_get("id")?,
device: row.try_get("device")?,
name: row.try_get("name")?,
abilities: abilities.and_then(|json| serde_json::from_str(&json).ok()),
last_used_at: row.try_get("last_used_at")?,
expires_at: row.try_get("expires_at")?,
created_at: row.try_get("created_at")?,
})
}
impl DeviceToken {
pub async fn create(
db: &Db,
device: &str,
name: &str,
abilities: Option<&[&str]>,
expires_at: Option<DateTime>,
) -> Result<NewDeviceToken> {
let secret = random_token();
let created = now();
let abilities = abilities.map(|list| serde_json::json!(list).to_string());
let row = crate::db::sql(format!(
"INSERT INTO device_tokens (device, name, abilities, token, expires_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING {COLUMNS}"
))
.bind(device)
.bind(name)
.bind(abilities)
.bind(sha256_hex(&secret))
.bind(expires_at)
.bind(created)
.bind(created)
.fetch_one(db)
.await?;
let token = from_row(&row)?;
Ok(NewDeviceToken {
plain: format!("d{}|{secret}", token.id),
token,
})
}
pub async fn for_device(db: &Db, device: &str) -> Result<Vec<DeviceToken>> {
let rows = crate::db::sql(format!(
"SELECT {COLUMNS} FROM device_tokens WHERE device = ? ORDER BY id DESC"
))
.bind(device)
.fetch_all(db)
.await?;
Ok(rows
.iter()
.map(from_row)
.collect::<std::result::Result<_, _>>()?)
}
pub async fn revoke(db: &Db, device: &str, token_id: i64) -> Result<bool> {
let done = crate::db::sql("DELETE FROM device_tokens WHERE id = ? AND device = ?")
.bind(token_id)
.bind(device)
.execute(db)
.await?;
Ok(done > 0)
}
pub async fn revoke_all(db: &Db, device: &str) -> Result<u64> {
Ok(crate::db::sql("DELETE FROM device_tokens WHERE device = ?")
.bind(device)
.execute(db)
.await?)
}
pub async fn prune_expired(db: &Db, grace: std::time::Duration) -> Result<u64> {
let before = now() - chrono::Duration::from_std(grace).unwrap_or_default();
Ok(
crate::db::sql("DELETE FROM device_tokens WHERE expires_at < ?")
.bind(before)
.execute(db)
.await?,
)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Device {
key: String,
token_id: i64,
name: String,
abilities: Option<Arc<Vec<String>>>,
}
impl Device {
pub fn key(&self) -> &str {
&self.key
}
pub fn token_id(&self) -> i64 {
self.token_id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn can(&self, ability: &str) -> bool {
self.abilities
.as_ref()
.is_none_or(|list| list.iter().any(|a| a == ability || a == "*"))
}
pub fn id_of(&self, kind: &str) -> Option<&str> {
self.key.strip_prefix(kind)?.strip_prefix(':')
}
}
impl Deref for Device {
type Target = str;
fn deref(&self) -> &str {
&self.key
}
}
fn unauthenticated() -> Response {
let body = serde_json::json!({ "message": "Unauthenticated." });
(StatusCode::UNAUTHORIZED, axum::Json(body)).into_response()
}
pub(super) fn current(extensions: &axum::http::Extensions) -> Option<Device> {
extensions
.get::<super::CurrentUser>()?
.device
.as_deref()
.cloned()
}
impl<S: Send + Sync> FromRequestParts<S> for Device {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _: &S) -> std::result::Result<Self, Response> {
current(&parts.extensions).ok_or_else(unauthenticated)
}
}
impl<S: Send + Sync> OptionalFromRequestParts<S> for Device {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
_: &S,
) -> std::result::Result<Option<Self>, std::convert::Infallible> {
Ok(current(&parts.extensions))
}
}
pub(super) async fn authenticate(db: &Db, bearer: &str) -> Result<Option<Device>> {
let Some((id, secret)) = bearer.strip_prefix('d').and_then(|b| b.split_once('|')) else {
return Ok(None);
};
let Ok(id) = id.parse::<i64>() else {
return Ok(None);
};
let Some(row) = crate::db::sql(
"SELECT device, name, token, abilities, expires_at FROM device_tokens WHERE id = ?",
)
.bind(id)
.fetch_optional(db)
.await?
else {
return Ok(None);
};
let hash: String = row.try_get("token")?;
let expires_at: Option<DateTime> = row.try_get("expires_at")?;
if !constant_time_eq(&hash, &sha256_hex(secret)) || expires_at.is_some_and(|at| at <= now()) {
return Ok(None);
}
crate::db::sql("UPDATE device_tokens SET last_used_at = ? WHERE id = ?")
.bind(now())
.bind(id)
.execute(db)
.await?;
let abilities: Option<String> = row.try_get("abilities")?;
Ok(Some(Device {
key: row.try_get("device")?,
token_id: id,
name: row.try_get("name")?,
abilities: abilities
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
.map(Arc::new),
}))
}
pub(crate) async fn require(
ability: Option<Arc<String>>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let Some(device) = current(req.extensions()) else {
return unauthenticated();
};
if ability.is_some_and(|a| !device.can(&a)) {
return crate::Error::Forbidden.into_response();
}
next.run(req).await
}