From 3b15f520c8b38ccf3a3ed909960fe8058c32c211 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Tue, 17 Oct 2023 20:49:27 -0400 Subject: [PATCH] Working on auth --- service/.env.TEMPLATE | 3 + service/Cargo.toml | 3 + service/Makefile | 1 + service/docker-compose.yml | 10 ++ .../migrations/000011_create_users/down.sql | 1 + service/migrations/000011_create_users/up.sql | 7 + service/src/db/schema.rs | 10 ++ service/src/db/users/mod.rs | 2 + service/src/db/users/model.rs | 149 +++++++++++++++--- service/src/db/users/routes.rs | 75 +++++++++ service/src/lib.rs | 17 +- service/src/main.rs | 32 +++- ui/package-lock.json | 16 ++ ui/package.json | 2 + ui/src/api/index.ts | 17 +- ui/src/api/users.ts | 9 ++ ui/src/components/Topbar/index.tsx | 142 +++++++++++++++-- ui/src/components/Topbar/topbar.css | 7 +- 18 files changed, 454 insertions(+), 49 deletions(-) create mode 100644 service/migrations/000011_create_users/down.sql create mode 100644 service/migrations/000011_create_users/up.sql create mode 100644 service/src/db/users/routes.rs create mode 100644 ui/src/api/users.ts diff --git a/service/.env.TEMPLATE b/service/.env.TEMPLATE index e8d5b3e..615e6f4 100644 --- a/service/.env.TEMPLATE +++ b/service/.env.TEMPLATE @@ -6,6 +6,9 @@ DATABASE_NAME=siren DATABASE_HOST=localhost DATABASE_PORT=5432 +REDIS_HOST=localhost +REDIS_PORT=6379 + SERVICE_HOST=localhost SERVICE_PORT=5000 DATA_DIR_PATH= diff --git a/service/Cargo.toml b/service/Cargo.toml index 658c72e..0c0e6d3 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -16,6 +16,8 @@ actix-web = "4.4.0" actix-rt = "2.9.0" actix-cors = "0.6.4" actix-web-httpauth = "0.8.1" +actix-identity = "0.6.0" +actix-session = { version = "0.8.0", features = ["redis-actor-session", "cookie-session"] } chrono = { version = "0.4.31", features = ["serde"] } dotenv = "0.15.0" serde_json = "1.0.107" @@ -25,6 +27,7 @@ diesel_migrations = { version = "2.1.0", features = ["postgres"] } r2d2 = "0.8.10" lazy_static = "1.4.0" uuid = { version = "1.4.1", features = ["serde", "v4"] } +argon2 = "0.5.2" [dependencies.tokio] version = "1.32.0" diff --git a/service/Makefile b/service/Makefile index 2c48928..c597f7a 100644 --- a/service/Makefile +++ b/service/Makefile @@ -15,6 +15,7 @@ build: ## Build the docker image utils: ## Start the utils docker compose up -d db + docker compose up -d redis up: ## Start the app docker compose up -d diff --git a/service/docker-compose.yml b/service/docker-compose.yml index b3b5d2c..8495a82 100644 --- a/service/docker-compose.yml +++ b/service/docker-compose.yml @@ -15,6 +15,8 @@ services: environment: DATABASE_HOST: db DATABASE_PORT: 5432 + REDIS_HOST: redis + REDIS_PORT: 6379 SERVICE_HOST: service SERVICE_PORT: 5000 DATA_DIR_PATH: /data @@ -45,6 +47,14 @@ services: networks: - backend restart: unless-stopped + redis: + image: redis:latest + container_name: siren-redis + ports: + - ${REDIS_PORT:-6379}:6379 + networks: + - backend + restart: unless-stopped volumes: db: diff --git a/service/migrations/000011_create_users/down.sql b/service/migrations/000011_create_users/down.sql new file mode 100644 index 0000000..441087a --- /dev/null +++ b/service/migrations/000011_create_users/down.sql @@ -0,0 +1 @@ +DROP TABLE users; \ No newline at end of file diff --git a/service/migrations/000011_create_users/up.sql b/service/migrations/000011_create_users/up.sql new file mode 100644 index 0000000..6fe1727 --- /dev/null +++ b/service/migrations/000011_create_users/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS users ( + email TEXT PRIMARY KEY NOT NULL, + hash TEXT NOT NULL, + role TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL +); \ No newline at end of file diff --git a/service/src/db/schema.rs b/service/src/db/schema.rs index 5d5e4bf..6690560 100644 --- a/service/src/db/schema.rs +++ b/service/src/db/schema.rs @@ -37,4 +37,14 @@ diesel::table! { bot_id -> BigInt, volume -> Integer, } +} + +diesel::table! { + users (email) { + email -> Text, + hash -> Text, + role -> Text, + first_name -> Text, + last_name -> Text, + } } \ No newline at end of file diff --git a/service/src/db/users/mod.rs b/service/src/db/users/mod.rs index 4a7ebf6..6fbb137 100644 --- a/service/src/db/users/mod.rs +++ b/service/src/db/users/mod.rs @@ -1,3 +1,5 @@ mod model; +mod routes; pub use model::*; +pub use routes::init_routes; diff --git a/service/src/db/users/model.rs b/service/src/db/users/model.rs index 0bbdd27..4159f48 100644 --- a/service/src/db/users/model.rs +++ b/service/src/db/users/model.rs @@ -1,37 +1,140 @@ -use actix_web::{dev::ServiceRequest, Error}; -use actix_web_httpauth::extractors::bearer::BearerAuth; +use std::future::{ready, Ready}; +use actix_identity::Identity; +use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload}; +use argon2::{password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString, Error as HashError}, Argon2, PasswordHash}; +use diesel::prelude::*; +use serde::{Serialize, Deserialize}; use siren::ServiceError; -pub struct User { - pub id: i32, +use crate::db::schema::users; + +#[derive(Debug, Serialize, Deserialize)] +pub struct RegisterUser { + pub email: String, + pub password: String, pub first_name: String, pub last_name: String, +} + +impl RegisterUser { + pub fn convert_to_insert(self) -> Result { + let hash = hash(self.password.as_bytes())?; + Ok(InsertUser { + email: self.email, + hash, + role: "user".to_string(), + first_name: self.first_name, + last_name: self.last_name, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginAuth { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LoggedUser { pub email: String } +impl FromRequest for LoggedUser { + type Error = ActixError; + type Future = Ready>; + + fn from_request(req: &HttpRequest, pl: &mut Payload) -> Self::Future { + if let Ok(identity) = Identity::from_request(req, pl).into_inner() { + if let Ok(user_json) = identity.id() { + if let Ok(user) = serde_json::from_str(&user_json) { + return ready(Ok(user)); + } + } + } + std::future::ready(Err( + ActixError::from(ServiceError { + status: 401, + message: "Unauthorized".to_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, +} + +impl QueryUser { + pub fn get_by_email(email: &str) -> Result { + let mut conn = crate::db::connection()?; + let user = users::table + .filter(users::email.eq(email)) + .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, +} + +impl InsertUser { + pub fn insert(user: Self) -> Result { + let mut conn = crate::db::connection()?; + let user = diesel::insert_into(users::table) + .values(user) + .get_result(&mut conn)?; + Ok(user) + } +} + // https://github.com/Sirneij/rust-auth/blob/main/backend/src/routes/users/login.rs // https://dev.to/sirneij/authentication-system-using-rust-actix-web-and-sveltekit-user-registration-580h // https://github.com/actix/actix-extras/blob/master/actix-session/examples/basic.rs // maybe https://github.com/actix/actix-extras/blob/master/actix-identity/examples/identity.rs +// https://www.lpalmieri.com/posts/session-based-authentication-in-rust/#3-3-1-postgres -pub async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result { - let token = credentials.token(); - println!("{:?}", req); - match validate_token(token) { - Ok(res) => { - if res { - Ok(req) - } else { - Err((Error::from(actix_web::error::ErrorUnauthorized("Invalid token")), req)) - } - }, - Err(err) => { - Err((Error::from(actix_web::error::ErrorUnauthorized(err)), req)) - } - } +// pub async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result { +// let token = credentials.token(); +// println!("{:?}", req); +// match validate_token(token) { +// Ok(res) => { +// if res { +// Ok(req) +// } else { +// Err((ActixError::from(actix_web::error::ErrorUnauthorized("Invalid token")), req)) +// } +// }, +// Err(err) => { +// Err((ActixError::from(actix_web::error::ErrorUnauthorized(err)), req)) +// } +// } +// } + +// fn validate_token(token: &str) -> Result { +// println!("Validating token: {}", token); +// Ok(true) +// } + +pub fn hash(password: &[u8]) -> Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default().hash_password(password, &salt)?.to_string()) } -fn validate_token(token: &str) -> Result { - println!("Validating token: {}", token); - Ok(true) -} \ No newline at end of file +pub fn verify(hash: &str, password: &[u8]) -> Result<(), HashError> { + let parsed_hash = PasswordHash::new(hash)?; + Ok(Argon2::default().verify_password(password, &parsed_hash)?) +} diff --git a/service/src/db/users/routes.rs b/service/src/db/users/routes.rs new file mode 100644 index 0000000..6937d3e --- /dev/null +++ b/service/src/db/users/routes.rs @@ -0,0 +1,75 @@ +use actix_identity::Identity; +use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError, HttpMessage}; +use siren::ServiceError; + +use crate::db::users::{LoginAuth, RegisterUser, InsertUser, QueryUser, verify, LoggedUser}; + +#[post("/register")] +async fn register(user: web::Json) -> HttpResponse { + let register_user = user.0; + let insert_user: InsertUser = match register_user.convert_to_insert() { + Ok(user) => user, + Err(err) => return ResponseError::error_response(&err) + }; + match InsertUser::insert(insert_user) { + Ok(_) => { + HttpResponse::Created().finish() + }, + Err(err) => { + // Obfuscate the service error message to prevent leaking database details + if err.status == 409 { + return HttpResponse::Conflict().finish(); + } else { + return ResponseError::error_response(&err); + } + } + } +} + +#[post("/login")] +async fn login(req: HttpRequest, auth: web::Json) -> HttpResponse { + let email = auth.email.clone(); + + match QueryUser::get_by_email(&email) { + Ok(query_user) => { + let hash = query_user.hash; + let password = auth.password.as_bytes(); + match verify(&hash, password) { + Ok(_) => { + let user = LoggedUser { + email: email.clone() + }; + let user_string = serde_json::to_string(&user).unwrap(); + match Identity::login(&req.extensions(), user_string) { + Ok(_) => HttpResponse::Ok().finish(), + Err(err) => return ResponseError::error_response(&err) + } + }, + Err(err) => ResponseError::error_response(&ServiceError { + status: 401, + message: err.to_string() + }) + } + }, + Err(err) => ResponseError::error_response(&err) + } +} + +#[post("/logout")] +async fn logout(id: Identity) -> HttpResponse { + id.logout(); + HttpResponse::Ok().finish() +} + +#[get("/me")] +async fn me(user: LoggedUser) -> HttpResponse { + HttpResponse::Ok().json(user) +} + +pub fn init_routes(config: &mut web::ServiceConfig) { + config.service(web::scope("users") + .service(register) + .service(login) + .service(logout) + .service(me)); +} \ No newline at end of file diff --git a/service/src/lib.rs b/service/src/lib.rs index b8e64bd..9dadc22 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -57,7 +57,14 @@ impl fmt::Display for ServiceError { impl From for ServiceError { fn from(error: DieselError) -> ServiceError { match error { - DieselError::DatabaseError(_, err) => ServiceError::new(409, err.message().to_string()), + 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()) }, @@ -87,6 +94,12 @@ impl From for ServiceError { } } +impl From for ServiceError { + fn from(error: argon2::password_hash::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown argon2 error: {}", error)) + } +} + impl ResponseError for ServiceError { fn error_response(&self) -> HttpResponse { let status_code = match StatusCode::from_u16(self.status) { @@ -101,4 +114,4 @@ impl ResponseError for ServiceError { HttpResponse::build(status_code).json(serde_json::json!({ "status": status_code.as_u16(), "message": error_message })) } -} \ No newline at end of file +} diff --git a/service/src/main.rs b/service/src/main.rs index d71d02f..ee08565 100644 --- a/service/src/main.rs +++ b/service/src/main.rs @@ -5,8 +5,9 @@ extern crate diesel_migrations; use std::env; use std::collections::HashSet; use std::sync::Arc; -use actix_web_httpauth::middleware::HttpAuthentication; -use db::users::validator; +use actix_identity::IdentityMiddleware; +use actix_session::{SessionMiddleware, storage::{RedisActorSessionStore, CookieSessionStore}, config::{PersistentSession, BrowserSession, CookieContentSecurity}}; +// use db::users::validator; use log::{error, warn, info}; use serenity::client::Cache; use serenity::framework::StandardFramework; @@ -15,7 +16,7 @@ use serenity::prelude::*; use songbird::{SerenityInit, Songbird}; use actix_cors::Cors; -use actix_web::{HttpServer, App, web}; +use actix_web::{HttpServer, App, web, cookie::{time::Duration, SameSite}}; use crate::bot::{commands::oai::GPTModel, handler::Handler}; use dotenv::dotenv; @@ -113,18 +114,39 @@ async fn main() -> std::io::Result<()> { let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string()); let server = match HttpServer::new(move || { - let auth = HttpAuthentication::bearer(validator); + // let auth = HttpAuthentication::bearer(validator); + let private_key = actix_web::cookie::Key::generate(); + // let redis_host = env::var("REDIS_HOST").unwrap_or("localhost".to_string()); + // let redis_port = env::var("REDIS_PORT").unwrap_or("6379".to_string()); + let session = SessionMiddleware::builder( + // RedisActorSessionStore::new(format!("{}:{}", redis_host, redis_port)), + CookieSessionStore::default(), + private_key + ) + .session_lifecycle(BrowserSession::default()) + .cookie_name("auth".to_owned()) + .cookie_secure(false) + .cookie_http_only(false) + // .cookie_content_security(CookieContentSecurity::Private) + .cookie_domain(Some("localhost".to_owned())) + .cookie_path("/".to_owned()) + .build(); let cors = Cors::default() .allow_any_origin() .allow_any_method() .allow_any_header() + .supports_credentials() .max_age(3600); + // let cors = Cors::permissive(); App::new() - .wrap(auth) + // .wrap(auth) + .wrap(IdentityMiddleware::default()) + .wrap(session) .wrap(cors) .app_data(web::Data::new(Arc::clone(&app_data))) .configure(crate::db::messages::init_routes) .configure(crate::db::spells::init_routes) + .configure(crate::db::users::init_routes) .configure(crate::bot::api::init_routes) }) .bind(format!("{}:{}", host, port)) { diff --git a/ui/package-lock.json b/ui/package-lock.json index 0f90ac5..c36e38f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -14,6 +14,7 @@ "@mantine/modals": "^7.1.2", "@mantine/notifications": "^7.1.2", "axios": "^1.5.1", + "js-cookie": "^3.0.5", "next": "^13.5.4", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -23,6 +24,7 @@ "recoil": "^0.7.7" }, "devDependencies": { + "@types/js-cookie": "^3.0.4", "@types/node": "20.8.2", "@types/react": "18.2.24", "@types/react-dom": "18.2.8", @@ -586,6 +588,12 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz", "integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==" }, + "node_modules/@types/js-cookie": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.4.tgz", + "integrity": "sha512-vMMnFF+H5KYqdd/myCzq6wLDlPpteJK+jGFgBus3Da7lw+YsDmx2C8feGTzY2M3Fo823yON+HC2CL240j4OV+w==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", @@ -3294,6 +3302,14 @@ "reflect.getprototypeof": "^1.0.3" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 9df65eb..b32fc76 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,6 +15,7 @@ "@mantine/modals": "^7.1.2", "@mantine/notifications": "^7.1.2", "axios": "^1.5.1", + "js-cookie": "^3.0.5", "next": "^13.5.4", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -24,6 +25,7 @@ "recoil": "^0.7.7" }, "devDependencies": { + "@types/js-cookie": "^3.0.4", "@types/node": "20.8.2", "@types/react": "18.2.24", "@types/react-dom": "18.2.8", diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 027cf23..d587e68 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -1,18 +1,25 @@ -import axios, { AxiosResponse } from 'axios'; +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; const serviceHost = process.env.SERVICE_HOST || 'http://localhost'; const servicePort = process.env.SERVICE_PORT || 5000; -export async function getRequest(endpoint: string, params: any): Promise | undefined> { +export async function getRequest( + url: string, + params: AxiosRequestConfig +): Promise | undefined> { const response = await axios - .get(`${serviceHost}:${servicePort}/${endpoint}`, { params }) + .get(`${serviceHost}:${servicePort}/${url}`, { params }) .catch((error) => console.error(error)); return response || undefined; } -export async function postRequest(endpoint: string, body: any): Promise | undefined> { +export async function postRequest( + url: string, + data?: any, + config?: AxiosRequestConfig +): Promise | undefined> { const response = await axios - .post(`${serviceHost}:${servicePort}/${endpoint}`, body || {}) + .post(`${serviceHost}:${servicePort}/${url}`, data, config) .catch((error) => console.error(error)); return response || undefined; } diff --git a/ui/src/api/users.ts b/ui/src/api/users.ts new file mode 100644 index 0000000..b5cd5c0 --- /dev/null +++ b/ui/src/api/users.ts @@ -0,0 +1,9 @@ +import { postRequest } from '.'; + +export async function login(email: string, password: string) { + return await postRequest('users/login', { email, password }, { withCredentials: true }); +} + +export async function logout() { + return await postRequest('users/logout', {}, { withCredentials: true }); +} diff --git a/ui/src/components/Topbar/index.tsx b/ui/src/components/Topbar/index.tsx index 5b00233..c076f65 100644 --- a/ui/src/components/Topbar/index.tsx +++ b/ui/src/components/Topbar/index.tsx @@ -3,6 +3,25 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import './topbar.css'; +import { + Anchor, + Avatar, + Button, + Checkbox, + Container, + Group, + Menu, + Modal, + Paper, + PasswordInput, + Text, + TextInput, + Title +} from '@mantine/core'; +import Cookies from 'js-cookie'; +import { useEffect, useState } from 'react'; +import { useForm } from '@mantine/form'; +import { login, logout } from '@/api/users'; const headerItems = [ { @@ -41,21 +60,118 @@ const headerItems = [ export default function Topbar() { const pathName = usePathname(); + const [showLogin, setShowLogin] = useState(false); + const [authenticated, setAuthenticated] = useState(false); + // Check if the auth cookie is set + // If it is, show the user avatar + // If not, show the login button + useEffect(() => { + console.log('cookies', Cookies.get()); + if (Cookies.get('auth')) { + setAuthenticated(true); + } + }, []); return ( - +
+ + + + + + {!authenticated && setShowLogin(true)}>Login} + {authenticated && ( + { + const response = await logout(); + if (response?.status == 200) { + Cookies.remove('auth'); + setAuthenticated(false); + } + }} + > + Logout + + )} + + +
+ + + + ); +} + +function LoginModal({ + showLogin, + setShowLogin, + setAuthenticated +}: { + showLogin: boolean; + setShowLogin: (show: boolean) => void; + setAuthenticated: (authenticated: boolean) => void; +}) { + const form = useForm({ + initialValues: { + email: '', + password: '' + } + }); + return ( + setShowLogin(false)} withCloseButton={false}> + + Welcome back! + + Do not have an account yet?{' '} + + Create account + + + + +
{ + const response = await login(values.email, values.password); + if (response?.status == 200) { + setShowLogin(false); + setAuthenticated(true); + } + })} + > + + + + + + Forgot password? + + + + +
+
+
); } diff --git a/ui/src/components/Topbar/topbar.css b/ui/src/components/Topbar/topbar.css index 6cc7cd2..176f5c8 100644 --- a/ui/src/components/Topbar/topbar.css +++ b/ui/src/components/Topbar/topbar.css @@ -9,6 +9,11 @@ display: flex; } +.navbar .user { + padding-left: 1em; + padding-right: 1em; +} + .navbar .title { padding-left: 2em; padding-right: 2em; @@ -44,4 +49,4 @@ .header-items .active { border-bottom: 2px solid #5f5f5f; -} \ No newline at end of file +}