Merge branch 'master' of git.silversoft.se:Imbus/FrostByte
This commit is contained in:
commit
44373dd2e2
7 changed files with 424 additions and 279 deletions
475
server/Cargo.lock
generated
475
server/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue