refactor auth, maybe bug with hashing

This commit is contained in:
2024-09-03 17:06:23 -04:00
parent cc74be72be
commit f0fd79bed8
25 changed files with 776 additions and 1340 deletions

13
.env
View File

@@ -1,12 +1,13 @@
RUST_LOG=warn,service=info RUST_LOG=warn,service=info
DATABASE_CONTAINER=aviation-db
DATABASE_USER=aviation DATABASE_USER=aviation
DATABASE_PASSWORD= DATABASE_PASSWORD=
DATABASE_NAME=aviation DATABASE_NAME=aviation
DATABASE_HOST=db DATABASE_HOST=localhost
DATABASE_PORT=5432 DATABASE_PORT=5432
REDIS_HOST=redis REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
MINIO_ROOT_USER=aviation MINIO_ROOT_USER=aviation
@@ -15,11 +16,9 @@ MINIO_HOST=localhost
MINIO_PORT=9000 MINIO_PORT=9000
MINIO_PORT_INTERNAL=9001 MINIO_PORT_INTERNAL=9001
SERVICE_HOST=service SERVICE_HOST=localhost
SERVICE_PORT=5000 SERVICE_PORT=5000
UI_PORT=3000
KEYS_DIR_PATH= NODE_ENV=development
ACCESS_TOKEN_MAXAGE=5
REFRESH_TOKEN_MAXAGE=30
GOV_API_URL=https://aviationweather.gov/cgi-bin/data GOV_API_URL=https://aviationweather.gov/cgi-bin/data

View File

@@ -14,29 +14,39 @@ help: ## This info
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo @echo
build: ## Build Docker containers format: ## Format code
docker compose build @cd service && cargo fmt
@cd ui && npm run format
tag: ## Tag Docker images backend-up: ## Start Docker containers
docker tag aviation-ui:latest aviation-ui:${GIT_HASH} @docker compose --profile backend up -d
docker tag aviation-service:latest aviation-service:${GIT_HASH}
up: ## Start Docker containers up-backend: backend-up
docker compose up -d
down: ## Stop Docker containers backend-down: ## Stop Docker containers
docker compose down @docker compose --profile backend down
clean: ## Cleanup Docker containers down-backend: backend-down
docker compose down && \
docker image rm aviation-ui || \
docker image rm aviation-service || \
docker network rm aviation-frontend || \
docker network rm aviation-backend
generate: ## Generate RSA keys run: ## Run the api
mkdir keys @cd service && cargo run
openssl genrsa -out keys/access_private_key.pem 4096
openssl rsa -in keys/access_private_key.pem -pubout -outform PEM -out keys/access_public_key.pem frontend-up: ## Start Docker containers
openssl genrsa -out keys/refresh_private_key.pem 4096 @docker compose --profile frontend up -d
openssl rsa -in keys/refresh_private_key.pem -pubout -outform PEM -out keys/refresh_public_key.pem
up-frontend: frontend-up
frontend-down: ## Stop Docker containers
@docker compose --profile frontend down
down-frontend: frontend-down
docker-clean: ## Stop the docker containers and remove volumes
@echo "Stopping docker container and removing volumes..."
@docker compose --profile frontend --profile api --profile backend down -v
@echo "Docker container stopped and volumes removed"
docker-refresh: docker-clean up-backend ## Refresh the database
psql: ## Connect to the PSQL DB
@docker exec -it ${DATABASE_CONTAINER} psql -U ${DATABASE_USER} -P pager=off

View File

@@ -1,12 +1,15 @@
version: '3' x-env_file: &env
- path: .env
required: true
- path: .env.local
required: false
name: aviation name: aviation
services: services:
db: db:
image: postgis/postgis:latest image: postgis/postgis:latest
container_name: aviation-db container_name: aviation-db
env_file: env_file: *env
- .env
environment: environment:
POSTGRES_USER: ${DATABASE_USER} POSTGRES_USER: ${DATABASE_USER}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD} POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
@@ -18,7 +21,10 @@ services:
- "${DATABASE_PORT:-5432}:5432" - "${DATABASE_PORT:-5432}:5432"
networks: networks:
- backend - backend
profiles:
- backend
restart: unless-stopped restart: unless-stopped
redis: redis:
image: redis:latest image: redis:latest
container_name: aviation-redis container_name: aviation-redis
@@ -28,7 +34,10 @@ services:
- ${REDIS_PORT:-6379}:6379 - ${REDIS_PORT:-6379}:6379
networks: networks:
- backend - backend
profiles:
- backend
restart: unless-stopped restart: unless-stopped
minio: minio:
image: minio/minio image: minio/minio
container_name: aviation-minio container_name: aviation-minio
@@ -42,17 +51,14 @@ services:
- ${MINIO_PORT_INTERNAL:-9001}:9001 - ${MINIO_PORT_INTERNAL:-9001}:9001
networks: networks:
- backend - backend
profiles:
- backend
command: server --console-address ":9001" /data command: server --console-address ":9001" /data
restart: unless-stopped restart: unless-stopped
service: api:
container_name: aviation-service container_name: aviation-api
env_file: env_file: *env
- .env
environment:
KEYS_DIR_PATH: /keys
volumes:
- ${KEYS_DIR_PATH}:/keys
ports: ports:
- "${SERVICE_PORT:-5000}:5000" - "${SERVICE_PORT:-5000}:5000"
build: build:
@@ -64,22 +70,29 @@ services:
networks: networks:
- frontend - frontend
- backend - backend
profiles:
- api
restart: unless-stopped restart: unless-stopped
ui: ui:
container_name: aviation-ui container_name: aviation-ui
env_file: env_file: *env
- .env
environment: environment:
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-development}
ports: ports:
- ${UI_PORT:-3000}:3000 - ${UI_PORT:-3000}:3000
build: build:
context: ui context: ./ui/
depends_on: target: dev
- service volumes:
- ./ui/src:/app/src
- ./ui/public:/app/public
- ./ui/styles:/app/styles
networks: networks:
- frontend - frontend
profiles:
- frontend
command: ["npm", "run", "dev"]
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -1,27 +0,0 @@
RUST_LOG=warn,service=debug
DATABASE_CONTAINER=aviation-db
DATABASE_USER=aviation
DATABASE_PASSWORD=
DATABASE_NAME=aviation
DATABASE_HOST=localhost
DATABASE_PORT=5432
REDIS_HOST=localhost
REDIS_PORT=6379
MINIO_ROOT_USER=aviation
MINIO_ROOT_PASSWORD=
MINIO_HOST=localhost
MINIO_PORT=9000
MINIO_PORT_INTERNAL=9001
SERVICE_HOST=localhost
SERVICE_PORT=5000
KEYS_DIR_PATH=
ACCESS_TOKEN_MAXAGE=240
REFRESH_TOKEN_MAXAGE=1440
GOV_API_URL=https://aviationweather.gov/cgi-bin/data

23
service/Cargo.lock generated
View File

@@ -547,9 +547,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.5.0" version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
[[package]] [[package]]
name = "bytestring" name = "bytestring"
@@ -2206,18 +2206,18 @@ checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.204" version = "1.0.209"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.204" version = "1.0.209"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2286,6 +2286,8 @@ dependencies = [
"log", "log",
"postgis_diesel", "postgis_diesel",
"r2d2", "r2d2",
"rand",
"rand_chacha",
"redis", "redis",
"regex", "regex",
"reqwest", "reqwest",
@@ -2698,11 +2700,10 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.37" version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [ dependencies = [
"cfg-if",
"log", "log",
"pin-project-lite", "pin-project-lite",
"tracing-core", "tracing-core",
@@ -2710,9 +2711,9 @@ dependencies = [
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.31" version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
dependencies = [ dependencies = [
"once_cell", "once_cell",
] ]

View File

@@ -36,3 +36,5 @@ ahash = "0.8.11" # https://github.com/tkaitchuck/aHash/issues/200
regex = "1.10.5" regex = "1.10.5"
futures-util = "0.3.30" futures-util = "0.3.30"
rust-s3 = "0.34.0" rust-s3 = "0.34.0"
rand = "0.8.5"
rand_chacha = "0.3.1"

View File

@@ -1,64 +0,0 @@
#!make
SHELL := /bin/bash
GIT_HASH ?= $(shell git log --format="%h" -n 1)
include .env
-include .env.local
export
.PHONY: help build start stop lint
help: ## This info
@echo
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo
format: ## Format code
@echo "Formatting code..."
@cargo fmt
@echo "Format complete"
run: ## Run the service
@cargo run
clean: ## Cleanup
@echo "Cleaning up..."
@cargo clean
@rm -rf ../keys
@echo "Cleanup complete"
up: ## Start the Docker containers
@docker compose --profile backend up -d
down: ## Stop the Docker containers
@docker compose --profile backend down
connect: ## Connect to the PSQL DB
@docker exec -it ${DATABASE_CONTAINER} psql -U postgres
docker-build: ## Build the Docker image
@docker compose build
docker-tag: ## Tag the Docker image
@docker tag aviation-service:latest aviation-service:${GIT_HASH}
docker-run: ## Start the service
@docker compose --profile service up -d
docker-clean: ## Cleanup Docker containers
@docker compose --profile backend --profile service down -v
clean-db: ## Remove database
@docker exec -i ${DATABASE_CONTAINER} sh -c 'PGPASSWORD=${DATABASE_PASSWORD} psql -U ${DATABASE_USER} -d postgres -c "DROP DATABASE IF EXISTS \"${DATABASE_NAME}\";"'
@docker exec -i ${DATABASE_CONTAINER} sh -c 'PGPASSWORD=${DATABASE_PASSWORD} psql -U ${DATABASE_USER} -d postgres -c "CREATE DATABASE \"${DATABASE_NAME}\";"' || true
generate-keys: ## Generate RSA keys
@mkdir ../keys/
@openssl genrsa -out ../keys/access_private_key.pem 4096
@openssl rsa -in ../keys/access_private_key.pem -pubout -outform PEM -out ../keys/access_public_key.pem
@openssl genrsa -out ../keys/refresh_private_key.pem 4096
@openssl rsa -in ../keys/refresh_private_key.pem -pubout -outform PEM -out ../keys/refresh_public_key.pem
psql: ## Connect to the PSQL DB
@docker exec -it ${DATABASE_CONTAINER} psql -U ${DATABASE_USER}

View File

@@ -1,93 +0,0 @@
x-env_file: &env
- path: .env
required: true
- path: .env.local
required: false
name: aviation
services:
db:
image: postgis/postgis:latest
container_name: aviation-db
env_file: *env
environment:
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: ${DATABASE_NAME}
volumes:
- db:/var/lib/postgresql/data
- db_logs:/var/log
ports:
- "${DATABASE_PORT:-5432}:5432"
networks:
- backend
profiles:
- backend
restart: unless-stopped
redis:
image: redis:latest
container_name: aviation-redis
volumes:
- redis:/data
ports:
- ${REDIS_PORT:-6379}:6379
networks:
- backend
profiles:
- backend
restart: unless-stopped
minio:
image: minio/minio
container_name: aviation-minio
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio:/data
ports:
- ${MINIO_PORT:-9000}:9000
- ${MINIO_PORT_INTERNAL:-9001}:9001
networks:
- backend
profiles:
- backend
command: server --console-address ":9001" /data
restart: unless-stopped
service:
container_name: aviation-service
env_file: *env
environment:
DATABASE_HOST: db
DATABASE_PORT: 5432
REDIS_HOST: redis
REDIS_PORT: 6379
SERVICE_HOST: service
SERVICE_PORT: 5000
KEYS_DIR_PATH: /keys
volumes:
- ${KEYS_DIR_PATH}:/keys
ports:
- "${SERVICE_PORT:-5000}:5000"
build:
context: .
depends_on:
- db
- redis
- minio
networks:
- frontend
- backend
profiles:
- service
restart: unless-stopped
volumes:
db:
db_logs:
redis:
minio:
networks:
frontend:
backend:

View File

@@ -2,7 +2,7 @@ use std::fmt::Display;
use std::str::FromStr; use std::str::FromStr;
use crate::db; use crate::db;
use crate::error_handler::ServiceError; use crate::error::{ApiError, ApiResult};
use crate::db::schema::airports; use crate::db::schema::airports;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sql_query; use diesel::sql_query;
@@ -218,7 +218,7 @@ impl FromStr for QueryOrderField {
} }
impl QueryAirport { impl QueryAirport {
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> { pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> ApiResult<Vec<Self>> {
let mut conn = db::connection()?; let mut conn = db::connection()?;
let mut query: String = "SELECT * FROM airports".to_string(); let mut query: String = "SELECT * FROM airports".to_string();
query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?); query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?);
@@ -258,7 +258,7 @@ impl QueryAirport {
let airports: Vec<QueryAirport> = match sql_query(query).load(&mut conn) { let airports: Vec<QueryAirport> = match sql_query(query).load(&mut conn) {
Ok(a) => a, Ok(a) => a,
Err(err) => { Err(err) => {
return Err(ServiceError { return Err(ApiError {
status: 500, status: 500,
message: format!("{}", err), message: format!("{}", err),
}) })
@@ -267,7 +267,7 @@ impl QueryAirport {
Ok(airports) Ok(airports)
} }
pub fn get_count(filters: &QueryFilters) -> Result<i64, ServiceError> { pub fn get_count(filters: &QueryFilters) -> ApiResult<i64> {
let mut conn = db::connection()?; let mut conn = db::connection()?;
let mut query = "SELECT COUNT(*) FROM airports".to_string(); let mut query = "SELECT COUNT(*) FROM airports".to_string();
query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?); query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?);
@@ -287,7 +287,7 @@ impl QueryAirport {
let count: Vec<Count> = match sql_query(query).load(&mut conn) { let count: Vec<Count> = match sql_query(query).load(&mut conn) {
Ok(a) => a, Ok(a) => a,
Err(err) => { Err(err) => {
return Err(ServiceError { return Err(ApiError {
status: 500, status: 500,
message: format!("{}", err), message: format!("{}", err),
}) })
@@ -297,14 +297,14 @@ impl QueryAirport {
} }
// TODO: Unsafe query, need to sanitize inputs // TODO: Unsafe query, need to sanitize inputs
fn build_filter_query(filters: &QueryFilters) -> Result<String, ServiceError> { fn build_filter_query(filters: &QueryFilters) -> ApiResult<String> {
let mut query = "".to_string(); let mut query = "".to_string();
let mut parts: Vec<String> = vec![]; let mut parts: Vec<String> = vec![];
if let Some(bounds) = &filters.bounds { if let Some(bounds) = &filters.bounds {
// convert bounds to a WKT polygon // convert bounds to a WKT polygon
if bounds.rings.len() > 1 { if bounds.rings.len() > 1 {
return Err(ServiceError { return Err(ApiError {
status: 400, status: 400,
message: "Only one polygon is allowed".to_string(), message: "Only one polygon is allowed".to_string(),
}); });
@@ -376,7 +376,7 @@ impl QueryAirport {
return Ok(query); return Ok(query);
} }
pub fn get(icao: &str) -> Result<Self, ServiceError> { pub fn get(icao: &str) -> ApiResult<Self> {
let mut conn = db::connection()?; let mut conn = db::connection()?;
let airport = airports::table let airport = airports::table
.filter(airports::icao.eq(icao)) .filter(airports::icao.eq(icao))
@@ -384,7 +384,7 @@ impl QueryAirport {
Ok(airport) Ok(airport)
} }
pub fn insert(airport: Self) -> Result<Self, ServiceError> { pub fn insert(airport: Self) -> ApiResult<Self> {
let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> = let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> =
db::connection()?; db::connection()?;
let airport = Self::from(airport); let airport = Self::from(airport);
@@ -395,7 +395,7 @@ impl QueryAirport {
Ok(airport) Ok(airport)
} }
pub fn insert_all(airports: Vec<Self>) -> Result<Vec<Self>, ServiceError> { pub fn insert_all(airports: Vec<Self>) -> ApiResult<Vec<Self>> {
let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> = let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> =
db::connection()?; db::connection()?;
let mut inserted_airports: Vec<Self> = vec![]; let mut inserted_airports: Vec<Self> = vec![];
@@ -410,7 +410,7 @@ impl QueryAirport {
Ok(inserted_airports) Ok(inserted_airports)
} }
pub fn update(airport: Self) -> Result<Self, ServiceError> { pub fn update(airport: Self) -> ApiResult<Self> {
let mut conn = db::connection()?; let mut conn = db::connection()?;
let airport = diesel::update(airports::table) let airport = diesel::update(airports::table)
.filter(airports::icao.eq(airport.icao.clone())) .filter(airports::icao.eq(airport.icao.clone()))
@@ -419,7 +419,7 @@ impl QueryAirport {
Ok(airport) Ok(airport)
} }
pub fn delete(icao: Option<String>) -> Result<usize, ServiceError> { pub fn delete(icao: Option<String>) -> ApiResult<usize> {
let mut conn = db::connection()?; let mut conn = db::connection()?;
let res = match icao { let res = match icao {
Some(icao) => { Some(icao) => {

View File

@@ -4,7 +4,7 @@ use futures_util::stream::StreamExt as _;
use crate::{ use crate::{
airports::{QueryAirport, QueryFilters, QueryOrderField, QueryOrderBy, Airport, AirportCategory}, airports::{QueryAirport, QueryFilters, QueryOrderField, QueryOrderBy, Airport, AirportCategory},
db::{Response, Metadata}, db::{Response, Metadata},
auth::{JwtAuth, verify_role}, auth::{Auth, verify_role},
}; };
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError}; use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
@@ -26,7 +26,7 @@ struct AirportsQuery {
} }
#[post("/import")] #[post("/import")]
async fn import_airports(mut payload: Multipart, auth: JwtAuth) -> HttpResponse { async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, "admin") { if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err); return ResponseError::error_response(&err);
}; };
@@ -193,11 +193,7 @@ async fn get_airports(req: HttpRequest) -> HttpResponse {
} }
HttpResponse::Ok().json(Response { HttpResponse::Ok().json(Response {
data: airports, data: airports,
meta: Some(Metadata { meta: Some(Metadata { page, limit, total }),
page,
limit,
total,
}),
}) })
} }
Err(err) => { Err(err) => {
@@ -222,7 +218,7 @@ async fn get_airport(icao: web::Path<String>) -> HttpResponse {
} }
#[post("")] #[post("")]
async fn create_airport(airport: web::Json<Airport>, auth: JwtAuth) -> HttpResponse { async fn create_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") { let _ = match verify_role(&auth, "admin") {
Ok(_) => {} Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(&err),
@@ -244,7 +240,7 @@ async fn create_airport(airport: web::Json<Airport>, auth: JwtAuth) -> HttpRespo
async fn update_airport( async fn update_airport(
_icao: web::Path<String>, _icao: web::Path<String>,
airport: web::Json<Airport>, airport: web::Json<Airport>,
auth: JwtAuth, auth: Auth,
) -> HttpResponse { ) -> HttpResponse {
let _ = match verify_role(&auth, "admin") { let _ = match verify_role(&auth, "admin") {
Ok(_) => {} Ok(_) => {}
@@ -264,7 +260,7 @@ async fn update_airport(
} }
#[delete("")] #[delete("")]
async fn delete_airports(auth: JwtAuth) -> HttpResponse { async fn delete_airports(auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") { let _ = match verify_role(&auth, "admin") {
Ok(_) => {} Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(&err),
@@ -279,7 +275,7 @@ async fn delete_airports(auth: JwtAuth) -> HttpResponse {
} }
#[delete("/{icao}")] #[delete("/{icao}")]
async fn delete_airport(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse { async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") { let _ = match verify_role(&auth, "admin") {
Ok(_) => {} Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(&err),

View File

@@ -1,111 +1,58 @@
use std::env;
use argon2::{ use argon2::{
password_hash::{ password_hash::{rand_core::OsRng, SaltString},
rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString, Error as HashError, Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
},
Argon2, PasswordHash,
}; };
use jsonwebtoken::{DecodingKey, EncodingKey, Header, encode, decode, Validation, Algorithm}; use rand::prelude::*;
use serde::{Deserialize, Serialize}; use rand_chacha::ChaCha20Rng;
mod model; mod model;
mod routes; mod routes;
mod session;
pub use model::*; pub use model::*;
pub use session::*;
pub use routes::init_routes; pub use routes::init_routes;
use crate::error_handler::ServiceError;
#[derive(Debug, Serialize, Deserialize)] use crate::error::{ApiError, ApiResult};
struct TokenClaims {
sub: String, // Subject pub const SESSION_COOKIE_NAME: &str = "session";
token_uuid: String, // Token UUID
iss: String, // Issuer pub fn csprng_128bit(take: usize) -> String {
exp: i64, // Expiration time // Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)
iat: i64, // Issued At let rng = ChaCha20Rng::from_entropy();
nbf: i64, // Not Before rng
.sample_iter(rand::distributions::Alphanumeric)
.take(take)
.map(char::from)
.collect()
} }
#[derive(Debug, Serialize, Deserialize)] pub fn hash(str: &str) -> ApiResult<String> {
pub struct TokenDetails { let salt = SaltString::generate(&mut OsRng);
pub token: Option<String>, let bytes = str.as_bytes();
pub token_uuid: uuid::Uuid, let hash = Argon2::default().hash_password(bytes, &salt)?.to_string();
pub email: String, Ok(hash)
pub expires_in: Option<i64>,
} }
pub fn verify_token(token: &str, public_key: &str) -> Result<TokenDetails, ServiceError> { pub fn verify_hash(str: &str, hash: &str) -> bool {
let key = DecodingKey::from_rsa_pem(public_key.as_bytes())?; let bytes = str.as_bytes();
let validation = Validation::new(Algorithm::RS256); let parsed_hash = match PasswordHash::new(hash) {
let decoded = decode::<TokenClaims>(token, &key, &validation)?; Ok(h) => h,
let email = decoded.claims.sub; Err(_) => return false,
let token_uuid = uuid::Uuid::parse_str(decoded.claims.token_uuid.as_str()).unwrap(); };
Ok(TokenDetails { match Argon2::default().verify_password(bytes, &parsed_hash) {
token: None, Ok(_) => true,
token_uuid, Err(_) => false,
email, }
expires_in: None, }
pub fn verify_role(auth: &Auth, role: &str) -> ApiResult<()> {
if auth.user.role == role {
Ok(())
} else {
Err(ApiError {
status: 403,
message: "User does not have permission to perform this action.".to_string(),
}) })
} }
pub fn generate_access_token(email: &str) -> Result<TokenDetails, ServiceError> {
let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE")
.expect("ACCESS_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("ACCESS_TOKEN_MAXAGE must be an integer");
let keys_dir = env::var("KEYS_DIR_PATH")?;
let access_private_key = std::fs::read_to_string(format!("{}/access_private_key.pem", keys_dir))?;
generate_token(&email, access_token_max_age, &access_private_key)
}
pub fn generate_refresh_token(email: &str) -> Result<TokenDetails, ServiceError> {
let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE")
.expect("REFRESH_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("REFRESH_TOKEN_MAXAGE must be an integer");
let keys_dir = env::var("KEYS_DIR_PATH")?;
let refresh_private_key =
std::fs::read_to_string(format!("{}/refresh_private_key.pem", keys_dir))?;
generate_token(&email, refresh_token_max_age, &refresh_private_key)
}
pub fn generate_token(
email: &str,
ttl: i64,
private_key: &str,
) -> Result<TokenDetails, ServiceError> {
let now = chrono::Utc::now();
let mut token_details = TokenDetails {
token: None,
token_uuid: uuid::Uuid::new_v4(),
email: email.to_string(),
expires_in: Some((now + chrono::Duration::minutes(ttl)).timestamp()),
};
let claims = TokenClaims {
sub: token_details.email.clone(),
iss: "aviation-weather".to_string(),
token_uuid: token_details.token_uuid.to_string(),
exp: token_details.expires_in.unwrap(),
iat: now.timestamp(),
nbf: now.timestamp(),
};
let header = Header::new(Algorithm::RS256);
let key = EncodingKey::from_rsa_pem(private_key.as_bytes())?;
let token = encode(&header, &claims, &key)?;
token_details.token = Some(token);
Ok(token_details)
}
pub fn hash_password(password: &[u8]) -> Result<String, HashError> {
let salt = SaltString::generate(&mut OsRng);
Ok(
Argon2::default()
.hash_password(password, &salt)?
.to_string(),
)
}
pub fn verify_password(hash: &str, password: &[u8]) -> Result<(), HashError> {
let parsed_hash = PasswordHash::new(hash)?;
Ok(Argon2::default().verify_password(password, &parsed_hash)?)
} }

View File

@@ -1,156 +1,29 @@
use std::{ use std::future::Future;
future::{ready, Ready}, use std::pin::Pin;
env,
};
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http}; use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http};
use diesel::prelude::*;
use log::error;
use redis::Commands;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::error_handler::ServiceError; use crate::{
error::ApiError,
users::{User, UserResponse},
};
use crate::db::{schema::users, connection}; use super::{Session, SESSION_COOKIE_NAME};
use super::{hash_password, verify_token};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct RegisterUser { pub struct Auth {
pub email: String, pub session_id: Option<String>,
pub password: String, pub user: UserResponse,
pub first_name: String,
pub last_name: String,
} }
impl RegisterUser { impl FromRequest for Auth {
pub fn convert_to_insert(self) -> Result<InsertUser, ServiceError> {
let hash = hash_password(self.password.as_bytes())?;
Ok(InsertUser {
email: self.email.to_lowercase(),
hash,
role: "user".to_string(),
first_name: self.first_name,
last_name: self.last_name,
updated_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now().naive_utc(),
profile_picture: None,
favorites: vec![],
verified: false,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = users)]
pub struct QueryUser {
pub email: String,
pub hash: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
}
impl QueryUser {
pub fn get_by_email(email: &str) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
// Check if the user exists by email, case insensitive
let user = users::table
.filter(users::email.eq(email.to_lowercase()))
.first(&mut conn)?;
Ok(user)
}
}
#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize)]
#[diesel(table_name = users)]
pub struct InsertUser {
pub email: String,
pub hash: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
}
impl InsertUser {
pub fn insert(user: Self) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::insert_into(users::table)
.values(user)
.get_result(&mut conn)?;
Ok(user)
}
pub fn update_profile_picture(
email: &str,
profile_picture: Option<&str>,
) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::update(users::table)
.filter(users::email.eq(&email))
.set(users::profile_picture.eq(profile_picture))
.get_result(&mut conn)?;
Ok(user)
}
pub fn update_favorites(email: &str, favorites: Vec<String>) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::update(users::table)
.filter(users::email.eq(&email))
.set(users::favorites.eq(favorites))
.get_result(&mut conn)?;
Ok(user)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ResponseUser {
pub email: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub profile_picture: Option<String>,
}
impl From<QueryUser> for ResponseUser {
fn from(user: QueryUser) -> Self {
ResponseUser {
email: user.email,
role: user.role,
first_name: user.first_name,
last_name: user.last_name,
profile_picture: user.profile_picture,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JwtAuth {
pub token: uuid::Uuid,
pub user: ResponseUser,
}
impl FromRequest for JwtAuth {
type Error = ActixError; type Error = ActixError;
type Future = Ready<Result<Self, Self::Error>>; type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let access_token = match req // Get session ID from request
.cookie("access_token") let session_id = match req
.cookie(SESSION_COOKIE_NAME)
.map(|c| c.value().to_string()) .map(|c| c.value().to_string())
.or_else(|| { .or_else(|| {
req req
@@ -158,75 +31,37 @@ impl FromRequest for JwtAuth {
.get(http::header::AUTHORIZATION) .get(http::header::AUTHORIZATION)
.map(|h| h.to_str().unwrap().split_at(7).1.to_string()) .map(|h| h.to_str().unwrap().split_at(7).1.to_string())
}) { }) {
Some(token) => token, Some(id) => id,
None => { None => {
return ready(Err(ActixError::from(ServiceError { let fut = async {
Err(
ApiError {
status: 401, status: 401,
message: "Unauthorized".to_string(), message: "No session ID found in the request".to_string(),
}))) }
.into(),
)
};
return Box::pin(fut);
} }
}; };
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set"); // Get IP address from request
let public_key = std::fs::read_to_string(format!("{}/access_public_key.pem", keys_dir)) let ip_address = req.peer_addr().unwrap().ip().to_string();
.expect("Failed to read access public key");
let access_token_details = match verify_token(&access_token, &public_key) { // Verify the session
Ok(token_details) => token_details, let fut = async move {
Err(err) => { match Session::verify(&session_id, &ip_address).await {
error!("Failed to verify access token: {}", err); Ok(session) => match User::get_by_email(&session.email) {
return ready(Err(ActixError::from(ServiceError { Ok(user) => Ok(Auth {
status: 401, session_id: Some(session_id),
message: format!("Failed to verify access token: {}", err),
})));
}
};
let access_token_uuid =
uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
let mut conn = match crate::db::redis_connection() {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
return ready(Err(ActixError::from(ServiceError {
status: 500,
message: format!("Failed to get redis connection: {}", err),
})));
}
};
let user_email = match conn.get::<_, String>(access_token_uuid.clone().to_string()) {
Ok(result) => result,
Err(_) => {
return ready(Err(ActixError::from(ServiceError {
status: 401,
message: format!("Access token was not found"),
})))
}
};
match QueryUser::get_by_email(&user_email) {
Ok(user) => ready(Ok(JwtAuth {
token: access_token_uuid,
user: user.into(), user: user.into(),
})), }),
Err(_) => { Err(err) => Err(err.into()),
return ready(Err(ActixError::from(ServiceError { },
status: 401, Err(err) => Err(err.into()),
message: format!("User was not found"), }
}))) };
} Box::pin(fut)
}
}
}
pub fn verify_role(auth: &JwtAuth, role: &str) -> Result<(), ServiceError> {
if auth.user.role == role {
Ok(())
} else {
Err(ServiceError {
status: 403,
message: "Forbidden".to_string(),
})
} }
} }

View File

@@ -1,32 +1,28 @@
use std::env;
use actix_web::{ use actix_web::{
get, post, web, HttpResponse, ResponseError, post, web, HttpResponse, ResponseError,
cookie::{Cookie, time::Duration}, cookie::{Cookie, time::Duration},
HttpRequest, HttpRequest,
}; };
use log::error;
use redis::AsyncCommands;
use serde::{Serialize, Deserialize};
use crate::{error_handler::ServiceError, db::Response};
use crate::{ use crate::{
auth::{ auth::{verify_hash, Session, SESSION_COOKIE_NAME},
LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token, error::ApiError,
generate_access_token, generate_refresh_token, users::{LoginRequest, RegisterRequest, User, UserResponse},
},
db,
}; };
use crate::auth::Auth;
#[post("/register")] #[post("/register")]
async fn register(user: web::Json<RegisterUser>) -> HttpResponse { async fn register(user: web::Json<RegisterRequest>) -> HttpResponse {
let register_user = user.0; let register_user = user.0;
let insert_user: InsertUser = match register_user.convert_to_insert() { let insert_user: User = match register_user.to_user() {
Ok(user) => user, Ok(user) => user,
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(&err),
}; };
match InsertUser::insert(insert_user) { match User::insert(insert_user) {
Ok(_) => HttpResponse::Created().finish(), Ok(user) => {
let response: UserResponse = user.into();
HttpResponse::Created().json(response)
},
Err(err) => { Err(err) => {
// Obfuscate the service error message to prevent leaking database details // Obfuscate the service error message to prevent leaking database details
if err.status == 409 { if err.status == 409 {
@@ -39,430 +35,64 @@ async fn register(user: web::Json<RegisterUser>) -> HttpResponse {
} }
#[post("/login")] #[post("/login")]
async fn login(request: web::Json<LoginRequest>) -> HttpResponse { async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let email = request.email.clone(); let email = request.email.clone();
let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match QueryUser::get_by_email(&email) { let query_user = match User::get_by_email(&email) {
Ok(query_user) => query_user, Ok(query_user) => query_user,
Err(err) => return ResponseError::error_response(&err),
};
let hash = &query_user.hash;
let password = request.password.as_bytes();
match verify_password(hash, password) {
Ok(_) => {
let access_token_details = match generate_access_token(&email) {
Ok(token_details) => token_details,
Err(err) => { Err(err) => {
error!("Failed to generate access token: {}", err); log::error!("{}", err);
return ResponseError::error_response(&err); return ResponseError::error_response(&err);
} }
}; };
if verify_hash(&query_user.hash, &request.password) {
let refresh_token_details = match generate_refresh_token(&email) { // Create a session
Ok(token_details) => token_details, let session = Session::new(&email, &ip_address);
Err(err) => { let session_cookie = session.cookie();
error!("Failed to generate refresh token: {}", err); // Save the session to the database
return ResponseError::error_response(&err); if let Err(err) = session.store().await {
log::error!("Failed to store session");
return ResponseError::error_response(&ApiError::new(500, err.to_string()));
} }
}; return HttpResponse::Ok().cookie(session_cookie).finish();
let mut conn = match db::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
return ResponseError::error_response(&err);
}
};
let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE")
.expect("ACCESS_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("ACCESS_TOKEN_MAXAGE must be an integer");
let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE")
.expect("REFRESH_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("REFRESH_TOKEN_MAXAGE must be an integer");
let access_result: redis::RedisResult<()> = conn
.set_ex(
access_token_details.token_uuid.to_string(),
&email,
(access_token_max_age * 60) as u64,
)
.await;
if let Err(err) = access_result {
error!("Failed to set access token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set access token in redis: {}", err),
});
};
let refresh_result: redis::RedisResult<()> = conn
.set_ex(
refresh_token_details.token_uuid.to_string(),
&email,
(refresh_token_max_age * 60) as u64,
)
.await;
if let Err(err) = refresh_result {
error!("Failed to set refresh token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set refresh token in redis: {}", err),
});
};
let access_cookie =
Cookie::build("access_token", access_token_details.token.clone().unwrap())
.path("/")
.max_age(Duration::new(access_token_max_age * 60, 0))
.http_only(true)
.secure(true)
.finish();
let refresh_cookie = Cookie::build(
"refresh_token",
refresh_token_details.token.clone().unwrap(),
)
.path("/")
.max_age(Duration::new(refresh_token_max_age * 60, 0))
.http_only(true)
.secure(true)
.finish();
let logged_in_cookie = Cookie::build("logged_in", "true")
.path("/")
.max_age(Duration::new(access_token_max_age * 60, 0))
.http_only(false)
.finish();
let access_token_uuid =
uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
HttpResponse::Ok()
.cookie(access_cookie)
.cookie(refresh_cookie)
.cookie(logged_in_cookie)
.json(JwtAuth {
token: access_token_uuid,
user: query_user.into(),
})
}
Err(err) => ResponseError::error_response(&ServiceError {
status: 401,
message: err.to_string(),
}),
}
}
#[get("/session")]
async fn session(req: HttpRequest) -> HttpResponse {
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
// If there is a access_token cookie, check if it is valid
let has_session = match req.cookie("access_token") {
Some(cookie) => {
let access_token = cookie.value().to_string();
let public_key = std::fs::read_to_string(format!("{}access_public_key.pem", keys_dir))
.expect("Unable to read refresh public key");
match verify_token(&access_token, &public_key) {
Ok(_) => true,
Err(_) => false,
}
}
None => false,
};
if !has_session {
// If there is a refresh_token cookie, check if it is valid
match req.cookie("refresh_token") {
Some(cookie) => {
let refresh_token = cookie.value().to_string();
let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir))
.expect("Unable to read refresh public key");
match verify_token(&refresh_token, &public_key) {
Ok(_) => return HttpResponse::Ok().json(true),
Err(_) => return HttpResponse::Ok().json(false),
};
}
None => return HttpResponse::Ok().json(false),
};
} else { } else {
return HttpResponse::Ok().json(true); log::error!("Invalid login attempt for {}", email);
} return HttpResponse::Unauthorized().finish();
}
#[derive(Serialize, Deserialize)]
struct RefreshParams {
refresh_token_rotation: Option<bool>,
}
#[get("/refresh")]
async fn refresh(req: HttpRequest) -> HttpResponse {
let params = match web::Query::<RefreshParams>::from_query(req.query_string()) {
Ok(params) => params,
Err(err) => {
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string(),
})
}
};
let refresh_token = match req.cookie("refresh_token") {
Some(cookie) => cookie.value().to_string(),
None => {
return ResponseError::error_response(&ServiceError {
status: 401,
message: "Refresh token not found".to_string(),
})
}
};
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir))
.expect("Unable to read refresh public key");
let refresh_token_details = match verify_token(&refresh_token, &public_key) {
Ok(token_details) => token_details,
Err(err) => return ResponseError::error_response(&err),
};
let email = refresh_token_details.email.clone();
match QueryUser::get_by_email(&email) {
Ok(query_user) => {
let access_token_details = match generate_access_token(&email) {
Ok(token_details) => token_details,
Err(err) => {
error!("Failed to generate access token: {}", err);
return ResponseError::error_response(&err);
}
};
let mut conn = match db::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
return ResponseError::error_response(&err);
}
};
// Delete old auth token if it exists
match req.cookie("access_token") {
Some(cookie) => {
let access_token = cookie.value().to_string();
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
let public_key = std::fs::read_to_string(format!("{}/access_public_key.pem", keys_dir))
.expect("Unable to read access public key");
match verify_token(&access_token, &public_key) {
Ok(token_details) => {
let _: redis::RedisResult<()> = conn.del(token_details.token_uuid.to_string()).await;
}
Err(_) => {}
};
}
None => {}
};
let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE")
.expect("ACCESS_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("ACCESS_TOKEN_MAXAGE must be an integer");
let access_result: redis::RedisResult<()> = conn
.set_ex(
access_token_details.token_uuid.to_string(),
&email,
(access_token_max_age * 60) as u64,
)
.await;
if let Err(err) = access_result {
error!("Failed to set access token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set access token in redis: {}", err),
});
};
let access_cookie =
Cookie::build("access_token", access_token_details.token.clone().unwrap())
.path("/")
.max_age(Duration::new(access_token_max_age * 60, 0))
.http_only(true)
.secure(true)
.finish();
let logged_in_cookie = Cookie::build("logged_in", "true")
.path("/")
.max_age(Duration::new(access_token_max_age * 60, 0))
.http_only(false)
.finish();
let access_token_uuid =
uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
// Refresh the refresh token if requested
let refresh_token_rotation = match params.refresh_token_rotation {
Some(refresh_token_rotation) => refresh_token_rotation,
None => false,
};
if refresh_token_rotation {
// Delete the old refresh token
let _: redis::RedisResult<()> =
conn.del(refresh_token_details.token_uuid.to_string()).await;
let refresh_token_details = match generate_refresh_token(&refresh_token_details.email) {
Ok(token_details) => token_details,
Err(err) => {
error!("Failed to generate refresh token: {}", err);
return ResponseError::error_response(&err);
}
};
let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE")
.expect("REFRESH_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("REFRESH_TOKEN_MAXAGE must be an integer");
let refresh_result: redis::RedisResult<()> = conn
.set_ex(
refresh_token_details.token_uuid.to_string(),
&refresh_token_details.email,
(refresh_token_max_age * 60) as u64,
)
.await;
if let Err(err) = refresh_result {
error!("Failed to set refresh token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set refresh token in redis: {}", err),
});
};
let refresh_cookie = Cookie::build(
"refresh_token",
refresh_token_details.token.clone().unwrap(),
)
.path("/")
.max_age(Duration::new(refresh_token_max_age * 60, 0))
.http_only(true)
.secure(true)
.finish();
HttpResponse::Ok()
.cookie(refresh_cookie)
.cookie(access_cookie)
.cookie(logged_in_cookie)
.json(JwtAuth {
token: access_token_uuid,
user: query_user.into(),
})
} else {
HttpResponse::Ok()
.cookie(access_cookie)
.cookie(logged_in_cookie)
.json(JwtAuth {
token: access_token_uuid,
user: query_user.into(),
})
}
}
Err(err) => return ResponseError::error_response(&err),
} }
} }
#[post("/logout")] #[post("/logout")]
async fn logout(req: HttpRequest, auth: JwtAuth) -> HttpResponse { async fn logout(req: HttpRequest, _auth: Auth) -> HttpResponse {
let refresh_token = match req.cookie("refresh_token") { // Delete the session from the store
Some(cookie) => cookie.value().to_string(), match req.cookie(SESSION_COOKIE_NAME) {
Some(cookie) => {
let session_id = cookie.value().to_string();
if let Err(err) = Session::delete(&session_id).await {
log::error!("Failed to delete session");
return ResponseError::error_response(&ApiError::new(500, err.to_string()));
}
}
None => { None => {
return ResponseError::error_response(&ServiceError { return ResponseError::error_response(&ApiError::new(400, "Invalid session".to_string()));
status: 401,
message: "Refresh token not found".to_string(),
})
} }
};
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir))
.expect("Unable to read refresh public key");
let refresh_token_details = match verify_token(&refresh_token, &public_key) {
Ok(token_details) => token_details,
Err(err) => return ResponseError::error_response(&err),
};
let mut conn = match db::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
return ResponseError::error_response(&err);
} }
};
let access_result: redis::RedisResult<()> = conn let session_cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.del(&[
refresh_token_details.token_uuid.to_string(),
auth.token.to_string(),
])
.await;
if let Err(err) = access_result {
error!("Failed to set access token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set access token in redis: {}", err),
});
};
let access_cookie = Cookie::build("access_token", "")
.path("/") .path("/")
.max_age(Duration::new(-1, 0)) .max_age(Duration::seconds(-1))
.http_only(true) .secure(true)
.finish();
let refresh_cookie = Cookie::build("refresh_token", "")
.path("/")
.max_age(Duration::new(-1, 0))
.http_only(true)
.finish();
let logged_in_cookie = Cookie::build("logged_in", "")
.path("/")
.max_age(Duration::new(-1, 0))
.http_only(true) .http_only(true)
.finish(); .finish();
HttpResponse::Ok() HttpResponse::Ok().cookie(session_cookie).finish()
.cookie(access_cookie)
.cookie(refresh_cookie)
.cookie(logged_in_cookie)
.finish()
}
#[get("/me")]
async fn me(auth: JwtAuth) -> HttpResponse {
HttpResponse::Ok().json(auth)
}
#[get("/roles")]
async fn roles() -> HttpResponse {
HttpResponse::Ok().json(Response {
data: vec!["admin", "user"],
meta: None,
})
} }
pub fn init_routes(config: &mut web::ServiceConfig) { pub fn init_routes(config: &mut web::ServiceConfig) {
let r = RegisterUser {
email: "admin".to_string(),
password: "admin".to_string(),
first_name: "Admin".to_string(),
last_name: "Admin".to_string(),
};
let mut u = r.convert_to_insert().unwrap();
u.role = "admin".to_string();
u.verified = true;
let _ = InsertUser::insert(u);
config.service( config.service(
web::scope("auth") web::scope("auth")
.service(register) .service(register)
.service(login) .service(login)
.service(refresh) .service(logout),
.service(logout)
.service(me)
.service(roles),
); );
} }

View File

@@ -0,0 +1,91 @@
use actix_web::cookie::{time::Duration, Cookie};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult};
use crate::{
db::redis_async_connection,
error::{ApiError, ApiResult},
};
use super::{csprng_128bit, hash, verify_hash};
pub const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session";
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub email: String,
pub ip_address: String,
pub expires_at: DateTime<Utc>,
}
impl Session {
pub fn new(email: &str, ip_address: &str) -> Self {
let now = chrono::Utc::now();
Self {
session_id: csprng_128bit(32),
email: email.to_string(),
ip_address: hash(&ip_address).unwrap(),
expires_at: now + chrono::Duration::seconds(DEFAULT_SESSION_TTL),
}
}
pub async fn store(&self) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let key = self.session_id.clone();
let value = serde_json::to_string(self)?;
let result: RedisResult<()> = conn.set_ex(key, &value, DEFAULT_SESSION_TTL as u64).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(session_id: &str) -> ApiResult<Option<Self>> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = conn.get(session_id).await;
match result {
Ok(Some(value)) => Ok(Some(serde_json::from_str(&value)?)),
Ok(None) => Ok(None),
Err(err) => Err(err.into()),
}
}
pub async fn delete(session_id: &str) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<()> = conn.del(session_id).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn verify(session_id: &str, ip_address: &str) -> ApiResult<Self> {
// Check if the session exists
let session = match Self::get(session_id).await? {
Some(session) => session,
None => return Err(ApiError::new(401, "Session does not exist".to_string())),
};
// Check if the IP Address matches the Session's IP Address
if verify_hash(ip_address, &session.ip_address) {
return Ok(session);
} else {
return Err(ApiError::new(
401,
"IP Address does not match".to_string(),
));
}
}
pub fn cookie(&self) -> Cookie {
Cookie::build(SESSION_COOKIE_NAME, self.session_id.clone())
.path("/")
.max_age(Duration::seconds(DEFAULT_SESSION_TTL))
.secure(true)
.http_only(true)
.finish()
}
}

View File

@@ -1,4 +1,4 @@
use crate::error_handler::ServiceError; use crate::error::{ApiError, ApiResult};
use diesel::{r2d2::ConnectionManager, PgConnection}; use diesel::{r2d2::ConnectionManager, PgConnection};
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection}; use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection};
use s3::{ use s3::{
@@ -76,7 +76,7 @@ pub async fn init() {
Ok(_) => info!("Bucket initialized"), Ok(_) => info!("Bucket initialized"),
Err(err) => match err.status { Err(err) => match err.status {
409 => warn!("Bucket already exists"), 409 => warn!("Bucket already exists"),
_ => error!("Failed to initialize bucket; {}", err.message), _ => error!("Failed to initialize bucket; {}", err),
}, },
}; };
let mut pool: DbConnection = connection().expect("Failed to get db connection"); let mut pool: DbConnection = connection().expect("Failed to get db connection");
@@ -86,23 +86,23 @@ pub async fn init() {
}; };
} }
pub fn connection() -> Result<DbConnection, ServiceError> { pub fn connection() -> ApiResult<DbConnection> {
POOL POOL
.get() .get()
.map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e))) .map_err(|e| ApiError::new(500, format!("Failed getting db connection: {}", e)))
} }
pub fn redis_connection() -> Result<redis::Connection, ServiceError> { pub fn redis_connection() -> ApiResult<redis::Connection> {
let conn = REDIS.get_connection()?; let conn = REDIS.get_connection()?;
Ok(conn) Ok(conn)
} }
pub async fn redis_async_connection() -> Result<RedisConnection, ServiceError> { pub async fn redis_async_connection() -> ApiResult<RedisConnection> {
let conn = REDIS.get_multiplexed_async_connection().await?; let conn = REDIS.get_multiplexed_async_connection().await?;
Ok(conn) Ok(conn)
} }
async fn create_bucket() -> Result<CreateBucketResponse, ServiceError> { async fn create_bucket() -> ApiResult<CreateBucketResponse> {
let url = env::var("MINIO_URL").unwrap_or("localhost".to_string()); let url = env::var("MINIO_URL").unwrap_or("localhost".to_string());
let port = env::var("MINIO_PORT").unwrap_or("9000".to_string()); let port = env::var("MINIO_PORT").unwrap_or("9000".to_string());
let user = env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set"); let user = env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
@@ -132,18 +132,18 @@ async fn create_bucket() -> Result<CreateBucketResponse, ServiceError> {
Ok(response) Ok(response)
} }
pub async fn upload_file(path: &str, content: &[u8]) -> Result<ResponseData, ServiceError> { pub async fn upload_file(path: &str, content: &[u8]) -> ApiResult<ResponseData> {
let response = BUCKET.put_object(path, content).await?; let response = BUCKET.put_object(path, content).await?;
Ok(response) Ok(response)
} }
pub async fn get_file(path: &str) -> Result<Vec<u8>, ServiceError> { pub async fn get_file(path: &str) -> ApiResult<Vec<u8>> {
let response = BUCKET.get_object(path).await?; let response = BUCKET.get_object(path).await?;
let bytes = response.bytes(); let bytes = response.bytes();
Ok(bytes.to_vec()) Ok(bytes.to_vec())
} }
pub async fn delete_file(path: &str) -> Result<ResponseData, ServiceError> { pub async fn delete_file(path: &str) -> ApiResult<ResponseData> {
let response = BUCKET.delete_object(path).await?; let response = BUCKET.delete_object(path).await?;
Ok(response) Ok(response)
} }

154
service/src/error.rs Normal file
View File

@@ -0,0 +1,154 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use diesel::result::Error as DieselError;
use log::warn;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiError {
pub status: u16,
pub message: String,
}
impl ApiError {
pub fn new(status: u16, message: String) -> Self {
Self { status, message }
}
pub fn to_http_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(s) => s,
Err(err) => {
warn!("{}", err);
StatusCode::INTERNAL_SERVER_ERROR
}
};
HttpResponse::build(status).body(self.message.to_string())
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.message.as_str())
}
}
impl From<std::io::Error> for ApiError {
fn from(error: std::io::Error) -> Self {
Self::new(500, format!("Unknown IO error: {}", error))
}
}
impl From<std::env::VarError> for ApiError {
fn from(error: std::env::VarError) -> Self {
Self::new(
500,
format!("Unknown environment variable error: {}", error),
)
}
}
impl From<DieselError> for ApiError {
fn from(error: DieselError) -> Self {
match error {
DieselError::DatabaseError(kind, err) => match kind {
diesel::result::DatabaseErrorKind::UniqueViolation => {
Self::new(409, err.message().to_string())
}
_ => Self::new(500, err.message().to_string()),
},
DieselError::NotFound => Self::new(404, "The record was not found".to_string()),
DieselError::SerializationError(err) => Self::new(422, err.to_string()),
err => Self::new(500, format!("Unknown Diesel error: {}", err)),
}
}
}
impl From<reqwest::Error> for ApiError {
fn from(error: reqwest::Error) -> Self {
Self::new(500, format!("Unknown reqwest error: {}", error))
}
}
impl From<serde_json::Error> for ApiError {
fn from(error: serde_json::Error) -> Self {
Self::new(500, format!("Unknown serde_json error: {}", error))
}
}
impl From<argon2::password_hash::Error> for ApiError {
fn from(error: argon2::password_hash::Error) -> Self {
Self::new(500, format!("Unknown argon2 error: {}", error))
}
}
impl From<jsonwebtoken::errors::Error> for ApiError {
fn from(error: jsonwebtoken::errors::Error) -> Self {
Self::new(500, format!("Unknown jsonwebtoken error: {}", error))
}
}
impl From<redis::RedisError> for ApiError {
fn from(error: redis::RedisError) -> Self {
Self::new(500, format!("Unknown redis error: {}", error))
}
}
impl From<s3::error::S3Error> for ApiError {
fn from(error: s3::error::S3Error) -> Self {
match error {
s3::error::S3Error::Credentials(err) => {
Self::new(500, format!("Unknown s3 credentials error: {}", err))
}
s3::error::S3Error::FromUtf8(err) => {
Self::new(500, format!("Unknown s3 from utf8 error: {}", err))
}
s3::error::S3Error::FmtError(err) => {
Self::new(500, format!("Unknown s3 fmt error: {}", err))
}
s3::error::S3Error::HeaderToStr(err) => {
Self::new(500, format!("Unknown s3 header to str error: {}", err))
}
s3::error::S3Error::HmacInvalidLength(err) => Self::new(
500,
format!("Unknown s3 hmac invalid length error: {}", err),
),
s3::error::S3Error::Http(error) => {
Self::new(error.status_code().as_u16(), error.to_string())
}
_ => {
let re = Regex::new(r"HTTP (\d{3})").unwrap();
// Apply the regex to the input string
if let Some(captures) = re.captures(&error.to_string()) {
if let Some(http_code_str) = captures.get(1) {
if let Ok(http_code) = http_code_str.as_str().parse::<u16>() {
return Self::new(http_code, error.to_string());
}
}
}
Self::new(500, format!("Unknown s3 error: {}", error))
}
}
}
}
impl ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(status) => status,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let message = match status.as_u16() < 500 {
true => self.message.clone(),
false => "Internal server error".to_string(),
};
HttpResponse::build(status).json(json!({ "status": status.as_u16(), "message": message }))
}
}

View File

@@ -1,138 +0,0 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use diesel::result::Error as DieselError;
use log::warn;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
#[derive(Debug, Deserialize, Serialize)]
pub struct ServiceError {
pub status: u16,
pub message: String,
}
impl ServiceError {
pub fn new(status: u16, message: String) -> ServiceError {
ServiceError { status, message }
}
pub fn to_http_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(s) => s,
Err(err) => {
warn!("{}", err);
StatusCode::INTERNAL_SERVER_ERROR
}
};
HttpResponse::build(status).body(self.message.to_string())
}
}
impl fmt::Display for ServiceError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.message.as_str())
}
}
impl From<std::io::Error> for ServiceError {
fn from(error: std::io::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown IO error: {}", error))
}
}
impl From<std::env::VarError> for ServiceError {
fn from(error: std::env::VarError) -> ServiceError {
ServiceError::new(
500,
format!("Unknown environment variable error: {}", error),
)
}
}
impl From<DieselError> for ServiceError {
fn from(error: DieselError) -> ServiceError {
match error {
DieselError::DatabaseError(kind, err) => match kind {
diesel::result::DatabaseErrorKind::UniqueViolation => {
ServiceError::new(409, err.message().to_string())
}
_ => ServiceError::new(500, err.message().to_string()),
},
DieselError::NotFound => ServiceError::new(404, "The record was not found".to_string()),
DieselError::SerializationError(err) => ServiceError::new(422, err.to_string()),
err => ServiceError::new(500, format!("Unknown Diesel error: {}", err)),
}
}
}
impl From<reqwest::Error> for ServiceError {
fn from(error: reqwest::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown reqwest error: {}", error))
}
}
impl From<serde_json::Error> for ServiceError {
fn from(error: serde_json::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown serde_json error: {}", error))
}
}
impl From<argon2::password_hash::Error> for ServiceError {
fn from(error: argon2::password_hash::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown argon2 error: {}", error))
}
}
impl From<jsonwebtoken::errors::Error> for ServiceError {
fn from(error: jsonwebtoken::errors::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown jsonwebtoken error: {}", error))
}
}
impl From<redis::RedisError> for ServiceError {
fn from(error: redis::RedisError) -> ServiceError {
ServiceError::new(500, format!("Unknown redis error: {}", error))
}
}
impl From<s3::error::S3Error> for ServiceError {
fn from(error: s3::error::S3Error) -> ServiceError {
match error {
s3::error::S3Error::Credentials(err) => {
ServiceError::new(500, format!("Unknown s3 credentials error: {}", err))
}
s3::error::S3Error::FromUtf8(err) => {
ServiceError::new(500, format!("Unknown s3 from utf8 error: {}", err))
}
s3::error::S3Error::FmtError(err) => {
ServiceError::new(500, format!("Unknown s3 fmt error: {}", err))
}
s3::error::S3Error::HeaderToStr(err) => {
ServiceError::new(500, format!("Unknown s3 header to str error: {}", err))
}
s3::error::S3Error::HmacInvalidLength(err) => ServiceError::new(
500,
format!("Unknown s3 hmac invalid length error: {}", err),
),
s3::error::S3Error::Http(error) => ServiceError::new(error.status_code().as_u16(), error.to_string()),
_ => ServiceError::new(500, format!("Unknown s3 error: {}", error)),
}
}
}
impl ResponseError for ServiceError {
fn error_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(status) => status,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let message = match status.as_u16() < 500 {
true => self.message.clone(),
false => "Internal server error".to_string(),
};
HttpResponse::build(status).json(json!({ "status": status.as_u16(), "message": message }))
}
}

View File

@@ -7,12 +7,11 @@ use std::env;
use actix_cors::Cors; use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger}; use actix_web::{App, HttpServer, middleware::Logger};
use dotenv::dotenv; use dotenv::dotenv;
use log::{info, error};
mod airports; mod airports;
mod auth; mod auth;
mod db; mod db;
mod error_handler; mod error;
mod metars; mod metars;
mod scheduler; mod scheduler;
mod users; mod users;
@@ -45,11 +44,11 @@ async fn main() -> std::io::Result<()> {
.bind(format!("{}:{}", host, port)) .bind(format!("{}:{}", host, port))
{ {
Ok(b) => { Ok(b) => {
info!("Binding server to {}:{}", host, port); log::info!("Binding server to {}:{}", host, port);
b b
} }
Err(err) => { Err(err) => {
error!("Could not bind server: {}", err); log::error!("Could not bind server: {}", err);
return Err(err); return Err(err);
} }
}; };

View File

@@ -1,5 +1,6 @@
use crate::airports::QueryAirport; use crate::airports::QueryAirport;
use crate::{error_handler::ServiceError, db}; use crate::error::ApiError;
use crate::{error::ApiResult, db};
use crate::db::schema::metars::{self}; use crate::db::schema::metars::{self};
use chrono::Datelike; use chrono::Datelike;
use diesel::{prelude::*, sql_query}; use diesel::{prelude::*, sql_query};
@@ -160,7 +161,7 @@ impl Default for Metar {
} }
impl Metar { impl Metar {
fn parse(metar_strings: Vec<&str>) -> Result<Vec<Self>, ServiceError> { fn parse(metar_strings: Vec<&str>) -> ApiResult<Vec<Self>> {
let mut metars: Vec<Self> = vec![]; let mut metars: Vec<Self> = vec![];
for metar_string in metar_strings { for metar_string in metar_strings {
trace!("Parsing METAR data: {}", metar_string); trace!("Parsing METAR data: {}", metar_string);
@@ -674,7 +675,7 @@ impl Metar {
return missing_metar_icaos; return missing_metar_icaos;
} }
async fn get_remote_metars(icaos: Vec<String>) -> Result<Vec<Metar>, ServiceError> { async fn get_remote_metars(icaos: Vec<String>) -> ApiResult<Vec<Metar>> {
let gov_api_url = std::env::var("GOV_API_URL").expect("GOV_API_URL must be set"); let gov_api_url = std::env::var("GOV_API_URL").expect("GOV_API_URL must be set");
// Query the remote API for the missing METAR data 10 at a time // Query the remote API for the missing METAR data 10 at a time
let icao_chunks = icaos let icao_chunks = icaos
@@ -688,7 +689,7 @@ impl Metar {
Ok(r) => { Ok(r) => {
// Check if the status code is 200 // Check if the status code is 200
if r.status() != 200 { if r.status() != 200 {
return Err(ServiceError::new( return Err(ApiError::new(
500, 500,
format!("Unable to get METAR request: {}", r.status()), format!("Unable to get METAR request: {}", r.status()),
)); ));
@@ -706,7 +707,7 @@ impl Metar {
} }
} }
Err(err) => { Err(err) => {
return Err(ServiceError::new( return Err(ApiError::new(
500, 500,
format!("Unable to parse METAR request: {}", err), format!("Unable to parse METAR request: {}", err),
)) ))
@@ -714,7 +715,7 @@ impl Metar {
} }
} }
Err(err) => { Err(err) => {
return Err(ServiceError::new( return Err(ApiError::new(
500, 500,
format!("Unable to get METAR request: {}", err), format!("Unable to get METAR request: {}", err),
)) ))
@@ -749,7 +750,7 @@ impl Metar {
return insert_metars; return insert_metars;
} }
pub async fn get_all(icao_string: String) -> Result<Vec<Self>, ServiceError> { pub async fn get_all(icao_string: String) -> ApiResult<Vec<Self>> {
if icao_string.is_empty() { if icao_string.is_empty() {
return Ok(vec![]); return Ok(vec![]);
} }
@@ -834,14 +835,14 @@ struct InsertMetar {
} }
impl InsertMetar { impl InsertMetar {
fn insert(metars: &Vec<Self>) -> Result<usize, ServiceError> { fn insert(metars: &Vec<Self>) -> ApiResult<usize> {
let mut conn = db::connection()?; let mut conn = db::connection()?;
match diesel::insert_into(metars::table) match diesel::insert_into(metars::table)
.values(metars) .values(metars)
.execute(&mut conn) .execute(&mut conn)
{ {
Ok(rows) => Ok(rows), Ok(rows) => Ok(rows),
Err(err) => Err(ServiceError { Err(err) => Err(ApiError {
status: 500, status: 500,
message: format!("{}", err), message: format!("{}", err),
}), }),
@@ -860,7 +861,7 @@ struct QueryMetar {
} }
impl QueryMetar { impl QueryMetar {
fn get_all(icaos: &Vec<&str>) -> Result<Vec<QueryMetar>, ServiceError> { fn get_all(icaos: &Vec<&str>) -> ApiResult<Vec<QueryMetar>> {
// Sanitize search to only allow [a-zA-Z0-9] // Sanitize search to only allow [a-zA-Z0-9]
let icaos = icaos let icaos = icaos
.iter() .iter()
@@ -880,7 +881,7 @@ impl QueryMetar {
format!("SELECT DISTINCT ON (icao) * FROM metars WHERE icao IN ({}) ORDER BY icao, observation_time DESC", station_query.join(",")) format!("SELECT DISTINCT ON (icao) * FROM metars WHERE icao IN ({}) ORDER BY icao, observation_time DESC", station_query.join(","))
).load(&mut conn) { ).load(&mut conn) {
Ok(m) => m, Ok(m) => m,
Err(err) => return Err(ServiceError { status: 500, message: format!("{}", err) }) Err(err) => return Err(ApiError { status: 500, message: format!("{}", err) })
}; };
return Ok(db_metars); return Ok(db_metars);
} }

View File

@@ -1,4 +1,4 @@
use crate::{error_handler::ServiceError, db::Metadata}; use crate::{error::ApiError, db::Metadata};
use crate::metars::Metar; use crate::metars::Metar;
use actix_web::{get, web, HttpResponse, HttpRequest}; use actix_web::{get, web, HttpResponse, HttpRequest};
use log::error; use log::error;
@@ -25,7 +25,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
}; };
let metars = let metars =
match web::block(|| Ok::<_, ServiceError>(async { Metar::get_all(icao_string).await })) match web::block(|| Ok::<_, ApiError>(async { Metar::get_all(icao_string).await }))
.await .await
.unwrap() .unwrap()
.unwrap() .unwrap()

View File

@@ -1,3 +1,5 @@
mod model;
mod routes; mod routes;
pub use model::*;
pub use routes::init_routes; pub use routes::init_routes;

108
service/src/users/model.rs Normal file
View File

@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
use diesel::prelude::*;
use crate::{
auth::hash,
db::{connection, schema::users},
error::ApiResult,
};
/**
* RegisterRequest
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub first_name: String,
pub last_name: String,
}
impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> {
let hash = hash(&self.password)?;
Ok(User {
email: self.email.to_lowercase(),
hash,
role: "user".to_string(),
first_name: self.first_name,
last_name: self.last_name,
updated_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now().naive_utc(),
profile_picture: None,
favorites: vec![],
verified: false,
})
}
}
/**
* LoginRequest
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
/**
* UserResponse
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct UserResponse {
pub email: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub profile_picture: Option<String>,
}
impl From<User> for UserResponse {
fn from(user: User) -> Self {
UserResponse {
email: user.email,
role: user.role,
first_name: user.first_name,
last_name: user.last_name,
profile_picture: user.profile_picture,
}
}
}
/**
* User
*/
#[derive(Debug, Insertable, AsChangeset, Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = users)]
pub struct User {
pub email: String,
pub hash: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
}
impl User {
pub fn get_by_email(email: &str) -> ApiResult<User> {
let mut conn = connection()?;
// Check if the user exists by email, case insensitive
let user = users::table
.filter(users::email.eq(email.to_lowercase()))
.first(&mut conn)?;
Ok(user)
}
pub fn insert(user: Self) -> ApiResult<User> {
let mut conn = connection()?;
let user = diesel::insert_into(users::table)
.values(user)
.get_result(&mut conn)?;
Ok(user)
}
}

View File

@@ -1,164 +1,165 @@
use actix_multipart::Multipart; // use actix_multipart::Multipart;
use actix_web::{get, post, delete, web, HttpResponse, ResponseError}; // use actix_web::{get, post, delete, web, HttpResponse, ResponseError};
use futures_util::StreamExt; // use futures_util::StreamExt;
use crate::{ // use crate::{
auth::{JwtAuth, QueryUser, InsertUser}, // auth::Auth,
error_handler::ServiceError, // db::{delete_file, get_file, upload_file},
db::{upload_file, get_file, delete_file}, // error::ServiceError,
}; // users::User,
// };
#[get("/favorites")] // #[get("/favorites")]
async fn get_favorites(auth: JwtAuth) -> HttpResponse { // async fn get_favorites(auth: Auth) -> HttpResponse {
match QueryUser::get_by_email(&auth.user.email) { // match User::get_by_email(&auth.user.email) {
Ok(user) => return HttpResponse::Ok().json(user.favorites), // Ok(user) => return HttpResponse::Ok().json(user.favorites),
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
} // }
} // }
#[post("/favorites/{icao}")] // #[post("/favorites/{icao}")]
async fn add_favorite(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse { // async fn add_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
match QueryUser::get_by_email(&auth.user.email) { // match User::get_by_email(&auth.user.email) {
Ok(user) => { // Ok(user) => {
if user.favorites.contains(&icao) { // if user.favorites.contains(&icao) {
// Check if the airport ICAO is already in the user's favorites // // Check if the airport ICAO is already in the user's favorites
return HttpResponse::Conflict().finish(); // return HttpResponse::Conflict().finish();
} else { // } else {
// Add the airport ICAO to the user's favorites // // Add the airport ICAO to the user's favorites
let mut favorites = user.favorites; // let mut favorites = user.favorites;
favorites.push(icao.into_inner()); // favorites.push(icao.into_inner());
match InsertUser::update_favorites(&user.email, favorites) { // match User::update_favorites(&user.email, favorites) {
Ok(_) => return HttpResponse::Ok().finish(), // Ok(_) => return HttpResponse::Ok().finish(),
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
} // }
} // }
} // }
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
} // }
} // }
#[delete("/favorites/{icao}")] // #[delete("/favorites/{icao}")]
async fn delete_favorite(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse { // async fn delete_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
let icao: String = icao.into_inner(); // let icao: String = icao.into_inner();
match QueryUser::get_by_email(&auth.user.email) { // match User::get_by_email(&auth.user.email) {
Ok(user) => { // Ok(user) => {
if user.favorites.contains(&icao) { // if user.favorites.contains(&icao) {
// Check if the airport ICAO is already in the user's favorites // // Check if the airport ICAO is already in the user's favorites
let mut favorites = user.favorites; // let mut favorites = user.favorites;
favorites.retain(|x| x != &icao); // favorites.retain(|x| x != &icao);
match InsertUser::update_favorites(&user.email, favorites) { // match User::update_favorites(&user.email, favorites) {
Ok(_) => return HttpResponse::Ok().finish(), // Ok(_) => return HttpResponse::Ok().finish(),
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
} // }
} else { // } else {
// Remove the airport ICAO from the user's favorites // // Remove the airport ICAO from the user's favorites
return HttpResponse::Conflict().finish(); // return HttpResponse::Conflict().finish();
} // }
} // }
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
} // }
} // }
#[post("/picture")] // #[post("/picture")]
async fn set_picture(mut payload: Multipart, auth: JwtAuth) -> HttpResponse { // async fn set_picture(mut payload: Multipart, auth: Auth) -> HttpResponse {
while let Some(item) = payload.next().await { // while let Some(item) = payload.next().await {
let mut bytes = web::BytesMut::new(); // let mut bytes = web::BytesMut::new();
let mut field = match item { // let mut field = match item {
Ok(field) => field, // Ok(field) => field,
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
}; // };
let content_type = field.content_disposition(); // let content_type = field.content_disposition();
let filename = match content_type.unwrap().get_filename() { // let filename = match content_type.unwrap().get_filename() {
Some(name) => match name.split(".").last() { // Some(name) => match name.split(".").last() {
Some(ext) => match ext { // Some(ext) => match ext {
"apng" | "avif" | "gif" | "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" | "png" | "svg" // "apng" | "avif" | "gif" | "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" | "png" | "svg"
| "webp" => name, // | "webp" => name,
_ => { // _ => {
return ResponseError::error_response(&ServiceError::new( // return ResponseError::error_response(&ServiceError::new(
400, // 400,
"File extension is not supported".to_string(), // "File extension is not supported".to_string(),
)) // ))
} // }
}, // },
None => { // None => {
return ResponseError::error_response(&ServiceError::new( // return ResponseError::error_response(&ServiceError::new(
400, // 400,
"Unknown file extension".to_string(), // "Unknown file extension".to_string(),
)) // ))
} // }
}, // },
None => { // None => {
return ResponseError::error_response(&ServiceError::new( // return ResponseError::error_response(&ServiceError::new(
400, // 400,
"File name is not provided".to_string(), // "File name is not provided".to_string(),
)) // ))
} // }
}; // };
let path = format!("users/{}/{}", auth.user.email, filename); // let path = format!("users/{}/{}", auth.user.email, filename);
while let Some(chunk) = field.next().await { // while let Some(chunk) = field.next().await {
let data = match chunk { // let data = match chunk {
Ok(data) => data, // Ok(data) => data,
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
}; // };
bytes.extend_from_slice(&data); // bytes.extend_from_slice(&data);
} // }
match upload_file(&path, &bytes).await { // match upload_file(&path, &bytes).await {
Ok(_) => { // Ok(_) => {
match InsertUser::update_profile_picture(&auth.user.email, Some(&path)) { // match User::update_profile_picture(&auth.user.email, Some(&path)) {
Ok(_) => {} // Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
}; // };
} // }
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
}; // };
} // }
HttpResponse::Ok().finish() // HttpResponse::Ok().finish()
} // }
#[get("/picture")] // #[get("/picture")]
async fn get_picture(auth: JwtAuth) -> HttpResponse { // async fn get_picture(auth: Auth) -> HttpResponse {
let user = match QueryUser::get_by_email(&auth.user.email) { // let user = match User::get_by_email(&auth.user.email) {
Ok(user) => user, // Ok(user) => user,
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
}; // };
if let Some(path) = user.profile_picture { // if let Some(path) = user.profile_picture {
match get_file(&path).await { // match get_file(&path).await {
Ok(bytes) => HttpResponse::Ok().body(bytes), // Ok(bytes) => HttpResponse::Ok().body(bytes),
Err(err) => ResponseError::error_response(&err), // Err(err) => ResponseError::error_response(&err),
} // }
} else { // } else {
HttpResponse::NotFound().finish() // HttpResponse::NotFound().finish()
} // }
} // }
#[delete("/picture")] // #[delete("/picture")]
async fn delete_picture(auth: JwtAuth) -> HttpResponse { // async fn delete_picture(auth: Auth) -> HttpResponse {
let user = match QueryUser::get_by_email(&auth.user.email) { // let user = match User::get_by_email(&auth.user.email) {
Ok(user) => user, // Ok(user) => user,
Err(err) => return ResponseError::error_response(&err), // Err(err) => return ResponseError::error_response(&err),
}; // };
if let Some(path) = user.profile_picture { // if let Some(path) = user.profile_picture {
match delete_file(&path).await { // match delete_file(&path).await {
Ok(_) => match InsertUser::update_profile_picture(&auth.user.email, None) { // Ok(_) => match User::update_profile_picture(&auth.user.email, None) {
Ok(_) => HttpResponse::Ok().finish(), // Ok(_) => HttpResponse::Ok().finish(),
Err(err) => ResponseError::error_response(&err), // Err(err) => ResponseError::error_response(&err),
}, // },
Err(err) => ResponseError::error_response(&err), // Err(err) => ResponseError::error_response(&err),
} // }
} else { // } else {
HttpResponse::NotFound().finish() // HttpResponse::NotFound().finish()
} // }
} // }
pub fn init_routes(config: &mut web::ServiceConfig) { pub fn init_routes(config: &mut actix_web::web::ServiceConfig) {
config.service( // config.service(
web::scope("users") // web::scope("users")
.service(get_favorites) // .service(get_favorites)
.service(add_favorite) // .service(add_favorite)
.service(delete_favorite) // .service(delete_favorite)
.service(set_picture) // .service(set_picture)
.service(get_picture) // .service(get_picture)
.service(delete_picture), // .service(delete_picture),
); // );
} }

View File

@@ -1,5 +0,0 @@
SERVICE_HOST=service
SERVICE_PORT=5000
UI_PORT=3000
NODE_ENV=development

View File

@@ -1,26 +0,0 @@
version: '3'
name: aviation
services:
ui:
container_name: aviation-ui
env_file:
- .env
environment:
- NODE_ENV=${NODE_ENV:-development}
ports:
- ${UI_PORT:-3000}:3000
build:
context: ./
target: dev
command: "npm run dev"
volumes:
- ./src:/app/src
- ./public:/app/public
- ./styles:/app/styles
networks:
- frontend
restart: unless-stopped
networks:
frontend: