diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index d7b5d0152..281f51297 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -52,6 +52,8 @@ const CreateFrameworkPage = lazy(() => import("./pages/CreateFrameworkPage")); const CreateControlPage = lazy(() => import("./pages/CreateControlPage")); const UpdateFrameworkPage = lazy(() => import("./pages/UpdateFrameworkPage")); const UpdateControlPage = lazy(() => import("./pages/UpdateControlPage")); +const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage")); +const ResetPasswordPage = lazy(() => import("./pages/ResetPasswordPage")); // Policy pages const PolicyListPage = lazy(() => import("./pages/PolicyListPage")); const PolicyOverviewPage = lazy(() => import("./pages/PolicyOverviewPage")); @@ -296,6 +298,26 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> { + e.preventDefault(); + setIsLoading(true); + + try { + const response = await fetch( + buildEndpoint("/api/console/v1/auth/forget-password"), + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email }), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to request password reset"); + } + + setIsSubmitted(true); + toast({ + title: "Check your email", + description: "If an account exists, you'll receive reset instructions", + }); + } catch (error) { + toast({ + title: "Error", + description: + error instanceof Error ? error.message : "An error occurred", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + + Forgot Password - Probo + + +
+ + + + Forgot Password + + + Enter your email address and we{"'"}ll send you a link to reset + your password + + + + + {isSubmitted ? ( +
+
+ Check your email for instructions to reset your password. If + you don{"'"}t see it, check your spam folder. +
+ +
+ ) : ( +
+
+ + setEmail(e.target.value)} + disabled={isLoading} + required + /> +
+ + +
+ )} +
+ + + + Back to Login + + +
+
+ + ); +} diff --git a/apps/console/src/pages/ResetPasswordPage.tsx b/apps/console/src/pages/ResetPasswordPage.tsx new file mode 100644 index 000000000..605b47bf9 --- /dev/null +++ b/apps/console/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,205 @@ +import { useState, useEffect } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { Helmet } from "react-helmet-async"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { buildEndpoint } from "@/utils"; +import { useToast } from "@/hooks/use-toast"; +import { Link } from "react-router"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function ResetPasswordPage() { + const [isLoading, setIsLoading] = useState(false); + const [isReset, setIsReset] = useState(false); + const [error, setError] = useState(null); + const [token, setToken] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const location = useLocation(); + const navigate = useNavigate(); + const { toast } = useToast(); + + useEffect(() => { + // Extract token from URL and prefill the form + const searchParams = new URLSearchParams(location.search); + const urlToken = searchParams.get("token"); + + if (urlToken) { + setToken(urlToken); + } + }, [location.search]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + + if (!token.trim()) { + setError("Please enter a reset token"); + setIsLoading(false); + return; + } + + if (!password) { + setError("Please enter a password"); + setIsLoading(false); + return; + } + + if (password !== confirmPassword) { + setError("Passwords do not match"); + setIsLoading(false); + return; + } + + if (password.length < 8) { + setError("Password must be at least 8 characters long"); + setIsLoading(false); + return; + } + + try { + const response = await fetch( + buildEndpoint("/api/console/v1/auth/reset-password"), + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: token.trim(), + password: password, + }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to reset password"); + } + + setIsReset(true); + toast({ + title: "Success", + description: "Your password has been reset successfully", + }); + } catch (error) { + setError( + error instanceof Error + ? error.message + : "Failed to reset password. Please try again." + ); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + + Reset Password - Probo + + +
+ + + + Reset Password + + + Enter your new password below + + + + + {isReset ? ( +
+

+ Your password has been reset successfully! +

+ +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setToken(e.target.value)} + placeholder="Enter your reset token" + disabled={isLoading} + required + /> +

+ The token has been automatically filled from the URL if + available +

+
+ +
+ + setPassword(e.target.value)} + placeholder="Enter new password" + disabled={isLoading} + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your new password" + disabled={isLoading} + required + /> +
+ + +
+ )} +
+ + + {!isReset && ( + + Back to Login + + )} + +
+
+ + ); +} diff --git a/pkg/coredata/user.go b/pkg/coredata/user.go index cb7323b98..92b6b1831 100644 --- a/pkg/coredata/user.go +++ b/pkg/coredata/user.go @@ -273,3 +273,36 @@ WHERE return nil } + +func (u *User) UpdatePassword( + ctx context.Context, + conn pg.Conn, + hashedPassword []byte, +) error { + q := ` +UPDATE + users +SET + hashed_password = @hashed_password, + updated_at = @updated_at +WHERE + id = @user_id +` + + now := time.Now() + args := pgx.StrictNamedArgs{ + "user_id": u.ID, + "hashed_password": hashedPassword, + "updated_at": now, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update user password: %w", err) + } + + u.HashedPassword = hashedPassword + u.UpdatedAt = now + + return nil +} diff --git a/pkg/server/api/console/v1/forget_password_handler.go b/pkg/server/api/console/v1/forget_password_handler.go new file mode 100644 index 000000000..40642fd59 --- /dev/null +++ b/pkg/server/api/console/v1/forget_password_handler.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_v1 + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/getprobo/probo/pkg/usrmgr" + "go.gearno.de/kit/httpserver" +) + +type ( + ForgetPasswordRequest struct { + Email string `json:"email"` + } + + ForgetPasswordResponse struct { + Success bool `json:"success"` + } +) + +func ForgetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req ForgetPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err)) + return + } + + err := usrmgrSvc.ForgetPassword(r.Context(), req.Email) + if err != nil { + // For security reasons, we don't expose whether an email exists or not + httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot process request: %w", err)) + return + } + + httpserver.RenderJSON(w, http.StatusOK, ForgetPasswordResponse{ + Success: true, + }) + } +} diff --git a/pkg/server/api/console/v1/reset_password_handler.go b/pkg/server/api/console/v1/reset_password_handler.go new file mode 100644 index 000000000..634eb4aca --- /dev/null +++ b/pkg/server/api/console/v1/reset_password_handler.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_v1 + +import ( + "encoding/json" + "fmt" + "net/http" + + "errors" + + "github.com/getprobo/probo/pkg/usrmgr" + "go.gearno.de/kit/httpserver" +) + +type ( + ResetPasswordRequest struct { + Token string `json:"token"` + Password string `json:"password"` + } + + ResetPasswordResponse struct { + Success bool `json:"success"` + } +) + +func ResetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req ResetPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err)) + return + } + + err := usrmgrSvc.ResetPassword(r.Context(), req.Token, req.Password) + if err != nil { + var invalidPasswordErr *usrmgr.ErrInvalidPassword + var invalidTokenErr *usrmgr.ErrInvalidTokenType + + if errors.As(err, &invalidPasswordErr) { + httpserver.RenderError(w, http.StatusBadRequest, err) + return + } + + if errors.As(err, &invalidTokenErr) { + httpserver.RenderError(w, http.StatusBadRequest, err) + return + } + + httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot reset password: %w", err)) + return + } + + httpserver.RenderJSON(w, http.StatusOK, ResetPasswordResponse{ + Success: true, + }) + } +} diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 5fc5c676a..a17404e88 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -78,6 +78,8 @@ func NewMux(proboSvc *probo.Service, usrmgrSvc *usrmgr.Service, authCfg AuthConf r.Post("/auth/login", SignInHandler(usrmgrSvc, authCfg)) r.Delete("/auth/logout", SignOutHandler(usrmgrSvc, authCfg)) r.Post("/auth/invitation", InvitationConfirmationHandler(usrmgrSvc, authCfg)) + r.Post("/auth/forget-password", ForgetPasswordHandler(usrmgrSvc, authCfg)) + r.Post("/auth/reset-password", ResetPasswordHandler(usrmgrSvc, authCfg)) r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query")) r.Post("/query", graphqlHandler(proboSvc, usrmgrSvc, authCfg)) diff --git a/pkg/usrmgr/usrmgr.go b/pkg/usrmgr/usrmgr.go index 634b1aaa5..97ae0724b 100644 --- a/pkg/usrmgr/usrmgr.go +++ b/pkg/usrmgr/usrmgr.go @@ -83,12 +83,17 @@ type ( Email string `json:"email"` FullName string `json:"full_name"` } + + PasswordResetData struct { + Email string `json:"email"` + } ) // Token types const ( TokenTypeEmailConfirmation = "email_confirmation" TokenTypeOrganizationInvitation = "organization_invitation" + TokenTypePasswordReset = "password_reset" ) var ( @@ -107,6 +112,16 @@ var ( [1] %s ` + + passwordResetEmailSubject = "Reset your password" + passwordResetEmailTemplate = ` + You have requested a password reset for your Probo account. + Please click the link below to reset your password[1] + + If you did not request this password reset, please ignore this email. + + [1] %s + ` ) func (e ErrInvalidCredentials) Error() string { @@ -162,6 +177,68 @@ func NewService( }, nil } +func (s Service) ForgetPassword( + ctx context.Context, + email string, +) error { + // Always generate a new token to avoid timing attacks and leaking information + // about existing emails + passwordResetToken, err := statelesstoken.NewToken( + s.tokenSecret, + TokenTypePasswordReset, + 1*time.Hour, + PasswordResetData{Email: email}, + ) + if err != nil { + return fmt.Errorf("cannot generate password reset token: %w", err) + } + + resetPasswordUrl := url.URL{ + Scheme: "https", + Host: s.hostname, + Path: "/reset-password", + RawQuery: url.Values{ + "token": []string{passwordResetToken}, + }.Encode(), + } + + user := &coredata.User{} + + err = s.pg.WithTx( + ctx, + func(tx pg.Conn) error { + if err := user.LoadByEmail(ctx, tx, email); err != nil { + var errUserNotFound *coredata.ErrUserNotFound + + if errors.As(err, &errUserNotFound) { + // We don't want to leak information about existing emails + // Return success even if the email doesn't exist + return nil + } + return fmt.Errorf("cannot load user by %q email: %w", email, err) + } + + resetPasswordEmail := coredata.NewEmail( + user.FullName, + user.EmailAddress, + passwordResetEmailSubject, + fmt.Sprintf(passwordResetEmailTemplate, resetPasswordUrl.String()), + ) + + if err := resetPasswordEmail.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot insert email: %w", err) + } + + return nil + }, + ) + if err != nil { + return err + } + + return nil +} + func (s Service) SignUp( ctx context.Context, email, password, fullName string, @@ -711,3 +788,46 @@ func (s Service) RemoveUser(ctx context.Context, organizationID gid.GID, userID }, ) } + +func (s Service) ResetPassword(ctx context.Context, tokenString string, newPassword string) error { + token, err := statelesstoken.ValidateToken[PasswordResetData]( + s.tokenSecret, + TokenTypePasswordReset, + tokenString, + ) + if err != nil { + return fmt.Errorf("cannot validate password reset token: %w", err) + } + + if len(newPassword) < 8 { + return &ErrInvalidPassword{length: 8} + } + + hashedPassword, err := s.hp.HashPassword([]byte(newPassword)) + if err != nil { + return fmt.Errorf("cannot hash password: %w", err) + } + + return s.pg.WithTx( + ctx, + func(tx pg.Conn) error { + user := &coredata.User{} + + if err := user.LoadByEmail(ctx, tx, token.Data.Email); err != nil { + var errUserNotFound *coredata.ErrUserNotFound + + if errors.As(err, &errUserNotFound) { + return fmt.Errorf("user not found: %w", err) + } + + return fmt.Errorf("cannot load user by email: %w", err) + } + + if err := user.UpdatePassword(ctx, tx, hashedPassword); err != nil { + return fmt.Errorf("cannot update user password: %w", err) + } + + return nil + }, + ) +}