init project, impl authorization

This commit is contained in:
2022-06-06 14:17:58 +05:00
commit ec73d6584f
26 changed files with 2680 additions and 0 deletions

3
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
*.env
.idea/

1947
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

22
backend/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4.0.1"
serde = "1.0.137"
rmp-serde = "1.1.0"
sqlx = { version = "0.5", features = ["runtime-actix-rustls", "postgres", "migrate", "uuid", "chrono"] }
dotenv = "0.15.0"
uuid = { version = "1.1.1", features = ["v4", "serde"] }
futures = "0.3.21"
derive_more = "0.99.17"
mime = "0.3.16"
rand = "0.8.5"
sha2 = "0.10.2"
chrono = "0.4.19"
jsonwebtoken = "8.1.0"
base64 = "0.13.0"

View File

@ -0,0 +1,9 @@
-- Add migration script here
CREATE TABLE account
(
id uuid primary key,
username varchar(128) unique not null,
password_hash bytea not null,
password_salt varchar(32) not null,
email text null
);

View File

@ -0,0 +1,9 @@
-- Add migration script here
CREATE TABLE refresh_token
(
id uuid not null,
user_id uuid not null,
time_created timestamp not null,
CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES account (id) ON DELETE CASCADE,
PRIMARY KEY (id, user_id)
);

View File

@ -0,0 +1,41 @@
use crate::dto::account::{CreateAccount, Login, Tokens};
use crate::error::AppError;
use crate::utils::extractor::msgpack::MsgPack;
use crate::AccountService;
use actix_web::{post, web, Scope};
pub mod dto {}
#[post("/register")]
pub async fn create_account(
account: MsgPack<CreateAccount>,
account_service: web::Data<AccountService>,
) -> MsgPack<Tokens> {
MsgPack(account_service.create_account(account.0).await)
}
#[post("/login")]
pub async fn login(
login: MsgPack<Login>,
account_service: web::Data<AccountService>,
) -> Result<MsgPack<Tokens>, AppError> {
account_service
.login(login.0)
.await
.map(|data| MsgPack(data))
}
#[post("/refresh")]
pub async fn refresh(
token: String,
account_service: web::Data<AccountService>,
) -> Result<MsgPack<Tokens>, AppError> {
account_service
.refresh_token(token)
.await
.map(|data| MsgPack(data))
}
pub fn auth_controller_builder() -> Scope {
web::scope("/auth").service(create_account)
}

View File

@ -0,0 +1 @@
pub mod auth_controller;

View File

@ -0,0 +1,34 @@
use crate::service::account::entity::Account as AccountEntity;
use crate::utils::uuid::{sqlx_uuid_to_uuid, uuid_to_sqlx_uuid};
pub struct Account {
pub id: sqlx::types::Uuid,
pub username: String,
pub password_hash: Vec<u8>,
pub password_salt: String,
pub email: Option<String>,
}
impl From<AccountEntity> for Account {
fn from(entity: AccountEntity) -> Self {
Account {
id: uuid_to_sqlx_uuid(entity.id),
username: entity.username,
password_hash: entity.password_hash,
password_salt: entity.password_salt,
email: entity.email,
}
}
}
impl Into<AccountEntity> for Account {
fn into(self) -> AccountEntity {
AccountEntity::new(
sqlx_uuid_to_uuid(self.id),
self.username,
self.password_hash,
self.password_salt,
self.email,
)
}
}

View File

@ -0,0 +1,103 @@
use crate::data::account::token::RefreshToken;
use crate::data::account::Account;
use crate::error::AppError;
use crate::service::account::entity::Account as AccountEntity;
use crate::utils::uuid::uuid_to_sqlx_uuid;
use sqlx::types::Uuid;
use sqlx::PgPool;
#[derive(Clone)]
pub struct AccountRepository {
pool: PgPool,
}
impl AccountRepository {
pub fn new(pool: PgPool) -> Self {
AccountRepository { pool }
}
pub async fn create_account(&self, create_account: AccountEntity) -> AccountEntity {
let account: Account = create_account.into();
sqlx::query_as!(
Account,
"INSERT INTO account(id, username, password_hash, password_salt, email) VALUES ($1, $2, $3, $4, $5) RETURNING *",
account.id,
account.username,
account.password_hash,
account.password_salt,
account.email,
)
.fetch_one(&self.pool)
.await
.unwrap()
.into()
}
pub async fn create_refresh_token(&self, for_account: uuid::Uuid) -> RefreshToken {
let id = Uuid::from_bytes(for_account.into_bytes());
sqlx::query_as!(
RefreshToken,
"INSERT INTO refresh_token(id, user_id, time_created) VALUES ($1, $2, $3) RETURNING *",
Uuid::from_bytes(uuid::Uuid::new_v4().into_bytes()),
id,
chrono::Utc::now().naive_utc(),
)
.fetch_one(&self.pool)
.await
.unwrap()
}
pub async fn get_account(&self, id: uuid::Uuid) -> Result<Option<AccountEntity>, AppError> {
Ok(sqlx::query_as!(
Account,
"SELECT * FROM account WHERE id = $1",
uuid_to_sqlx_uuid(id),
)
.fetch_optional(&self.pool)
.await?
.map(|account| account.into()))
}
pub async fn get_account_by_name(
&self,
name: String,
) -> Result<Option<AccountEntity>, AppError> {
Ok(
sqlx::query_as!(Account, "SELECT * FROM account WHERE username = $1", name,)
.fetch_optional(&self.pool)
.await?
.map(|account| account.into()),
)
}
pub async fn get_refresh_token(
&self,
token_id: uuid::Uuid,
user_id: uuid::Uuid,
) -> Result<Option<RefreshToken>, AppError> {
Ok(sqlx::query_as!(
RefreshToken,
"SELECT * FROM refresh_token WHERE id = $1 AND user_id = $2",
uuid_to_sqlx_uuid(token_id),
uuid_to_sqlx_uuid(user_id),
)
.fetch_optional(&self.pool)
.await?)
}
pub async fn delete_refresh_token(
&self,
token_id: uuid::Uuid,
user_id: uuid::Uuid,
) -> Result<(), AppError> {
sqlx::query!(
"DELETE FROM refresh_token WHERE id = $1 AND user_id = $2",
uuid_to_sqlx_uuid(token_id),
uuid_to_sqlx_uuid(user_id),
)
.execute(&self.pool)
.await?;
Ok(())
}
}

View File

@ -0,0 +1,6 @@
mod account;
mod account_repository;
mod token;
pub use account::Account;
pub use account_repository::AccountRepository;

View File

@ -0,0 +1,7 @@
use chrono::NaiveDateTime;
pub struct RefreshToken {
pub id: sqlx::types::Uuid,
pub user_id: sqlx::types::Uuid,
pub time_created: NaiveDateTime,
}

1
backend/src/data/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod account;

View File

@ -0,0 +1,57 @@
use crate::service::account::entity::Account as AccountEntity;
use derive_more::Constructor;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Deserialize)]
pub struct CreateAccount {
pub name: String,
pub email: Option<String>,
pub password: String,
}
#[derive(Deserialize)]
pub struct Login {
pub name: String,
pub password: String,
}
#[derive(Constructor, Serialize)]
pub struct Account {
pub email: Option<String>,
pub name: String,
pub id: Uuid,
}
#[derive(Constructor, Serialize)]
pub struct Tokens {
pub refresh: String,
pub access: String,
}
#[derive(Constructor, Serialize)]
pub struct AccountCreated {
pub account: Account,
pub tokens: Tokens,
}
#[derive(Constructor, Serialize, Deserialize)]
pub struct AccessTokenClaims {
pub exp: i64,
pub sub: String,
pub iat: i64,
}
#[derive(Constructor, Serialize, Deserialize)]
pub struct RefreshTokenClaims {
pub exp: i64,
pub sub: String,
pub iat: i64,
pub refresh_token_id: Uuid,
}
impl From<AccountEntity> for Account {
fn from(entity: AccountEntity) -> Self {
Account::new(entity.email, entity.username, entity.id)
}
}

10
backend/src/dto/mod.rs Normal file
View File

@ -0,0 +1,10 @@
pub mod account;
use derive_more::Constructor;
use serde::Serialize;
#[derive(Serialize, Constructor)]
pub struct ErrorDto {
pub code: String,
pub message: Option<String>,
}

57
backend/src/error/mod.rs Normal file
View File

@ -0,0 +1,57 @@
use crate::dto::ErrorDto;
use actix_web::body::BoxBody;
use actix_web::http::header::TryIntoHeaderValue;
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use derive_more::{Constructor, Display};
use sqlx::Error;
#[derive(Constructor, Display, Debug)]
#[display(
fmt = "AppError:status: {}, code: {}, message: {:?}",
status,
code,
message
)]
pub struct AppError {
code: String,
status: u16,
message: Option<String>,
}
impl AppError {
pub fn from_code(code: String) -> Self {
AppError::from_code_and_status(code, 400)
}
pub fn from_code_and_status(code: String, status: u16) -> Self {
AppError::new(code, status, None)
}
}
impl ResponseError for AppError {
fn status_code(&self) -> StatusCode {
StatusCode::from_u16(self.status).unwrap()
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let mut response = HttpResponse::new(self.status_code());
response.headers_mut().insert(
actix_web::http::header::CONTENT_TYPE,
mime::APPLICATION_MSGPACK.try_into_value().unwrap(),
);
response.set_body(BoxBody::new(
rmp_serde::to_vec(&ErrorDto::new(
self.code.clone(),
self.message.as_ref().map(|data| data.clone()),
))
.unwrap(),
))
}
}
impl From<sqlx::Error> for AppError {
fn from(_: Error) -> Self {
AppError::from_code_and_status("INTERNAL_ERROR".to_string(), 500)
}
}

46
backend/src/main.rs Normal file
View File

@ -0,0 +1,46 @@
mod controller;
mod data;
mod dto;
mod error;
mod service;
mod utils;
use crate::controller::auth_controller::auth_controller_builder;
use crate::data::account::AccountRepository;
use crate::service::account::AccountService;
use crate::utils::env::extract_env_var;
use actix_web::{web, App, HttpServer};
use sqlx::postgres::PgPoolOptions;
const DB_URL_KEY: &str = "DATABASE_URL";
const JWT_SECRET_KEY: &str = "JWT_SECRET";
#[derive(Clone)]
pub struct JwtSecretKey(Vec<u8>);
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().unwrap();
let db_url = extract_env_var(DB_URL_KEY);
let jwt_secret = extract_env_var(JWT_SECRET_KEY);
let pool = PgPoolOptions::new().connect(db_url.as_str()).await.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let key = JwtSecretKey(base64::decode(jwt_secret).unwrap());
let account_repository = AccountRepository::new(pool.clone());
HttpServer::new(move || {
App::new()
.service(auth_controller_builder())
.app_data(web::Data::new(AccountService::new(
account_repository.clone(),
key.clone(),
)))
.app_data(web::Data::new(key.clone()))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}

View File

@ -0,0 +1,139 @@
use crate::data::account::AccountRepository;
use crate::dto::account::{AccessTokenClaims, CreateAccount, Login, RefreshTokenClaims, Tokens};
use crate::error::AppError;
use crate::service::account::entity::Account;
use crate::utils::uuid::sqlx_uuid_to_uuid;
use crate::JwtSecretKey;
use chrono::{Duration, Utc};
use derive_more::Constructor;
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{decode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use rand::Rng;
use sha2::{Digest, Sha512};
use std::ops::Add;
use uuid::Uuid;
#[derive(Constructor, Clone)]
pub struct AccountService {
account_repository: AccountRepository,
secret: JwtSecretKey,
}
impl AccountService {
pub async fn create_account(&self, create_account_dto: CreateAccount) -> Tokens {
let salt: String = rand::thread_rng()
.sample_iter::<char, _>(rand::distributions::Standard)
.take(32)
.collect();
let mut hasher = Sha512::new();
hasher.update(create_account_dto.password.add(salt.as_str()).as_bytes());
let hash = hasher.finalize().to_vec();
let account = self
.account_repository
.create_account(Account::new(
Uuid::new_v4(),
create_account_dto.name,
hash,
salt,
create_account_dto.email,
))
.await;
self.create_tokens(account.id).await
}
pub async fn create_tokens(&self, for_account: Uuid) -> Tokens {
let header = Header::new(Algorithm::HS512);
let iat = Utc::now();
let access_token_claims = AccessTokenClaims::new(
iat.add(Duration::hours(1)).timestamp(),
for_account.to_string(),
iat.timestamp(),
);
let encoding_key = EncodingKey::from_secret(self.secret.0.as_slice());
let access_token =
jsonwebtoken::encode(&header, &access_token_claims, &encoding_key).unwrap();
let refresh_token_data = self
.account_repository
.create_refresh_token(for_account)
.await;
let refresh_token_claims = RefreshTokenClaims::new(
iat.add(Duration::days(30)).timestamp(),
refresh_token_data.user_id.to_string(),
iat.timestamp(),
sqlx_uuid_to_uuid(refresh_token_data.id),
);
let refresh_token =
jsonwebtoken::encode(&header, &refresh_token_claims, &encoding_key).unwrap();
Tokens::new(access_token, refresh_token)
}
pub async fn login(&self, login_dto: Login) -> Result<Tokens, AppError> {
let account = if let Some(account) = self
.account_repository
.get_account_by_name(login_dto.name)
.await?
{
account
} else {
return Err(AppError::from_code("USER_NOT_FOUND".to_string()));
};
let mut hasher = Sha512::new();
hasher.update(
login_dto
.password
.add(account.password_salt.as_str())
.as_bytes(),
);
let hash = hasher.finalize().to_vec();
if hash == account.password_hash {
Ok(self.create_tokens(account.id).await)
} else {
Err(AppError::from_code("INCORRECT_PASSWORD".to_string()))
}
}
pub async fn refresh_token(&self, token: String) -> Result<Tokens, AppError> {
let token = match decode::<RefreshTokenClaims>(
token.as_str(),
&DecodingKey::from_secret(self.secret.0.as_slice()),
&Validation::new(Algorithm::HS512),
) {
Err(err) => {
return Err(match *err.kind() {
ErrorKind::InvalidToken => {
AppError::from_code_and_status("NOT_TOKEN".to_string(), 400)
}
ErrorKind::ExpiredSignature => {
AppError::from_code_and_status("EXPIRED".to_string(), 403)
}
_ => AppError::from_code_and_status("INVALID_TOKEN".to_string(), 401),
});
}
Ok(claims) => claims,
};
let user_uuid = Uuid::parse_str(&token.claims.sub).unwrap();
let token_uuid = token.claims.refresh_token_id;
let old_token = if let Some(token) = self
.account_repository
.get_refresh_token(token_uuid, user_uuid)
.await?
{
token
} else {
return Err(AppError::from_code_and_status(
"INVALID_TOKEN".to_string(),
401,
));
};
if old_token.time_created.add(Duration::days(30)) > Utc::now().naive_utc() {
return Err(AppError::from_code_and_status(
"TOKEN_EXPIRED".to_string(),
400,
));
};
self.account_repository
.delete_refresh_token(token_uuid, user_uuid)
.await?;
Ok(self.create_tokens(user_uuid).await)
}
}

View File

@ -0,0 +1,12 @@
use derive_more::Constructor;
use serde::Serialize;
use uuid::Uuid;
#[derive(Constructor, Serialize)]
pub struct Account {
pub id: Uuid,
pub username: String,
pub password_hash: Vec<u8>,
pub password_salt: String,
pub email: Option<String>,
}

View File

@ -0,0 +1,3 @@
mod account;
pub use account::Account;

View File

@ -0,0 +1,4 @@
mod account_service;
pub mod entity;
pub use account_service::AccountService;

View File

@ -0,0 +1 @@
pub mod account;

8
backend/src/utils/env.rs Normal file
View File

@ -0,0 +1,8 @@
use std::env;
pub fn extract_env_var(key: &str) -> String {
env::var(key).unwrap_or_else(|_| {
println!("Bruh, we need {} to be set", key);
std::process::exit(1)
})
}

View File

@ -0,0 +1 @@
pub mod msgpack;

View File

@ -0,0 +1,149 @@
use actix_web::body::EitherBody;
use actix_web::dev::{Decompress, Payload};
use actix_web::error::PayloadError;
use actix_web::http::StatusCode;
use actix_web::web::{Bytes, BytesMut};
use actix_web::{Error, FromRequest, HttpRequest, HttpResponse, Responder, ResponseError};
use derive_more::Display;
use futures::{ready, Stream};
use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
use std::task::Poll::{Pending, Ready};
use std::task::{Context, Poll};
pub struct MsgPack<T>(pub T);
impl<T: DeserializeOwned> FromRequest for MsgPack<T> {
type Error = Error;
type Future = MsgPackExtractFuture<T>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
MsgPackExtractFuture::new(req.clone(), payload)
}
}
#[derive(Debug, Display)]
pub enum MsgPackError {
#[display(fmt = "Can not deserialize")]
Deserialize(RmpDecodeError),
#[display(fmt = "Reading payload error {}", _0)]
ReadPayload(PayloadError),
#[display(fmt = "Can not serialize msgpack")]
Serialize(RmpEncodeError),
}
impl ResponseError for MsgPackError {
fn status_code(&self) -> StatusCode {
match self {
MsgPackError::Deserialize(_) => StatusCode::UNPROCESSABLE_ENTITY,
MsgPackError::ReadPayload(err) => err.status_code(),
MsgPackError::Serialize(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
pub struct MsgPackExtractFuture<T> {
_req: HttpRequest,
fut: MsgPackBody<T>,
_res: PhantomData<T>,
}
impl<T: DeserializeOwned> MsgPackExtractFuture<T> {
fn new(req: HttpRequest, payload: &mut Payload) -> Self {
MsgPackExtractFuture {
_req: req.clone(),
fut: MsgPackBody::new(req.clone(), payload),
_res: PhantomData {},
}
}
}
impl<T> Unpin for MsgPackExtractFuture<T> {}
impl<T: DeserializeOwned> Future for MsgPackExtractFuture<T> {
type Output = Result<MsgPack<T>, Error>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
let res = ready!(Pin::new(&mut this.fut).poll(cx));
match res {
Ok(data) => Ready(Ok(MsgPack(data))),
Err(err) => Ready(Err(err.into())),
}
}
}
struct MsgPackBody<T> {
buf: BytesMut,
payload: Decompress<Payload>,
_res: PhantomData<T>,
}
impl<T> Unpin for MsgPackBody<T> {}
impl<T: DeserializeOwned> MsgPackBody<T> {
fn new(req: HttpRequest, payload: &mut Payload) -> Self {
MsgPackBody {
payload: Decompress::from_headers(payload.take(), req.headers()),
buf: BytesMut::new(),
_res: PhantomData {},
}
}
}
impl<T: DeserializeOwned> Future for MsgPackBody<T> {
type Output = Result<T, MsgPackError>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
let next = ready!(Pin::new(&mut this.payload).poll_next(cx));
match next {
None => {
let res = match rmp_serde::from_slice(&this.buf) {
Ok(data) => data,
Err(err) => {
return Ready(Err(MsgPackError::Deserialize(err)));
}
};
Ready(Ok(res))
}
Some(res) => {
let data: Bytes = match res {
Ok(data) => data,
Err(err) => {
return Ready(Err(MsgPackError::ReadPayload(err)));
}
};
this.buf.extend_from_slice(&data);
Pending
}
}
}
}
impl<T: Serialize> Responder for MsgPack<T> {
type Body = EitherBody<Bytes>;
fn respond_to(self, _: &HttpRequest) -> HttpResponse<Self::Body> {
match rmp_serde::to_vec(&self.0) {
Ok(data) => {
match HttpResponse::Ok()
.content_type(mime::APPLICATION_MSGPACK)
.message_body(Bytes::from(data))
{
Ok(response) => response.map_into_left_body(),
Err(err) => HttpResponse::from_error(err).map_into_right_body(),
}
}
Err(err) => {
HttpResponse::from_error(MsgPackError::Serialize(err)).map_into_right_body()
}
}
}
}

3
backend/src/utils/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod env;
pub mod extractor;
pub mod uuid;

View File

@ -0,0 +1,7 @@
pub fn sqlx_uuid_to_uuid(from: sqlx::types::Uuid) -> uuid::Uuid {
uuid::Uuid::from_bytes(*from.as_bytes())
}
pub fn uuid_to_sqlx_uuid(from: uuid::Uuid) -> sqlx::types::Uuid {
sqlx::types::Uuid::from_bytes(from.into_bytes())
}