Add cookie based auth, file serving and uploading
This commit is contained in:
12
src/app.rs
12
src/app.rs
@@ -8,6 +8,7 @@ use loco_rs::{
|
||||
controller::AppRoutes,
|
||||
db::{self, truncate_table},
|
||||
environment::Environment,
|
||||
storage::{self, Storage},
|
||||
task::Tasks,
|
||||
Result,
|
||||
};
|
||||
@@ -47,6 +48,7 @@ impl Hooks for App {
|
||||
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes() // controller routes below
|
||||
.add_route(controllers::data::routes())
|
||||
.add_route(controllers::auth::routes())
|
||||
.add_route(controllers::website::routes())
|
||||
}
|
||||
@@ -56,8 +58,18 @@ impl Hooks for App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||
let store = storage::drivers::local::new_with_prefix("uploads").map_err(Box::from)?;
|
||||
|
||||
Ok(AppContext {
|
||||
storage: Storage::single(store).into(),
|
||||
..ctx
|
||||
})
|
||||
}
|
||||
|
||||
fn register_tasks(tasks: &mut Tasks) {
|
||||
tasks.register(tasks::seed::SeedData);
|
||||
tasks.register(tasks::create_user::CreateUserData);
|
||||
}
|
||||
|
||||
async fn truncate(db: &DatabaseConnection) -> Result<()> {
|
||||
|
@@ -1,14 +1,16 @@
|
||||
use axum::debug_handler;
|
||||
use axum::http::header;
|
||||
use axum::http::HeaderValue;
|
||||
use axum::response::Redirect;
|
||||
use cookie::Cookie;
|
||||
use cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
mailers::auth::AuthMailer,
|
||||
models::{
|
||||
_entities::users,
|
||||
users::{LoginParams, RegisterParams},
|
||||
},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
models::{_entities::users, users::LoginParams},
|
||||
views::auth::CurrentResponse,
|
||||
};
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct VerifyParams {
|
||||
@@ -26,37 +28,6 @@ pub struct ResetParams {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Register function creates a new user with the given parameters and sends a
|
||||
/// welcome email to the user
|
||||
#[debug_handler]
|
||||
async fn register(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<RegisterParams>,
|
||||
) -> Result<Response> {
|
||||
let res = users::Model::create_with_password(&ctx.db, ¶ms).await;
|
||||
|
||||
let user = match res {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
tracing::info!(
|
||||
message = err.to_string(),
|
||||
user_email = ¶ms.email,
|
||||
"could not register user",
|
||||
);
|
||||
return format::json(());
|
||||
}
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Verify register user. if the user not verified his email, he can't login to
|
||||
/// the system.
|
||||
#[debug_handler]
|
||||
@@ -121,7 +92,11 @@ async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -
|
||||
|
||||
/// Creates a user login and returns a token
|
||||
#[debug_handler]
|
||||
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
|
||||
async fn login(
|
||||
State(ctx): State<AppContext>,
|
||||
jar: CookieJar,
|
||||
Form(params): Form<LoginParams>,
|
||||
) -> Result<Response> {
|
||||
let user = users::Model::find_by_email(&ctx.db, ¶ms.email).await?;
|
||||
|
||||
let valid = user.verify_password(¶ms.password);
|
||||
@@ -136,7 +111,32 @@ async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -
|
||||
.generate_jwt(&jwt_secret.secret, &jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::json(LoginResponse::new(&user, &token))
|
||||
let cookie = Cookie::build(("token", token.clone()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(cookie::SameSite::Lax);
|
||||
let cookie = jar.add(cookie);
|
||||
|
||||
let cookie = cookie.get("token").map(|c| c.value().to_owned());
|
||||
|
||||
let mut response = Redirect::to("/").into_response();
|
||||
match cookie {
|
||||
Some(token) => {
|
||||
let cookie = format!("token={}; Path=/; HttpOnly; Secure; SameSite=Lax", token);
|
||||
let authorization_header = format!("Bearer {}", token);
|
||||
response
|
||||
.headers_mut()
|
||||
.append(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
|
||||
response.headers_mut().append(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_str(&authorization_header).unwrap(),
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -145,13 +145,24 @@ async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Respo
|
||||
format::json(CurrentResponse::new(&user))
|
||||
}
|
||||
|
||||
async fn logout() -> impl IntoResponse {
|
||||
let cookie = "token=''; Path=/; HttpOnly; Secure; SameSite=Lax";
|
||||
let mut response = Redirect::to("/").into_response();
|
||||
|
||||
response
|
||||
.headers_mut()
|
||||
.append(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/auth")
|
||||
.add("/register", post(register))
|
||||
.add("/verify", post(verify))
|
||||
.add("/login", post(login))
|
||||
.add("/forgot", post(forgot))
|
||||
.add("/reset", post(reset))
|
||||
.add("/current", get(current))
|
||||
.add("/logout", get(logout))
|
||||
}
|
||||
|
39
src/controllers/data.rs
Normal file
39
src/controllers/data.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use axum::extract::Multipart;
|
||||
use loco_rs::prelude::*;
|
||||
use tokio::fs::File;
|
||||
|
||||
use axum_range::KnownSize;
|
||||
use axum_range::Ranged;
|
||||
|
||||
use axum_extra::headers::Range;
|
||||
use axum_extra::TypedHeader;
|
||||
|
||||
use crate::services;
|
||||
|
||||
pub async fn get_data(
|
||||
auth: auth::JWT,
|
||||
range: Option<TypedHeader<Range>>,
|
||||
Path(file_name): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Ranged<KnownSize<File>>> {
|
||||
services::data::serve_data_file(&auth, range, &file_name, &ctx).await
|
||||
}
|
||||
|
||||
pub async fn upload_data(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
payload: Multipart,
|
||||
) -> Result<Response> {
|
||||
services::data::add(&auth, &ctx, payload).await?;
|
||||
format::html("<h1>File uploaded successfully</h1>")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/data/")
|
||||
.add("/upload", post(upload_data))
|
||||
.add("/:file_name", get(get_data))
|
||||
}
|
@@ -1,2 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod website;
|
||||
|
||||
pub mod data;
|
@@ -12,6 +12,12 @@ pub async fn render_index(
|
||||
views::website::index(v, &ctx).await
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().add("/", get(render_index))
|
||||
pub async fn render_login(ViewEngine(v): ViewEngine<TeraView>) -> impl IntoResponse {
|
||||
views::website::login(v).await
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/", get(render_index))
|
||||
.add("/login", get(render_login))
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ pub struct Model {
|
||||
pub id: i32,
|
||||
pub file_url: String,
|
||||
pub protected: bool,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
105
src/services/data.rs
Normal file
105
src/services/data.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::models::_entities::data::{self, ActiveModel, Entity, Model};
|
||||
use crate::models::users::users;
|
||||
use axum::extract::Multipart;
|
||||
use axum_extra::headers::Range;
|
||||
use axum_extra::TypedHeader;
|
||||
use loco_rs::prelude::*;
|
||||
use tokio::fs::File;
|
||||
|
||||
use axum_range::KnownSize;
|
||||
use axum_range::Ranged;
|
||||
|
||||
pub async fn get_data_by_file_name(file_name: &str, ctx: &AppContext) -> ModelResult<Model> {
|
||||
let data = Entity::find()
|
||||
.filter(data::Column::FileName.eq(file_name))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
data.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
pub async fn serve_data_file(
|
||||
auth: &auth::JWT,
|
||||
range: Option<TypedHeader<Range>>,
|
||||
file_name: &str,
|
||||
ctx: &AppContext,
|
||||
) -> Result<Ranged<KnownSize<File>>> {
|
||||
let data = get_data_by_file_name(&file_name, &ctx).await?;
|
||||
if data.protected {
|
||||
match users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await {
|
||||
Ok(_) => {}
|
||||
Err(_) => return unauthorized("Unauthorized"),
|
||||
}
|
||||
}
|
||||
|
||||
let file = File::open(&data.file_url).await?;
|
||||
let body = KnownSize::file(file).await?;
|
||||
let range = range.map(|TypedHeader(range)| range);
|
||||
Ok(Ranged::new(range, body))
|
||||
}
|
||||
|
||||
pub async fn add(auth: &auth::JWT, ctx: &AppContext, mut payload: Multipart) -> ModelResult<Model> {
|
||||
let _current_user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
let mut protected = None;
|
||||
let mut file_name = None;
|
||||
|
||||
while let Some(field) = payload
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| ModelError::Any("Failed to get next field".into()))?
|
||||
{
|
||||
let name = field
|
||||
.name()
|
||||
.ok_or_else(|| ModelError::Any("Failed to get field name".into()))?;
|
||||
match name {
|
||||
"protected" => {
|
||||
let value = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| ModelError::Any("Failed to get text".into()))?
|
||||
.parse::<bool>()
|
||||
.map_err(|_| ModelError::Any("Failed to parse bool".into()))?;
|
||||
protected = Some(value);
|
||||
}
|
||||
"file" => {
|
||||
file_name = match field.file_name() {
|
||||
Some(file_name) => Some(String::from(file_name)),
|
||||
None => return Err(ModelError::Any("Failed to get file name".into())),
|
||||
};
|
||||
|
||||
if file_name.is_none() {
|
||||
return Err(ModelError::Any("Failed to get file name".into()));
|
||||
}
|
||||
|
||||
let path = PathBuf::from("uploads").join(file_name.as_ref().unwrap());
|
||||
|
||||
let content = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| ModelError::Any("Failed to get bytes".into()))?;
|
||||
|
||||
match ctx.storage.as_ref().upload(path.as_path(), &content).await {
|
||||
Ok(_) => {}
|
||||
Err(_) => return Err(ModelError::Any("Failed to save file to storage".into())),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let protected =
|
||||
protected.ok_or_else(|| ModelError::Any("Protected field is required".into()))?;
|
||||
let file_name = file_name.ok_or_else(|| ModelError::Any("File field is required".into()))?;
|
||||
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
item.protected = Set(protected);
|
||||
item.file_name = Set(file_name);
|
||||
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
Ok(item)
|
||||
}
|
@@ -1,2 +1,3 @@
|
||||
pub mod data;
|
||||
pub mod jobs;
|
||||
pub mod skills;
|
||||
|
39
src/tasks/create_user.rs
Normal file
39
src/tasks/create_user.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::models::users::{self};
|
||||
|
||||
pub struct CreateUserData;
|
||||
|
||||
#[async_trait]
|
||||
impl Task for CreateUserData {
|
||||
fn task(&self) -> TaskInfo {
|
||||
TaskInfo {
|
||||
name: "create_user".to_string(),
|
||||
detail: "Task for creating a new user".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, app_context: &AppContext, vars: &task::Vars) -> Result<()> {
|
||||
let username = vars.cli_arg("username")?;
|
||||
let email = vars.cli_arg("email")?;
|
||||
let password = vars.cli_arg("password")?;
|
||||
|
||||
let user = users::Model::create_with_password(
|
||||
&app_context.db,
|
||||
&users::RegisterParams {
|
||||
name: username.to_string(),
|
||||
email: email.to_string(),
|
||||
password: password.to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
user_id = user.id,
|
||||
user_email = &user.email,
|
||||
"User created successfully",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@@ -1 +1,2 @@
|
||||
pub mod create_user;
|
||||
pub mod seed;
|
||||
|
@@ -12,3 +12,7 @@ pub async fn index(v: impl ViewRenderer, ctx: &AppContext) -> Result<impl IntoRe
|
||||
data!({ "skills": skills, "jobs": jobs }),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn login(v: impl ViewRenderer) -> Result<impl IntoResponse> {
|
||||
format::render().view(&v, "website/login.html", data!({}))
|
||||
}
|
||||
|
Reference in New Issue
Block a user