From 58ec8ef92a5d944de5f3b73fff72ac3c1625708b Mon Sep 17 00:00:00 2001 From: Chris Carlon Date: Sun, 8 Dec 2024 15:49:47 +0000 Subject: [PATCH] feat: added password reset basic functionality - cannot be pushed to prod yet due to security issues [2024-12-08] --- gridwalk-backend/src/core/user.rs | 21 ++++ gridwalk-backend/src/data/config.rs | 1 + gridwalk-backend/src/data/dynamodb/config.rs | 19 +++ gridwalk-backend/src/routes/user.rs | 51 ++++++++ gridwalk-backend/src/server.rs | 3 +- .../src/app/login/components/loginForm.tsx | 109 +++++++++++++++++- makefile | 20 ++-- 7 files changed, 213 insertions(+), 11 deletions(-) diff --git a/gridwalk-backend/src/core/user.rs b/gridwalk-backend/src/core/user.rs index d51c94d..0269388 100644 --- a/gridwalk-backend/src/core/user.rs +++ b/gridwalk-backend/src/core/user.rs @@ -83,4 +83,25 @@ impl User { hash: password_hash, } } + + pub async fn update_password( + &mut self, + database: &Arc, + new_password: &str, + ) -> Result<()> { + let new_hash = hash_password(new_password)?; + self.hash = new_hash; + database.update_user_password(self).await + } + + pub async fn reset_password( + database: &Arc, + email: &str, + new_password: &str, + ) -> Result { + let mut user = Self::from_email(database, email).await?; + user.update_password(database, new_password).await?; + + Ok(user) + } } diff --git a/gridwalk-backend/src/data/config.rs b/gridwalk-backend/src/data/config.rs index 4ed08ef..149b503 100644 --- a/gridwalk-backend/src/data/config.rs +++ b/gridwalk-backend/src/data/config.rs @@ -37,6 +37,7 @@ pub trait UserStore: Send + Sync + 'static { async fn get_workspaces(&self, user: &User) -> Result>; async fn get_projects(&self, workspace_id: &str) -> Result>; async fn delete_project(&self, project: &Project) -> Result<()>; + async fn update_user_password(&self, user: &User) -> Result<()>; } #[async_trait] diff --git a/gridwalk-backend/src/data/dynamodb/config.rs b/gridwalk-backend/src/data/dynamodb/config.rs index 4d1fac5..40b63ea 100644 --- a/gridwalk-backend/src/data/dynamodb/config.rs +++ b/gridwalk-backend/src/data/dynamodb/config.rs @@ -199,6 +199,25 @@ impl UserStore for Dynamodb { Ok(()) } + async fn update_user_password(&self, user: &User) -> Result<()> { + // Create update expression for the USER item + let key = format!("USER#{}", user.id); + + self.client + .update_item() + .table_name(&self.table_name) + .key("PK", AV::S(key.clone())) + .key("SK", AV::S(key)) + .update_expression("SET #hash = :hash") + .expression_attribute_names("#hash", "hash") + .expression_attribute_values(":hash", AV::S(user.hash.clone())) + .send() + .await + .map_err(|e| anyhow!("Failed to update password: {}", e))?; + + Ok(()) + } + async fn get_user_by_email(&self, email: &str) -> Result { let email_key = format!("EMAIL#{email}"); match self diff --git a/gridwalk-backend/src/routes/user.rs b/gridwalk-backend/src/routes/user.rs index 8c0ff90..0a6eaa1 100644 --- a/gridwalk-backend/src/routes/user.rs +++ b/gridwalk-backend/src/routes/user.rs @@ -122,3 +122,54 @@ pub async fn profile( None => Err((StatusCode::FORBIDDEN, "unauthorized".to_string())), } } + +#[derive(Debug, Deserialize)] +pub struct ResetPasswordRequest { + email: String, + new_password: String, +} + +pub async fn reset_password( + State(state): State>, + Extension(auth_user): Extension, + Json(req): Json, +) -> impl IntoResponse { + // Ensure user is authenticated + let user = match auth_user.user { + Some(user) => user, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "Authentication required" + })), + ) + } + }; + + // Only allow users to reset their own password + if user.email != req.email { + return ( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "Can only reset your own password" + })), + ); + } + + // Use the static method to handle the update + match User::reset_password(&state.app_data, &req.email, &req.new_password).await { + Ok(_) => ( + StatusCode::OK, + Json(json!({ + "message": "Password updated successfully" + })), + ), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": "Failed to update password" + })), + ), + } +} diff --git a/gridwalk-backend/src/server.rs b/gridwalk-backend/src/server.rs index d5515af..3ab3706 100644 --- a/gridwalk-backend/src/server.rs +++ b/gridwalk-backend/src/server.rs @@ -4,7 +4,7 @@ use crate::routes::{ add_workspace_member, create_connection, create_project, create_workspace, delete_connection, delete_project, generate_os_token, get_projects, get_workspace_members, get_workspaces, health_check, list_connections, list_sources, login, logout, profile, register, - remove_workspace_member, tiles, upload_layer, + remove_workspace_member, reset_password, tiles, upload_layer, }; use axum::{ extract::DefaultBodyLimit, @@ -34,6 +34,7 @@ pub fn create_app(app_state: AppState) -> Router { .route("/workspaces", get(get_workspaces)) .route("/logout", post(logout)) .route("/profile", get(profile)) + .route("/password_reset", post(reset_password)) .route("/workspace", post(create_workspace)) .route("/workspace/members", post(add_workspace_member)) .route( diff --git a/gridwalk-ui/src/app/login/components/loginForm.tsx b/gridwalk-ui/src/app/login/components/loginForm.tsx index 5be9d72..8230e9e 100644 --- a/gridwalk-ui/src/app/login/components/loginForm.tsx +++ b/gridwalk-ui/src/app/login/components/loginForm.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState } from "react"; -import { Lock, Mail, User } from "lucide-react"; +import { Lock, Mail, User, ArrowLeft } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Card, @@ -22,9 +22,16 @@ interface AuthFormData { last_name?: string; } +const resetPasswordAction = async (email: string): Promise => { + // TODO: Implement password reset logic + console.log("Password reset requested for:", email); + throw new Error("Password reset not yet implemented"); +}; + export default function AuthForm(): JSX.Element { const router = useRouter(); const [isLogin, setIsLogin] = useState(true); + const [showForgotPassword, setShowForgotPassword] = useState(false); const [formData, setFormData] = useState({ email: "", password: "", @@ -33,6 +40,8 @@ export default function AuthForm(): JSX.Element { }); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [resetEmail, setResetEmail] = useState(""); + const [isResetSuccess, setIsResetSuccess] = useState(false); const handleInputChange = (e: React.ChangeEvent): void => { const { name, value } = e.target; @@ -75,6 +84,26 @@ export default function AuthForm(): JSX.Element { } }; + const handleResetPassword = async ( + e: React.FormEvent, + ): Promise => { + e.preventDefault(); + if (!resetEmail) { + setError("Please enter your email address"); + return; + } + setError(""); + setLoading(true); + try { + await resetPasswordAction(resetEmail); + setIsResetSuccess(true); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setLoading(false); + } + }; + const toggleAuthMode = () => { setIsLogin(!isLogin); setError(""); @@ -86,6 +115,77 @@ export default function AuthForm(): JSX.Element { }); }; + const handleBackToLogin = () => { + setShowForgotPassword(false); + setIsResetSuccess(false); + setResetEmail(""); + setError(""); + }; + + if (showForgotPassword) { + return ( + + + Reset Password + + Enter your email address and we will send you a reset link + + + + {isResetSuccess ? ( +
+ + + If an account exists with this email, you will receive a + password reset link shortly. Please check your email - and + junk folder. + + + +
+ ) : ( +
+ {error && ( + + {error} + + )} +
+
+ + setResetEmail(e.target.value)} + /> +
+
+ + +
+ )} +
+
+ ); + } + return ( @@ -174,7 +274,12 @@ export default function AuthForm(): JSX.Element { /> Remember me - diff --git a/makefile b/makefile index 11cf8c9..b619949 100644 --- a/makefile +++ b/makefile @@ -50,43 +50,47 @@ git-push: git push -# New commands for dev environment setup .PHONY: dev-env dev-env-kill load-env docker-services backend frontend -# Load environment variables +# Export all variables +export + +# Load environment variables and export them load-env: @if [ -f .env ]; then \ set -a; \ . .env; \ set +a; \ + $(eval include .env) \ + $(eval export $(shell sed 's/=.*//' .env)) \ else \ echo "Error: .env file not found"; \ exit 1; \ fi # Start Docker services -docker-services: +docker-services: load-env @echo "Starting Docker services..." docker-compose up -d # Start backend services -backend: +backend: load-env @echo "Starting backend services..." cd gridwalk-backend && \ - echo "$$AWS_PASS" | aws-vault exec gridw -- cargo run + aws-vault exec gridw -- cargo run # Start frontend services -frontend: +frontend: load-env @echo "Starting frontend services..." cd gridwalk-ui && \ npm run dev # Main command to set up development environment -dev-env: +dev-env: load-env @echo "Setting up development environment..." tmux new-session -d -s gridwalk tmux rename-window -t gridwalk:0 'GRIDWALK DEV ENVIRONMENT' - tmux send-keys -t gridwalk:0 'docker-compose up -d' C-m + tmux send-keys -t gridwalk:0 'make docker-services' C-m tmux send-keys -t gridwalk:0 'cd gridwalk-ui && npm run dev' C-m tmux split-window -h -t gridwalk:0 tmux send-keys -t gridwalk:0.1 'cd gridwalk-backend && echo $$AWS_PASS | aws-vault exec gridw -- cargo run' C-m