auth: Initial JWT implementation
We'll use a JWT in the `Authorization` request header to identify the user saving a page. The token will need to be set in the _authorization token_ field in the SingleFile configuration so it will be included when uploading.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
/Rocket.toml
|
/Rocket.toml
|
||||||
|
/jwt.secret
|
||||||
/meilisearch.token
|
/meilisearch.token
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1786,6 +1786,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
|
"jsonwebtoken",
|
||||||
"markup5ever_rcdom",
|
"markup5ever_rcdom",
|
||||||
"meilisearch-sdk",
|
"meilisearch-sdk",
|
||||||
"rand 0.9.0",
|
"rand 0.9.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ edition = "2021"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = { version = "0.4.40", default-features = false, features = ["std", "clock", "serde"] }
|
chrono = { version = "0.4.40", default-features = false, features = ["std", "clock", "serde"] }
|
||||||
html5ever = "0.27.0"
|
html5ever = "0.27.0"
|
||||||
|
jsonwebtoken = { version = "9.3.1", default-features = false }
|
||||||
markup5ever_rcdom = "0.3.0"
|
markup5ever_rcdom = "0.3.0"
|
||||||
meilisearch-sdk = "0.28.0"
|
meilisearch-sdk = "0.28.0"
|
||||||
rand = "0.9.0"
|
rand = "0.9.0"
|
||||||
|
|||||||
37
examples/make-token.rs
Normal file
37
examples/make-token.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct UserClaims {
|
||||||
|
aud: String,
|
||||||
|
exp: u64,
|
||||||
|
iat: u64,
|
||||||
|
iss: String,
|
||||||
|
nbf: u64,
|
||||||
|
sub: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<_> = std::env::args().collect();
|
||||||
|
let mut secret = vec![];
|
||||||
|
let mut f = std::fs::File::open(&args[1]).unwrap();
|
||||||
|
f.read_to_end(&mut secret).unwrap();
|
||||||
|
let k = EncodingKey::from_secret(&secret);
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
let claims = UserClaims {
|
||||||
|
aud: env!("CARGO_PKG_NAME").into(),
|
||||||
|
exp: now + 604800,
|
||||||
|
iat: now,
|
||||||
|
iss: env!("CARGO_PKG_NAME").into(),
|
||||||
|
nbf: now - 60,
|
||||||
|
sub: args[2].to_string(),
|
||||||
|
};
|
||||||
|
let jwt = encode(&Header::default(), &claims, &k).unwrap();
|
||||||
|
println!("{}", jwt);
|
||||||
|
}
|
||||||
201
src/auth.rs
Normal file
201
src/auth.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation};
|
||||||
|
use rocket::http::Status;
|
||||||
|
use rocket::request::{FromRequest, Outcome, Request};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::Context;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct UserClaims {
|
||||||
|
pub aud: String,
|
||||||
|
pub exp: u64,
|
||||||
|
pub iat: u64,
|
||||||
|
pub iss: String,
|
||||||
|
pub nbf: u64,
|
||||||
|
pub sub: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for UserClaims {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
let secret = &req.rocket().state::<Context>().unwrap().jwt_secret;
|
||||||
|
match req.headers().get_one("Authorization") {
|
||||||
|
Some(s) => match extract_token(secret, s) {
|
||||||
|
Ok(d) => Outcome::Success(d.claims),
|
||||||
|
Err(e) => Outcome::Error((Status::Unauthorized, e)),
|
||||||
|
},
|
||||||
|
None => Outcome::Error((Status::Unauthorized, "Unauthorized")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the JWT secret from the path specified in the configuration
|
||||||
|
pub fn load_secret(config: &Config) -> Result<Vec<u8>, std::io::Error> {
|
||||||
|
let mut secret = vec![];
|
||||||
|
let mut f = std::fs::File::open(&config.auth.jwt_secret)?;
|
||||||
|
f.read_to_end(&mut secret)?;
|
||||||
|
Ok(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_token(
|
||||||
|
secret: &[u8],
|
||||||
|
header: &str,
|
||||||
|
) -> Result<TokenData<UserClaims>, &'static str> {
|
||||||
|
let mut iter = header.split_ascii_whitespace();
|
||||||
|
let scheme = iter.next();
|
||||||
|
let token = iter.next();
|
||||||
|
if token.is_some() && Some("Bearer") != scheme {
|
||||||
|
return Err("Unsupported authorization scheme");
|
||||||
|
}
|
||||||
|
if let Some(token) = token {
|
||||||
|
let mut v = Validation::new(Algorithm::HS256);
|
||||||
|
v.validate_nbf = true;
|
||||||
|
v.set_issuer(&[env!("CARGO_PKG_NAME")]);
|
||||||
|
v.set_audience(&[env!("CARGO_PKG_NAME")]);
|
||||||
|
let k = DecodingKey::from_secret(secret);
|
||||||
|
match decode(token, &k, &v) {
|
||||||
|
Ok(d) => Ok(d),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to decode auth token: {}", e);
|
||||||
|
Err("Invalid token")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err("Invalid Authorization header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
static SECRET: &[u8; 32] = b"6gmrLQ4PGjeUpT3Xs48thx9Cu6XE5pgD";
|
||||||
|
|
||||||
|
fn now() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_token(claims: &UserClaims) -> String {
|
||||||
|
let k = EncodingKey::from_secret(SECRET);
|
||||||
|
encode(&Header::default(), claims, &k).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_token() {
|
||||||
|
let now = now();
|
||||||
|
let claims = UserClaims {
|
||||||
|
aud: env!("CARGO_PKG_NAME").into(),
|
||||||
|
exp: now + 60,
|
||||||
|
iat: now,
|
||||||
|
iss: env!("CARGO_PKG_NAME").into(),
|
||||||
|
nbf: now - 60,
|
||||||
|
sub: "test1".into(),
|
||||||
|
};
|
||||||
|
let jwt = make_token(&claims);
|
||||||
|
let header = format!("Bearer {}", jwt);
|
||||||
|
let data = extract_token(SECRET, &header).unwrap();
|
||||||
|
assert_eq!(claims, data.claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_token_expired() {
|
||||||
|
let now = now() - 600;
|
||||||
|
let claims = UserClaims {
|
||||||
|
aud: env!("CARGO_PKG_NAME").into(),
|
||||||
|
exp: now + 60,
|
||||||
|
iat: now,
|
||||||
|
iss: env!("CARGO_PKG_NAME").into(),
|
||||||
|
nbf: now - 60,
|
||||||
|
sub: "test1".into(),
|
||||||
|
};
|
||||||
|
let jwt = make_token(&claims);
|
||||||
|
let header = format!("Bearer {}", jwt);
|
||||||
|
let err = extract_token(SECRET, &header).unwrap_err();
|
||||||
|
assert_eq!(err, "Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_token_future() {
|
||||||
|
let now = now() + 600;
|
||||||
|
let claims = UserClaims {
|
||||||
|
aud: env!("CARGO_PKG_NAME").into(),
|
||||||
|
exp: now + 60,
|
||||||
|
iat: now,
|
||||||
|
iss: env!("CARGO_PKG_NAME").into(),
|
||||||
|
nbf: now - 60,
|
||||||
|
sub: "test1".into(),
|
||||||
|
};
|
||||||
|
let jwt = make_token(&claims);
|
||||||
|
let header = format!("Bearer {}", jwt);
|
||||||
|
let err = extract_token(SECRET, &header).unwrap_err();
|
||||||
|
assert_eq!(err, "Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_token_bad_issuer() {
|
||||||
|
let now = now();
|
||||||
|
let claims = UserClaims {
|
||||||
|
aud: env!("CARGO_PKG_NAME").into(),
|
||||||
|
exp: now + 60,
|
||||||
|
iat: now,
|
||||||
|
iss: "mallory".into(),
|
||||||
|
nbf: now - 60,
|
||||||
|
sub: "test1".into(),
|
||||||
|
};
|
||||||
|
let jwt = make_token(&claims);
|
||||||
|
let header = format!("Bearer {}", jwt);
|
||||||
|
let err = extract_token(SECRET, &header).unwrap_err();
|
||||||
|
assert_eq!(err, "Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_token_bad_aud() {
|
||||||
|
let now = now();
|
||||||
|
let claims = UserClaims {
|
||||||
|
aud: "mallory".into(),
|
||||||
|
exp: now + 60,
|
||||||
|
iat: now,
|
||||||
|
iss: env!("CARGO_PKG_NAME").into(),
|
||||||
|
nbf: now - 60,
|
||||||
|
sub: "test1".into(),
|
||||||
|
};
|
||||||
|
let jwt = make_token(&claims);
|
||||||
|
let header = format!("Bearer {}", jwt);
|
||||||
|
let err = extract_token(SECRET, &header).unwrap_err();
|
||||||
|
assert_eq!(err, "Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_malformed_header() {
|
||||||
|
let now = now();
|
||||||
|
let claims = UserClaims {
|
||||||
|
aud: "mallory".into(),
|
||||||
|
exp: now + 60,
|
||||||
|
iat: now,
|
||||||
|
iss: env!("CARGO_PKG_NAME").into(),
|
||||||
|
nbf: now - 60,
|
||||||
|
sub: "test1".into(),
|
||||||
|
};
|
||||||
|
let jwt = make_token(&claims);
|
||||||
|
let err = extract_token(SECRET, &jwt).unwrap_err();
|
||||||
|
assert_eq!(err, "Invalid Authorization header");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unsupported_auth_scheme() {
|
||||||
|
let header = "Basic dXNlcm5hbWU6cGFzc3dvcmQ=";
|
||||||
|
let err = extract_token(SECRET, header).unwrap_err();
|
||||||
|
assert_eq!(err, "Unsupported authorization scheme");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -9,8 +11,14 @@ pub struct MeilisearchConfig {
|
|||||||
pub index: String,
|
pub index: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
pub jwt_secret: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
pub auth: AuthConfig,
|
||||||
pub meilisearch: MeilisearchConfig,
|
pub meilisearch: MeilisearchConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
src/main.rs
15
src/main.rs
@@ -1,3 +1,4 @@
|
|||||||
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod meilisearch;
|
mod meilisearch;
|
||||||
mod page;
|
mod page;
|
||||||
@@ -15,18 +16,27 @@ struct Context {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
config: Config,
|
config: Config,
|
||||||
client: MeilisearchClient,
|
client: MeilisearchClient,
|
||||||
|
jwt_secret: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
enum InitError {
|
enum InitError {
|
||||||
#[error("Meilisearch error: {0}")]
|
#[error("Meilisearch error: {0}")]
|
||||||
Meilisearch(#[from] meilisearch::Error),
|
Meilisearch(#[from] meilisearch::Error),
|
||||||
|
#[error("Failed to load JWT secret: {0}")]
|
||||||
|
LoadSecret(std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
pub fn init(config: Config) -> Result<Self, InitError> {
|
pub fn init(config: Config) -> Result<Self, InitError> {
|
||||||
let client = MeilisearchClient::try_from(&config)?;
|
let client = MeilisearchClient::try_from(&config)?;
|
||||||
Ok(Self { config, client })
|
let jwt_secret =
|
||||||
|
auth::load_secret(&config).map_err(InitError::LoadSecret)?;
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
client,
|
||||||
|
jwt_secret,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +52,11 @@ pub struct SavePageForm {
|
|||||||
/// Save a visited page in SingleFile format
|
/// Save a visited page in SingleFile format
|
||||||
#[rocket::post("/save", data = "<form>")]
|
#[rocket::post("/save", data = "<form>")]
|
||||||
async fn save_page(
|
async fn save_page(
|
||||||
|
user: auth::UserClaims,
|
||||||
form: Form<SavePageForm>,
|
form: Form<SavePageForm>,
|
||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
) -> Result<Json<page::Page>, String> {
|
) -> Result<Json<page::Page>, String> {
|
||||||
match page::save_page(&form.url, &form.data, ctx).await {
|
match page::save_page(&form.url, &form.data, ctx, &user).await {
|
||||||
Ok(p) => Ok(Json(p)),
|
Ok(p) => Ok(Json(p)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to save page: {}", e);
|
error!("Failed to save page: {}", e);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use rand::Rng;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::{debug, event, span, Level};
|
use tracing::{debug, event, span, Level};
|
||||||
|
|
||||||
|
use crate::auth::UserClaims;
|
||||||
use crate::Context;
|
use crate::Context;
|
||||||
|
|
||||||
static ID_CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
static ID_CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||||
@@ -16,6 +17,8 @@ static ID_CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
|||||||
pub struct Page {
|
pub struct Page {
|
||||||
/// Unique saved page ID
|
/// Unique saved page ID
|
||||||
id: String,
|
id: String,
|
||||||
|
/// User ID of page owner
|
||||||
|
user_id: String,
|
||||||
/// Visit timestamp
|
/// Visit timestamp
|
||||||
timestamp: DateTime<Utc>,
|
timestamp: DateTime<Utc>,
|
||||||
/// Page URL
|
/// Page URL
|
||||||
@@ -31,14 +34,16 @@ pub async fn save_page(
|
|||||||
url: &str,
|
url: &str,
|
||||||
data: &str,
|
data: &str,
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
|
user: &UserClaims,
|
||||||
) -> Result<Page, Error> {
|
) -> Result<Page, Error> {
|
||||||
let span = span!(Level::INFO, "save_page", url = url);
|
let span = span!(Level::INFO, "save_page", url = url, user = user.sub);
|
||||||
let _guard = span.enter();
|
let _guard = span.enter();
|
||||||
let index_name = &ctx.config.meilisearch.index;
|
let index_name = &ctx.config.meilisearch.index;
|
||||||
debug!("Saving page in Meilisearch index {}", index_name);
|
debug!("Saving page in Meilisearch index {}", index_name);
|
||||||
let index = ctx.client.get_index(index_name).await?;
|
let index = ctx.client.get_index(index_name).await?;
|
||||||
let doc = Page {
|
let doc = Page {
|
||||||
id: gen_id(),
|
id: gen_id(),
|
||||||
|
user_id: user.sub.clone(),
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
url: url.into(),
|
url: url.into(),
|
||||||
title: extract_title(data),
|
title: extract_title(data),
|
||||||
|
|||||||
Reference in New Issue
Block a user