Merge branch 'master' of git.silversoft.se:Imbus/FrostByte

This commit is contained in:
Imbus 2024-02-05 03:15:31 +01:00
commit 44373dd2e2
7 changed files with 424 additions and 279 deletions

475
server/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,58 +1,114 @@
use jsonwebtoken::{ use jsonwebtoken::{
decode, encode, errors::Result as JwtResult, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::Result as JwtResult, DecodingKey, EncodingKey, Header, Validation,
}; };
use log::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const DAYS_VALID: i64 = 7; /// Claims holds the data that will be encoded into the JWT token.
const JWT_SECRET: &[u8] = "secret".as_bytes();
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Claims { pub struct Claims {
#[serde(rename = "sub")]
pub sub: String, pub sub: String,
#[serde(rename = "iss")]
pub iss: String, pub iss: String,
pub aud: String, #[serde(rename = "iat")]
pub iat: usize, pub iat: usize,
#[serde(rename = "exp")]
pub exp: usize, pub exp: usize,
} }
// JwtResult is just a predefined error from the jsonwebtoken crate impl Claims {
pub fn token_factory(user: &str) -> JwtResult<String> { pub fn new(sub: &str, days: i64) -> Self {
info!("Issuing JWT token for {}", user); Claims {
sub: sub.to_string(),
let token = encode(
&Header::default(),
&Claims {
sub: user.to_string(),
iss: "frostbyte".to_string(), iss: "frostbyte".to_string(),
aud: "frostbyte".to_string(),
iat: chrono::Utc::now().timestamp() as usize, iat: chrono::Utc::now().timestamp() as usize,
exp: (chrono::Utc::now() + chrono::Duration::days(DAYS_VALID)).timestamp() as usize, exp: (chrono::Utc::now() + chrono::Duration::days(days)).timestamp() as usize,
},
&EncodingKey::from_secret(JWT_SECRET),
)
.unwrap();
Ok(token)
}
// JwtResult is just a predefined error from the jsonwebtoken crate
// This function is incomplete and should be expanded to check for more things
pub fn validate_token(token: &str) -> JwtResult<Claims> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(JWT_SECRET),
&Validation::default(),
);
match token_data {
Ok(token_data) => {
info!("Token validated for {}", token_data.claims.sub);
Ok(token_data.claims)
}
Err(e) => {
error!("Token validation failed: {}", e);
Err(e)
} }
} }
} }
/// Authentication holds the data needed to encode and decode JWT tokens.
// This is then passed to the AuthenticationMiddleware
#[derive(Clone)]
pub struct Authentication {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
validation: Validation,
days_valid: i64, // chrono::Duration::days() takes an i64, we don't want to cast it every time
}
impl Authentication {
/// Create a new Authentication struct
pub fn new(secret: &[u8]) -> Self {
Authentication {
encoding_key: EncodingKey::from_secret(secret),
decoding_key: DecodingKey::from_secret(secret),
validation: Validation::default(),
days_valid: 7,
}
}
/// Encode the Claims struct into a JWT token, this is the raw version
fn encode_raw(&self, claims: Claims) -> JwtResult<String> {
encode(&Header::default(), &claims, &self.encoding_key)
}
/// Wrapper for encode_raw that takes a username (sub) and creates a Claims struct
pub fn encode(&self, sub: &str) -> JwtResult<String> {
let claims = Claims::new(sub, self.days_valid);
self.encode_raw(claims)
}
/// Decode a JWT token into a Claims struct
// If this faie, it means the token is invalid
pub fn decode(&self, token: &str) -> JwtResult<Claims> {
decode::<Claims>(token, &self.decoding_key, &self.validation).map(|data| data.claims)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth() {
let username: &str = "testuser";
let auth = Authentication::new("secret".as_bytes());
assert!(auth.encode(username).is_ok());
let token = auth.encode(username);
assert!(!token.is_err());
let token = token.unwrap();
assert!(!token.is_empty());
}
#[test]
fn test_validate() {
let username: &str = "testuser";
let auth = Authentication::new("secret".as_bytes());
let token = auth.encode(username).unwrap();
let claims = auth.decode(&token).unwrap();
assert_eq!(claims.sub, username);
}
#[test]
fn test_invalid() {
let auth = Authentication::new("secret".as_bytes());
let token = auth.encode("testuser").unwrap();
// Remove the first character should invalidate the token
let token = token[1..].to_string();
assert!(auth.decode(&token).is_err());
}
#[test]
fn test_expired() {
let auth = Authentication::new("secret".as_bytes());
// Chrono::duration allows negative durations, -1 is yesterday in this case
let claims = Claims::new("testuser", -1);
let token = auth.encode_raw(claims).unwrap();
assert!(auth.decode(&token).is_err());
}
}

View file

@ -12,6 +12,7 @@ mod state;
mod types; mod types;
mod util; mod util;
use jwt::Authentication;
use routes::{get_comments, get_posts, login, new_comment, new_post, post_by_id, register}; use routes::{get_comments, get_posts, login, new_comment, new_post, post_by_id, register};
use state::CaptchaState; use state::CaptchaState;
use state::ServerState; use state::ServerState;
@ -23,6 +24,7 @@ async fn main() -> std::io::Result<()> {
let data = ServerState::new().await; let data = ServerState::new().await;
let capt_db = CaptchaState::new(); let capt_db = CaptchaState::new();
let auth = Authentication::new("secret".as_bytes());
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
@ -37,10 +39,13 @@ async fn main() -> std::io::Result<()> {
HttpServer::new(move || { HttpServer::new(move || {
let cors = Cors::default() let cors = Cors::default()
.allowed_origin("https://shitpost.se") .allowed_origin("https://shitpost.se")
.allowed_origin("http://localhost:8080")
.allowed_methods(vec!["GET", "POST"]) .allowed_methods(vec!["GET", "POST"])
.max_age(3600); .max_age(3600);
// In debug mode, allow localhost
#[cfg(debug_assertions)]
let cors = cors.allowed_origin("http://localhost:8080");
App::new() App::new()
.wrap(cors) .wrap(cors)
.wrap(middleware::Compress::default()) .wrap(middleware::Compress::default())
@ -56,7 +61,8 @@ async fn main() -> std::io::Result<()> {
.service(login) .service(login)
.service(register) .service(register)
.app_data(Data::new(data.clone())) .app_data(Data::new(data.clone()))
.app_data(Data::new(capt_db.clone())), .app_data(Data::new(capt_db.clone()))
.app_data(Data::new(auth.clone())),
) )
.service( .service(
Files::new("/", "./public") Files::new("/", "./public")

View file

@ -1,5 +1,5 @@
use crate::db::{db_get_comments, db_new_comment}; use crate::db::{db_get_comments, db_new_comment};
use crate::jwt::validate_token; use crate::jwt::Authentication;
use crate::types::{CommentQueryParams, NewComment}; use crate::types::{CommentQueryParams, NewComment};
use crate::ServerState; use crate::ServerState;
@ -31,8 +31,9 @@ pub async fn get_comments(
pub async fn new_comment( pub async fn new_comment(
data: Json<NewComment>, data: Json<NewComment>,
state: Data<ServerState>, state: Data<ServerState>,
auth: Data<Authentication>,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
let user_claims = validate_token(&data.user_token); let user_claims = auth.decode(&data.user_token);
// Bail if the token is invalid // Bail if the token is invalid
if let Err(e) = user_claims { if let Err(e) = user_claims {

View file

@ -1,5 +1,5 @@
use crate::db::{db_get_latest_posts, db_get_post, db_new_post}; use crate::db::{db_get_latest_posts, db_get_post, db_new_post};
use crate::jwt::validate_token; use crate::jwt::Authentication;
use crate::types::{NewPost, PostQueryParams}; use crate::types::{NewPost, PostQueryParams};
use crate::ServerState; use crate::ServerState;
@ -23,8 +23,12 @@ pub async fn get_posts(
/// Creates a new post, requires a token in release mode /// Creates a new post, requires a token in release mode
#[post("/posts")] #[post("/posts")]
pub async fn new_post(new_post: Json<NewPost>, state: Data<ServerState>) -> Result<impl Responder> { pub async fn new_post(
let user_claims = validate_token(&new_post.token); new_post: Json<NewPost>,
state: Data<ServerState>,
auth: Data<Authentication>,
) -> Result<impl Responder> {
let user_claims = auth.decode(&new_post.token);
if let Err(e) = user_claims { if let Err(e) = user_claims {
info!("Error validating token: {}", e); info!("Error validating token: {}", e);

View file

@ -1,5 +1,6 @@
use crate::db::{db_new_user, db_user_login}; use crate::db::{db_new_user, db_user_login};
use crate::jwt::token_factory; use crate::jwt::Authentication;
// use crate::jwt::token_factory;
use crate::state::CaptchaState; use crate::state::CaptchaState;
use crate::types::{AuthResponse, LoginData, RegisterData}; use crate::types::{AuthResponse, LoginData, RegisterData};
use crate::ServerState; use crate::ServerState;
@ -14,6 +15,7 @@ pub async fn register(
data: Json<RegisterData>, data: Json<RegisterData>,
state: Data<ServerState>, state: Data<ServerState>,
captcha_state: Data<CaptchaState>, captcha_state: Data<CaptchaState>,
auth: Data<Authentication>,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
if !captcha_state if !captcha_state
.capthca_db .capthca_db
@ -30,7 +32,7 @@ pub async fn register(
info!("User: {} registered", &user.username); info!("User: {} registered", &user.username);
Ok(HttpResponse::Ok().json(AuthResponse { Ok(HttpResponse::Ok().json(AuthResponse {
username: user.username.clone(), username: user.username.clone(),
token: token_factory(&user.username).unwrap(), token: auth.encode(&user.username).unwrap(),
})) }))
} }
None => { None => {
@ -41,14 +43,18 @@ pub async fn register(
} }
#[post("/login")] #[post("/login")]
pub async fn login(data: Json<LoginData>, state: Data<ServerState>) -> Result<impl Responder> { pub async fn login(
data: Json<LoginData>,
state: Data<ServerState>,
auth: Data<Authentication>,
) -> Result<impl Responder> {
let result = db_user_login(data.username.clone(), data.password.clone(), &state.pool).await; let result = db_user_login(data.username.clone(), data.password.clone(), &state.pool).await;
match result { match result {
Some(_) => { Some(_) => {
return Ok(HttpResponse::Ok().json(AuthResponse { return Ok(HttpResponse::Ok().json(AuthResponse {
username: data.username.clone(), username: data.username.clone(),
token: token_factory(&data.username).unwrap(), token: auth.encode(&data.username).unwrap(),
})); }));
} }
None => { None => {

View file

@ -55,9 +55,12 @@ impl ServerState {
None => error!("Failed to create default user..."), None => error!("Failed to create default user..."),
} }
// We want dummy posts
lipsum_setup(&pool).await.unwrap();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
debug_setup(&pool).await.unwrap(); debug_setup(&pool).await.unwrap();
lipsum_setup(&pool).await.unwrap();
Self { pool } Self { pool }
} }
} }
@ -66,48 +69,18 @@ impl ServerState {
// Mostly useful for debugging new posts, as we need to satisfy foreign key constraints. // Mostly useful for debugging new posts, as we need to satisfy foreign key constraints.
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
async fn debug_setup(pool: &PgPool) -> Result<(), sqlx::Error> { async fn debug_setup(pool: &PgPool) -> Result<(), sqlx::Error> {
use lipsum::lipsum;
use rand::prelude::*;
use sqlx::query;
use crate::db::db_new_user; use crate::db::db_new_user;
db_new_user("user".to_string(), "pass".to_string(), pool).await; db_new_user("user".to_string(), "pass".to_string(), pool).await;
// Check if the demo post already exists
let no_posts = query!("SELECT * FROM posts WHERE id = 1",)
.fetch_one(pool)
.await
.ok()
.is_none();
// If the demo user already has a post, don't insert another one
if no_posts {
let mut rng = rand::thread_rng();
// This requires that the user with id 1 exists in the user table
for _ in 0..100 {
query!(
"INSERT INTO posts (user_id, content) VALUES (1, $1)",
lipsum(rng.gen_range(10..100))
)
.execute(pool)
.await?;
}
query!("INSERT INTO posts (user_id, content) VALUES (1, 'Hello world! The demo username is user and the password is pass.')",)
.execute(pool)
.await?;
}
Ok(()) Ok(())
} }
/// Inserts a bunch of dummy posts into the database
async fn lipsum_setup(pool: &PgPool) -> Result<(), sqlx::Error> { async fn lipsum_setup(pool: &PgPool) -> Result<(), sqlx::Error> {
use lipsum::lipsum; use lipsum::lipsum;
use rand::prelude::*; use rand::prelude::*;
use sqlx::query; use sqlx::query;
// Fetch any user
let user_exist = query!("SELECT * FROM users",) let user_exist = query!("SELECT * FROM users",)
.fetch_one(pool) .fetch_one(pool)
.await .await
@ -117,6 +90,7 @@ async fn lipsum_setup(pool: &PgPool) -> Result<(), sqlx::Error> {
if user_exist { if user_exist {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
// Insert a 100 dummy posts.
// This requires that the user with id 1 exists in the user table // This requires that the user with id 1 exists in the user table
for _ in 0..100 { for _ in 0..100 {
query!( query!(
@ -130,3 +104,16 @@ async fn lipsum_setup(pool: &PgPool) -> Result<(), sqlx::Error> {
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_state() {
let state = CaptchaState::new();
assert!(state.capthca_db.lock().unwrap().is_empty());
state.capthca_db.lock().unwrap().insert("test".to_string());
assert!(!state.capthca_db.lock().unwrap().is_empty());
}
}