feat: password auth #2

Merged
juancwu merged 1 commit from feat/password-auth into main 2026-02-07 19:13:01 +00:00
7 changed files with 317 additions and 9 deletions

View file

@ -1,6 +1,7 @@
package handler package handler
import ( import (
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
@ -31,6 +32,70 @@ func (h *authHandler) PasswordPage(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, pages.AuthPassword("")) ui.Render(w, r, pages.AuthPassword(""))
} }
func (h *authHandler) LoginWithPassword(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
if email == "" || password == "" {
ui.Render(w, r, pages.AuthPassword("Email and password are required"))
return
}
user, err := h.authService.LoginWithPassword(email, password)
if err != nil {
slog.Warn("password login failed", "error", err, "email", email)
msg := "An error occurred. Please try again."
if errors.Is(err, service.ErrInvalidCredentials) {
msg = "Invalid email or password"
} else if errors.Is(err, service.ErrNoPassword) {
msg = "This account uses passwordless login. Please use a magic link."
}
ui.Render(w, r, pages.AuthPassword(msg))
return
}
jwtToken, err := h.authService.GenerateJWT(user)
if err != nil {
slog.Error("failed to generate JWT", "error", err, "user_id", user.ID)
ui.Render(w, r, pages.AuthPassword("An error occurred. Please try again."))
return
}
h.authService.SetJWTCookie(w, jwtToken, time.Now().Add(7*24*time.Hour))
// Check for pending invite
inviteCookie, err := r.Cookie("pending_invite")
if err == nil && inviteCookie.Value != "" {
spaceID, err := h.inviteService.AcceptInvite(inviteCookie.Value, user.ID)
if err != nil {
slog.Error("failed to process pending invite", "error", err, "token", inviteCookie.Value)
} else {
slog.Info("accepted pending invite", "user_id", user.ID, "space_id", spaceID)
http.SetCookie(w, &http.Cookie{
Name: "pending_invite",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
}
}
needsOnboarding, err := h.authService.NeedsOnboarding(user.ID)
if err != nil {
slog.Warn("failed to check onboarding status", "error", err, "user_id", user.ID)
}
if needsOnboarding {
http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther)
}
func (h *authHandler) Logout(w http.ResponseWriter, r *http.Request) { func (h *authHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.authService.ClearJWTCookie(w) h.authService.ClearJWTCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)

View file

@ -0,0 +1,74 @@
package handler
import (
"errors"
"log/slog"
"net/http"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type settingsHandler struct {
authService *service.AuthService
userService *service.UserService
}
func NewSettingsHandler(authService *service.AuthService, userService *service.UserService) *settingsHandler {
return &settingsHandler{
authService: authService,
userService: userService,
}
}
func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
// Re-fetch user from DB since middleware strips PasswordHash
fullUser, err := h.userService.ByID(user.ID)
if err != nil {
slog.Error("failed to fetch user for settings", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), ""))
}
func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
// Re-fetch user to check HasPassword
fullUser, err := h.userService.ByID(user.ID)
if err != nil {
slog.Error("failed to fetch user for set password", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = h.authService.SetPassword(user.ID, currentPassword, newPassword, confirmPassword)
if err != nil {
slog.Warn("set password failed", "error", err, "user_id", user.ID)
msg := "An error occurred. Please try again."
if errors.Is(err, service.ErrInvalidCredentials) {
msg = "Current password is incorrect"
} else if errors.Is(err, service.ErrPasswordsDoNotMatch) {
msg = "New passwords do not match"
} else if errors.Is(err, service.ErrWeakPassword) {
msg = "Password must be at least 12 characters"
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg))
return
}
// Password set successfully — render page with success message
ui.Render(w, r, pages.AppSettings(true, ""))
}

View file

@ -14,6 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler(a.AuthService, a.InviteService) auth := handler.NewAuthHandler(a.AuthService, a.InviteService)
home := handler.NewHomeHandler() home := handler.NewHomeHandler()
dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService) dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService)
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.EventBus) space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.EventBus)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -43,7 +44,8 @@ func SetupRoutes(a *app.App) http.Handler {
// Auth Actions // Auth Actions
mux.HandleFunc("POST /auth/magic-link", authRateLimiter(middleware.RequireGuest(auth.SendMagicLink))) mux.HandleFunc("POST /auth/magic-link", authRateLimiter(middleware.RequireGuest(auth.SendMagicLink)))
mux.HandleFunc("POST /auth/logout", authRateLimiter(auth.Logout)) mux.HandleFunc("POST /auth/password", authRateLimiter(middleware.RequireGuest(auth.LoginWithPassword)))
mux.HandleFunc("POST /auth/logout", auth.Logout)
// ==================================================================================== // ====================================================================================
// PRIVATE ROUTES // PRIVATE ROUTES
@ -53,6 +55,8 @@ func SetupRoutes(a *app.App) http.Handler {
mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding))) mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding)))
mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage)) mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage))
mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage))
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))
// Space routes // Space routes
spaceDashboardHandler := middleware.RequireAuth(space.DashboardPage) spaceDashboardHandler := middleware.RequireAuth(space.DashboardPage)

View file

@ -84,6 +84,11 @@ func (s *AuthService) LoginWithPassword(email, password string) (*model.User, er
return nil, e.WithError(ErrNoPassword) return nil, e.WithError(ErrNoPassword)
} }
err = s.ComparePassword(password, *user.PasswordHash)
if err != nil {
return nil, e.WithError(ErrInvalidCredentials)
}
return user, nil return user, nil
} }
@ -109,6 +114,45 @@ func (s *AuthService) ComparePassword(password, hash string) error {
return nil return nil
} }
func (s *AuthService) SetPassword(userID, currentPassword, newPassword, confirmPassword string) error {
e := exception.New("AuthService.SetPassword")
user, err := s.userRepository.ByID(userID)
if err != nil {
return e.WithError(err)
}
// If user already has a password, verify current password
if user.HasPassword() {
err = s.ComparePassword(currentPassword, *user.PasswordHash)
if err != nil {
return e.WithError(ErrInvalidCredentials)
}
}
if newPassword != confirmPassword {
return e.WithError(ErrPasswordsDoNotMatch)
}
err = validation.ValidatePassword(newPassword)
if err != nil {
return e.WithError(ErrWeakPassword)
}
hashed, err := s.HashPassword(newPassword)
if err != nil {
return e.WithError(err)
}
user.PasswordHash = &hashed
err = s.userRepository.Update(user)
if err != nil {
return e.WithError(err)
}
return nil
}
func (s *AuthService) GenerateJWT(user *model.User) (string, error) { func (s *AuthService) GenerateJWT(user *model.User) (string, error) {
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"user_id": user.ID, "user_id": user.ID,

View file

@ -146,14 +146,14 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
} }
</div> </div>
@dropdown.Separator() @dropdown.Separator()
<!-- @dropdown.Item(dropdown.ItemProps{ --> @dropdown.Item(dropdown.ItemProps{
<!-- Href: "/app/settings", --> Href: "/app/settings",
<!-- }) { --> }) {
<!-- <span class="flex items-center"> --> <span class="flex items-center">
<!-- @icon.Settings(icon.Props{Size: 16, Class: "mr-2"}) --> @icon.Settings(icon.Props{Size: 16, Class: "mr-2"})
<!-- Settings --> Settings
<!-- </span> --> </span>
<!-- } --> }
<form action="/auth/logout" method="POST" class="contents"> <form action="/auth/logout" method="POST" class="contents">
@csrf.Token() @csrf.Token()
@dropdown.Item(dropdown.ItemProps{ @dropdown.Item(dropdown.ItemProps{

View file

@ -0,0 +1,106 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
)
templ AppSettings(hasPassword bool, errorMsg string) {
@layouts.App("Settings") {
<div class="container max-w-2xl px-6 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold">Settings</h1>
<p class="text-muted-foreground mt-2">Manage your account settings</p>
</div>
@card.Card() {
@card.Header() {
@card.Title() {
if hasPassword {
Change Password
} else {
Set Password
}
}
@card.Description() {
if hasPassword {
Update your existing password
} else {
Set a password to sign in without a magic link
}
}
}
@card.Content() {
<form action="/app/settings/password" method="POST" class="space-y-4">
@csrf.Token()
if hasPassword {
@form.Item() {
@label.Label(label.Props{
For: "current_password",
Class: "block mb-2",
}) {
Current Password
}
@input.Input(input.Props{
ID: "current_password",
Name: "current_password",
Type: input.TypePassword,
Placeholder: "••••••••",
HasError: errorMsg != "",
})
}
}
@form.Item() {
@label.Label(label.Props{
For: "new_password",
Class: "block mb-2",
}) {
New Password
}
@input.Input(input.Props{
ID: "new_password",
Name: "new_password",
Type: input.TypePassword,
Placeholder: "••••••••",
HasError: errorMsg != "",
})
}
@form.Item() {
@label.Label(label.Props{
For: "confirm_password",
Class: "block mb-2",
}) {
Confirm Password
}
@input.Input(input.Props{
ID: "confirm_password",
Name: "confirm_password",
Type: input.TypePassword,
Placeholder: "••••••••",
HasError: errorMsg != "",
})
if errorMsg != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ errorMsg }
}
}
}
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
if hasPassword {
Change Password
} else {
Set Password
}
}
</form>
}
}
</div>
}
}

View file

@ -0,0 +1,15 @@
package validation
import "errors"
func ValidatePassword(password string) error {
if password == "" {
return errors.New("password is required")
}
if len(password) < 12 {
return errors.New("password must be at least 12 characters")
}
return nil
}