auth and migration util

This commit is contained in:
realaravinth
2021-03-10 20:43:25 +05:30
parent e500a84c09
commit 328fe5ed3a
20 changed files with 3618 additions and 303 deletions

View File

@@ -20,6 +20,22 @@ jobs:
name: ${{ matrix.version }} - x86_64-unknown-linux-gnu name: ${{ matrix.version }} - x86_64-unknown-linux-gnu
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: password
POSTGRES_USER: postgres
POSTGRES_DB: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: ⚡ Cache - name: ⚡ Cache
@@ -38,6 +54,14 @@ jobs:
profile: minimal profile: minimal
override: true override: true
- name: Run migrations
uses: actions-rs/cargo@v1
with:
command: run
args: --bin tests-migrate
env:
DATABASE_URL: postgres://postgres:password@localhost:5432/postgres
- name: check build - name: check build
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: with:

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/target /target
tarpaulin-report.html tarpaulin-report.html
.env .env
.env

3162
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,26 @@ name = "guard"
version = "0.1.0" version = "0.1.0"
authors = ["realaravinth <realaravinth@batsense.net>"] authors = ["realaravinth <realaravinth@batsense.net>"]
edition = "2018" edition = "2018"
default-run = "guard"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "guard"
path = "./src/main.rs"
[[bin]]
name = "tests-migrate"
path = "./src/tests-migrate.rs"
[dependencies] [dependencies]
actix-web = "3" actix-web = "3"
actix = "0.10"
sqlx = { version = "0.4.0", features = [ "runtime-actix-rustls", "postgres" ] } sqlx = { version = "0.4.0", features = [ "runtime-actix-rustls", "postgres" ] }
argon2-creds = { version = "0.2", git = "https://github.com/realaravinth/argon2-creds" } argon2-creds = { version = "0.2", git = "https://github.com/realaravinth/argon2-creds", tag = "0.2.0" }
config = "0.10" config = "0.10"
validator = "0.12" validator = "0.12"
@@ -23,10 +35,16 @@ serde_json = "1"
url = "2.2" url = "2.2"
pretty_env_logger = "0.3" pretty_env_logger = "0.4"
log = "0.4" log = "0.4"
lazy_static = "1.4" lazy_static = "1.4"
actix-identity = "0.3" actix-identity = "0.3"
actix-http = "2.2" actix-http = "2.2"
m_captcha = { version = "0.1.0", git = "https://github.com/mCaptcha/mCaptcha", tag = "0.1.0" }
[dev-dependencies]
actix-rt = "1"

View File

@@ -12,7 +12,7 @@ hostname = "localhost"
port = "5432" port = "5432"
username = "postgres" username = "postgres"
password = "password" password = "password"
name = "webhunt-postgress" name = "postgres"
pool = 4 pool = 4
# This section deals with the configuration of the actual server # This section deals with the configuration of the actual server
@@ -28,3 +28,7 @@ domain = "localhost"
allow_registration = true allow_registration = true
# directory containing static files # directory containing static files
static_files_dir = "./frontend/dist" static_files_dir = "./frontend/dist"
[pow]
salt = "asdl;kjfhjawehfpa;osdkjasdvjaksndfpoanjdfainsdfaijdsfajlkjdsaf;ajsdfweroire"

View File

@@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS mcaptcha_config (
name VARCHAR(100) references mcaptcha_users(name),
id VARCHAR(32) PRIMARY KEY NOT NULL UNIQUE,
duration INTEGER NOT NULL
);

View File

@@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS mcaptcha_levels (
id VARCHAR(32) references mcaptcha_config(id),
difficulty_factor INTEGER NOT NULL,
visitor_threshold INTEGER NOT NULL
);

View File

@@ -1,4 +1,6 @@
CREATE TABLE IF NOT EXISTS mcaptcha_users ( CREATE TABLE IF NOT EXISTS mcaptcha_users (
name VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password TEXT NOT NULL,
ID SERIAL PRIMARY KEY NOT NULL ID SERIAL PRIMARY KEY NOT NULL
); );

View File

@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS mcaptcha_domains (
name VARCHAR(100) PRIMARY KEY NOT NULL UNIQUE,
ID INTEGER references mcaptcha_users(ID)
);

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS mcaptcha_config (
config_id SERIAL PRIMARY KEY NOT NULL,
ID INTEGER references mcaptcha_users(ID),
key VARCHAR(100) NOT NULL UNIQUE,
duration INTEGER NOT NULL
);

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS mcaptcha_levels (
config_id INTEGER references mcaptcha_config(config_id),
difficulty_factor INTEGER NOT NULL,
visitor_threshold INTEGER NOT NULL,
level_id SERIAL PRIMARY KEY NOT NULL
);

18
src/api/mod.rs Normal file
View File

@@ -0,0 +1,18 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod v1;

235
src/api/v1/auth.rs Normal file
View File

@@ -0,0 +1,235 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_identity::Identity;
use actix_web::{post, web, HttpResponse, Responder};
use log::debug;
use serde::{Deserialize, Serialize};
use crate::errors::*;
use crate::Data;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SomeData {
pub a: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Register {
pub username: String,
pub password: String,
pub email: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Login {
pub username: String,
pub password: String,
}
struct Password {
password: String,
}
#[post("/api/v1/signup")]
pub async fn signup(
payload: web::Json<Register>,
data: web::Data<Data>,
) -> ServiceResult<impl Responder> {
let username = data.creds.username(&payload.username)?;
let hash = data.creds.password(&payload.password)?;
data.creds.email(Some(&payload.email))?;
sqlx::query!(
"INSERT INTO mcaptcha_users (name , password, email) VALUES ($1, $2, $3)",
username,
hash,
&payload.email
)
.execute(&data.db)
.await?;
Ok(HttpResponse::Ok())
}
#[post("/api/v1/signin")]
pub async fn signin(
id: Identity,
payload: web::Json<Login>,
data: web::Data<Data>,
) -> ServiceResult<impl Responder> {
use argon2_creds::Config;
use sqlx::Error::RowNotFound;
let rec = sqlx::query_as!(
Password,
r#"SELECT password FROM mcaptcha_users WHERE name = ($1)"#,
&payload.username,
)
.fetch_one(&data.db)
.await;
match rec {
Ok(s) => {
if Config::verify(&s.password, &payload.password)? {
debug!("remembered {}", payload.username);
id.remember(payload.into_inner().username);
Ok(HttpResponse::Ok())
} else {
Err(ServiceError::WrongPassword)
}
}
Err(RowNotFound) => return Err(ServiceError::UsernameNotFound),
Err(_) => return Err(ServiceError::InternalServerError)?,
}
}
#[post("/api/v1/signout")]
pub async fn signout(id: Identity) -> impl Responder {
if let Some(_) = id.identity() {
id.forget();
}
HttpResponse::Ok()
}
fn is_authenticated(id: &Identity) -> ServiceResult<bool> {
debug!("{:?}", id.identity());
// access request identity
if let Some(_) = id.identity() {
Ok(true)
} else {
Err(ServiceError::AuthorizationRequired)
}
}
#[cfg(test)]
mod tests {
use actix_web::http::{header, StatusCode};
use actix_web::test;
use super::*;
use crate::api::v1::services as v1_services;
use crate::data::Data;
use crate::*;
pub async fn delete_user(name: &str, data: &Data) {
let _ = sqlx::query!("DELETE FROM mcaptcha_users WHERE name = ($1)", name,)
.execute(&data.db)
.await;
}
macro_rules! post_request {
($serializable:expr, $uri:expr) => {
test::TestRequest::post()
.uri($uri)
.header(header::CONTENT_TYPE, "application/json")
.set_payload(serde_json::to_string($serializable).unwrap())
};
}
macro_rules! get_server {
() => {
App::new()
.wrap(middleware::Logger::default())
.wrap(get_identity_service())
.wrap(middleware::Compress::default())
.wrap(middleware::NormalizePath::new(
middleware::normalize::TrailingSlash::Trim,
))
.app_data(get_json_err())
.configure(v1_services)
};
}
#[actix_rt::test]
async fn auth_works() {
let data = Data::new().await;
const NAME: &str = "testuser";
const PASSWORD: &str = "longpassword";
const EMAIL: &str = "testuser1@a.com";
let mut app = test::init_service(get_server!().data(data.clone())).await;
delete_user(NAME, &data).await;
// 1. Register
let msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
email: EMAIL.into(),
};
let resp =
test::call_service(&mut app, post_request!(&msg, "/api/v1/signup").to_request()).await;
assert_eq!(resp.status(), StatusCode::OK);
// 2. check if duplicate username is allowed
let duplicate_user_resp =
test::call_service(&mut app, post_request!(&msg, "/api/v1/signup").to_request()).await;
assert_eq!(duplicate_user_resp.status(), StatusCode::BAD_REQUEST);
// 3. signin
let sigin_msg = Login {
username: NAME.into(),
password: PASSWORD.into(),
};
let signin_resp = test::call_service(
&mut app,
post_request!(&sigin_msg, "/api/v1/signin").to_request(),
)
.await;
assert_eq!(signin_resp.status(), StatusCode::OK);
let cookies = signin_resp.response().cookies().next().unwrap().to_owned();
// 4. sigining in with non-existent user
let nonexistantuser = Login {
username: "nonexistantuser".into(),
password: msg.password.clone(),
};
let userdoesntexist = test::call_service(
&mut app,
post_request!(&nonexistantuser, "/api/v1/signin").to_request(),
)
.await;
assert_eq!(userdoesntexist.status(), StatusCode::UNAUTHORIZED);
let txt: ErrorToResponse = test::read_body_json(userdoesntexist).await;
assert_eq!(txt.error, format!("{}", ServiceError::UsernameNotFound));
// 5. trying to signin with wrong password
let wrongpassword = Login {
username: NAME.into(),
password: NAME.into(),
};
let wrongpassword_resp = test::call_service(
&mut app,
post_request!(&wrongpassword, "/api/v1/signin").to_request(),
)
.await;
assert_eq!(wrongpassword_resp.status(), StatusCode::UNAUTHORIZED);
let txt: ErrorToResponse = test::read_body_json(wrongpassword_resp).await;
assert_eq!(txt.error, format!("{}", ServiceError::WrongPassword));
// 6. signout
let signout_resp = test::call_service(
&mut app,
post_request!(&wrongpassword, "/api/v1/signout")
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(signout_resp.status(), StatusCode::OK);
delete_user(NAME, &data).await;
}
}

27
src/api/v1/mod.rs Normal file
View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::web::ServiceConfig;
pub mod auth;
pub fn services(cfg: &mut ServiceConfig) {
use auth::*;
cfg.service(signout);
cfg.service(signin);
cfg.service(signup);
}

View File

@@ -15,7 +15,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
use actix::prelude::*;
use argon2_creds::{Config, ConfigBuilder, PasswordPolicy}; use argon2_creds::{Config, ConfigBuilder, PasswordPolicy};
use m_captcha::{
cache::HashCache,
master::Master,
pow::ConfigBuilder as PoWConfigBuilder,
system::{System, SystemBuilder},
};
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
@@ -25,6 +32,7 @@ use crate::SETTINGS;
pub struct Data { pub struct Data {
pub db: PgPool, pub db: PgPool,
pub creds: Config, pub creds: Config,
pub captcha: System<HashCache>,
} }
impl Data { impl Data {
@@ -44,6 +52,20 @@ impl Data {
.build() .build()
.unwrap(); .unwrap();
Data { creds, db } let master = Master::new().start();
let cache = HashCache::default().start();
let pow = PoWConfigBuilder::default()
.salt(SETTINGS.pow.salt.clone())
.build()
.unwrap();
let captcha = SystemBuilder::default()
.master(master)
.cache(cache)
.pow(pow)
.build()
.unwrap();
Data { creds, db, captcha }
} }
} }

View File

@@ -1,4 +1,19 @@
use std::io::{Error as IOError, ErrorKind as IOErrorKind}; /*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::{ use actix_web::{
dev::HttpResponseBuilder, dev::HttpResponseBuilder,
@@ -11,7 +26,7 @@ use argon2_creds::errors::CredsError;
use derive_more::{Display, Error}; use derive_more::{Display, Error};
use log::debug; use log::debug;
use serde::Serialize; use serde::{Deserialize, Serialize};
// use validator::ValidationErrors; // use validator::ValidationErrors;
use std::convert::From; use std::convert::From;
@@ -23,14 +38,11 @@ pub enum ServiceError {
InternalServerError, InternalServerError,
#[display(fmt = "The value you entered for email is not an email")] //405j #[display(fmt = "The value you entered for email is not an email")] //405j
NotAnEmail, NotAnEmail,
#[display(fmt = "File not found")] #[display(fmt = "Wrong password")]
FileNotFound, WrongPassword,
#[display(fmt = "File exists")] #[display(fmt = "Username not found")]
FileExists, UsernameNotFound,
#[display(fmt = "Permission denied")]
PermissionDenied,
#[display(fmt = "Invalid credentials")]
InvalidCredentials,
#[display(fmt = "Authorization required")] #[display(fmt = "Authorization required")]
AuthorizationRequired, AuthorizationRequired,
@@ -51,15 +63,16 @@ pub enum ServiceError {
/// when the value passed contains profainity /// when the value passed contains profainity
#[display(fmt = "Username not available")] #[display(fmt = "Username not available")]
UsernameTaken, UsernameTaken,
/// when a question is already answered #[display(fmt = "Passsword too short")]
#[display(fmt = "Already answered")] PasswordTooShort,
AlreadyAnswered, #[display(fmt = "Username too long")]
PasswordTooLong,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
#[cfg(not(tarpaulin_include))] #[cfg(not(tarpaulin_include))]
struct ErrorToResponse { pub struct ErrorToResponse {
error: String, pub error: String,
} }
impl ResponseError for ServiceError { impl ResponseError for ServiceError {
@@ -75,28 +88,15 @@ impl ResponseError for ServiceError {
match *self { match *self {
ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::NotAnEmail => StatusCode::BAD_REQUEST, ServiceError::NotAnEmail => StatusCode::BAD_REQUEST,
ServiceError::FileNotFound => StatusCode::NOT_FOUND, ServiceError::WrongPassword => StatusCode::UNAUTHORIZED,
ServiceError::FileExists => StatusCode::METHOD_NOT_ALLOWED, ServiceError::UsernameNotFound => StatusCode::UNAUTHORIZED,
ServiceError::PermissionDenied => StatusCode::UNAUTHORIZED,
ServiceError::InvalidCredentials => StatusCode::UNAUTHORIZED,
ServiceError::AuthorizationRequired => StatusCode::UNAUTHORIZED, ServiceError::AuthorizationRequired => StatusCode::UNAUTHORIZED,
ServiceError::ProfainityError => StatusCode::BAD_REQUEST, ServiceError::ProfainityError => StatusCode::BAD_REQUEST,
ServiceError::BlacklistError => StatusCode::BAD_REQUEST, ServiceError::BlacklistError => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST,
ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST, ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST,
ServiceError::UsernameTaken => StatusCode::BAD_REQUEST, ServiceError::UsernameTaken => StatusCode::BAD_REQUEST,
ServiceError::AlreadyAnswered => StatusCode::BAD_REQUEST,
}
}
}
impl From<IOError> for ServiceError {
fn from(e: IOError) -> ServiceError {
debug!("{:?}", &e);
match e.kind() {
IOErrorKind::NotFound => ServiceError::FileNotFound,
IOErrorKind::PermissionDenied => ServiceError::PermissionDenied,
IOErrorKind::AlreadyExists => ServiceError::FileExists,
_ => ServiceError::InternalServerError,
} }
} }
} }
@@ -110,7 +110,8 @@ impl From<CredsError> for ServiceError {
CredsError::BlacklistError => ServiceError::BlacklistError, CredsError::BlacklistError => ServiceError::BlacklistError,
CredsError::NotAnEmail => ServiceError::NotAnEmail, CredsError::NotAnEmail => ServiceError::NotAnEmail,
CredsError::Argon2Error(_) => ServiceError::InternalServerError, CredsError::Argon2Error(_) => ServiceError::InternalServerError,
_ => ServiceError::InternalServerError, CredsError::PasswordTooLong => ServiceError::PasswordTooLong,
CredsError::PasswordTooShort => ServiceError::PasswordTooShort,
} }
} }
} }

View File

@@ -24,6 +24,7 @@ use lazy_static::lazy_static;
mod data; mod data;
mod errors; mod errors;
//mod routes; //mod routes;
mod api;
mod settings; mod settings;
pub use data::Data; pub use data::Data;
@@ -35,24 +36,24 @@ lazy_static! {
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
// use routes::services; use api::v1::services as v1_services;
// let data = Data::new().await; let data = Data::new().await;
pretty_env_logger::init(); pretty_env_logger::init();
// sqlx::migrate!("./migrations/").run(&data.db).await.unwrap(); sqlx::migrate!("./migrations/").run(&data.db).await.unwrap();
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.wrap(middleware::Logger::default()) .wrap(middleware::Logger::default())
.wrap(get_identity_service()) .wrap(get_identity_service())
.wrap(middleware::Compress::default()) .wrap(middleware::Compress::default())
// .data(data.clone()) .data(data.clone())
.wrap(middleware::NormalizePath::new( .wrap(middleware::NormalizePath::new(
middleware::normalize::TrailingSlash::Trim, middleware::normalize::TrailingSlash::Trim,
)) ))
.app_data(get_json_err()) .app_data(get_json_err())
//.configure(services) .configure(v1_services)
}) })
.bind(SETTINGS.server.get_ip()) .bind(SETTINGS.server.get_ip())
.unwrap() .unwrap()
@@ -61,7 +62,7 @@ async fn main() -> std::io::Result<()> {
} }
#[cfg(not(tarpaulin_include))] #[cfg(not(tarpaulin_include))]
fn get_json_err() -> JsonConfig { pub fn get_json_err() -> JsonConfig {
JsonConfig::default().error_handler(|err, _| { JsonConfig::default().error_handler(|err, _| {
//debug!("JSON deserialization error: {:?}", &err); //debug!("JSON deserialization error: {:?}", &err);
InternalError::new(err, StatusCode::BAD_REQUEST).into() InternalError::new(err, StatusCode::BAD_REQUEST).into()
@@ -69,7 +70,7 @@ fn get_json_err() -> JsonConfig {
} }
#[cfg(not(tarpaulin_include))] #[cfg(not(tarpaulin_include))]
fn get_identity_service() -> IdentityService<CookieIdentityPolicy> { pub fn get_identity_service() -> IdentityService<CookieIdentityPolicy> {
let cookie_secret = &SETTINGS.server.cookie_secret; let cookie_secret = &SETTINGS.server.cookie_secret;
IdentityService::new( IdentityService::new(
CookieIdentityPolicy::new(cookie_secret.as_bytes()) CookieIdentityPolicy::new(cookie_secret.as_bytes())

View File

@@ -1,248 +0,0 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_identity::Identity;
use actix_web::{
get, post,
web::{self, Path as WebPath, ServiceConfig},
HttpResponse, Responder,
};
use log::debug;
use serde::{Deserialize, Serialize};
use crate::errors::*;
use crate::Data;
#[derive(Clone, Debug, Deserialize, Serialize)]
struct SomeData {
pub a: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Creds {
pub username: String,
pub password: String,
}
#[post("/api/signup")]
async fn signup(payload: web::Json<Creds>, data: web::Data<Data>) -> ServiceResult<impl Responder> {
let username = data.creds.username(&payload.username)?;
let hash = data.creds.password(&payload.password)?;
sqlx::query!(
"INSERT INTO users (name , password) VALUES ($1, $2)",
username,
hash
)
.execute(&data.db)
.await?;
Ok(HttpResponse::Ok())
}
struct Password {
password: String,
}
#[post("/api/signin")]
async fn signin(
id: Identity,
payload: web::Json<Creds>,
data: web::Data<Data>,
) -> ServiceResult<impl Responder> {
use argon2_creds::Config;
let rec = sqlx::query_as!(
Password,
"SELECT password FROM users WHERE name = ($1)",
&payload.username
)
.fetch_one(&data.db)
.await?;
if Config::verify(&rec.password, &payload.password)? {
debug!("remembered {}", payload.username);
id.remember(payload.into_inner().username);
return Ok(HttpResponse::Ok());
} else {
return Err(ServiceError::InvalidCredentials);
}
}
#[get("/api/signout")]
async fn signout(id: Identity) -> impl Responder {
if let Some(_) = id.identity() {
id.forget();
}
HttpResponse::Ok()
}
#[get("/questions/{id}")]
async fn get_question(
//session: Session,
id: Identity,
path: WebPath<(u32,)>,
) -> ServiceResult<impl Responder> {
is_authenticated(&id)?;
Ok(HttpResponse::Ok().body(format!("User detail: {}", path.into_inner().0)))
}
struct LevelScore {
level: i32,
points: i32,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Answer {
answer: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct AnswerDatabaseFetch {
answer: String,
points: i32,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct AnswerVerifyResp {
correct: bool,
points: i32,
}
#[post("/api/answer/verify/{id}")]
async fn verify_answer(
//session: Session,
payload: web::Json<Answer>,
data: web::Data<Data>,
id: Identity,
path: WebPath<(u32,)>,
) -> ServiceResult<impl Responder> {
is_authenticated(&id)?;
let name = id.identity().unwrap();
let rec = sqlx::query_as!(
LevelScore,
"SELECT level, points FROM users WHERE name = ($1)",
&name
)
.fetch_one(&data.db)
.await?;
let current = path.into_inner().0 as i32;
if rec.level == current {
// TODO
// check answer
let answer = sqlx::query_as!(
AnswerDatabaseFetch,
"SELECT answer, points FROM answers WHERE question_num = ($1)",
&current
)
.fetch_one(&data.db)
.await?;
let resp;
// TODO all answers lowercase?
if payload.answer.trim().to_lowercase() == answer.answer {
let points = rec.points + answer.points;
resp = AnswerVerifyResp {
correct: true,
points,
};
sqlx::query!(
"UPDATE users SET points = $1, level = $2 WHERE name = $3",
points,
rec.level + 1,
name
)
.execute(&data.db)
.await?;
} else {
resp = AnswerVerifyResp {
correct: false,
points: rec.points,
};
}
return Ok(HttpResponse::Ok().json(resp));
} else if rec.level > current {
return Err(ServiceError::AlreadyAnswered);
} else {
return Err(ServiceError::AuthorizationRequired);
}
}
#[get("/api/score")]
async fn score(
//session: Session,
// payload: web::Json<SomeData>,
data: web::Data<Data>,
id: Identity,
) -> ServiceResult<impl Responder> {
debug!("{:?}", id.identity());
is_authenticated(&id)?;
let recs = sqlx::query_as!(
Leader,
"SELECT name, points FROM users ORDER BY points DESC"
)
.fetch_all(&data.db)
.await?;
Ok(HttpResponse::Ok().json(recs))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Leader {
name: String,
points: i32,
}
#[get("/api/leaderboard")]
async fn leaderboard(
//session: Session,
// payload: web::Json<SomeData>,
data: web::Data<Data>,
id: Identity,
) -> ServiceResult<impl Responder> {
is_authenticated(&id)?;
let recs = sqlx::query_as!(
Leader,
"SELECT name, points FROM users ORDER BY points DESC"
)
.fetch_all(&data.db)
.await?;
debug!("{:?}", &recs);
Ok(HttpResponse::Ok().json(recs))
}
pub fn services(cfg: &mut ServiceConfig) {
cfg.service(get_question);
cfg.service(verify_answer);
cfg.service(score);
cfg.service(leaderboard);
cfg.service(signout);
cfg.service(signin);
cfg.service(signup);
}
fn is_authenticated(id: &Identity) -> ServiceResult<bool> {
debug!("{:?}", id.identity());
// access request identity
if let Some(_) = id.identity() {
Ok(true)
} else {
Err(ServiceError::AuthorizationRequired)
}
}

View File

@@ -31,6 +31,11 @@ pub struct Server {
pub ip: String, pub ip: String,
} }
#[derive(Debug, Clone, Deserialize)]
pub struct Captcha {
pub salt: String,
}
impl Server { impl Server {
pub fn get_ip(&self) -> String { pub fn get_ip(&self) -> String {
format!("{}:{}", self.ip, self.port) format!("{}:{}", self.ip, self.port)
@@ -79,6 +84,7 @@ pub struct Settings {
pub debug: bool, pub debug: bool,
pub database: Database, pub database: Database,
pub server: Server, pub server: Server,
pub pow: Captcha,
} }
#[cfg(not(tarpaulin_include))] #[cfg(not(tarpaulin_include))]

36
src/tests-migrate.rs Normal file
View File

@@ -0,0 +1,36 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use lazy_static::lazy_static;
mod data;
mod settings;
pub use data::Data;
pub use settings::Settings;
lazy_static! {
pub static ref SETTINGS: Settings = Settings::new().unwrap();
}
#[actix_web::main]
async fn main() {
let data = Data::new().await;
pretty_env_logger::init();
sqlx::migrate!("./migrations/").run(&data.db).await.unwrap();
}