diff --git a/internal/db/queries_users.go b/internal/db/queries_users.go index 252b438..85fba6d 100644 --- a/internal/db/queries_users.go +++ b/internal/db/queries_users.go @@ -238,6 +238,24 @@ func (d *DB) CreateAdmin(username, passwordHash string) (Admin, error) { return d.GetAdminByUsername(username) } +func (d *DB) UpdateAdminPasswordHash(id int64, hash string) error { + result, err := d.conn.Exec( + `UPDATE admins SET password_hash = ?, updated_at = datetime('now') WHERE id = ?`, + hash, id, + ) + if err != nil { + return fmt.Errorf("update admin password: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return fmt.Errorf("admin not found") + } + return nil +} + func (d *DB) GetSetting(key string) (string, bool, error) { var value string err := d.conn.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) diff --git a/internal/web/auth.go b/internal/web/auth.go index d89d19d..4dd224d 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -78,13 +78,33 @@ func (a *AuthService) EnsureAdmin(username, password string) (bool, error) { func (a *AuthService) Authenticate(username, password string) bool { admin, err := a.db.GetAdminByUsername(username) if err != nil { - // Run a dummy hash comparison to reduce timing side-channels. _ = bcrypt.CompareHashAndPassword([]byte("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password)) return false } return bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) == nil } +func (a *AuthService) ChangePassword(username, oldPassword, newPassword string) error { + if len(newPassword) < 8 { + return fmt.Errorf("la nueva contraseña debe tener al menos 8 caracteres") + } + admin, err := a.db.GetAdminByUsername(username) + if err != nil { + return fmt.Errorf("admin no encontrado") + } + if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(oldPassword)); err != nil { + return fmt.Errorf("contraseña actual incorrecta") + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + if err := a.db.UpdateAdminPasswordHash(admin.ID, string(hash)); err != nil { + return err + } + return nil +} + func (a *AuthService) issueToken(username string) string { expiry := time.Now().Add(sessionTTL).Unix() payload := fmt.Sprintf("%s|%d", username, expiry) diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index eecf22f..d03a546 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -10,6 +10,11 @@ type loginRequest struct { Password string `json:"password"` } +type changePasswordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if s.Auth == nil { writeError(w, http.StatusServiceUnavailable, "auth not configured") @@ -47,3 +52,30 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "username": username, }) } + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + if s.Auth == nil { + writeError(w, http.StatusServiceUnavailable, "auth not configured") + return + } + username, ok := s.Auth.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + defer r.Body.Close() + var req changePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.OldPassword == "" || req.NewPassword == "" { + writeError(w, http.StatusBadRequest, "old_password and new_password are required") + return + } + if err := s.Auth.ChangePassword(username, req.OldPassword, req.NewPassword); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/internal/web/router.go b/internal/web/router.go index 284eaaa..2ea1c15 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -29,6 +29,7 @@ func NewRouter(s *Server) chi.Router { protected.Post("/apply", s.handleApply) protected.Get("/apply/log", s.handleApplyLog) protected.Get("/system/status", s.handleSystemStatus) + protected.Put("/auth/password", s.handleChangePassword) protected.Get("/import/status", s.handleImportStatus) protected.Post("/import/samba", s.handleImportSamba) diff --git a/web/src/App.tsx b/web/src/App.tsx index 0206e4f..e4af253 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import Users from "./pages/Users"; import Samba from "./pages/Samba"; import Nfs from "./pages/Nfs"; import Log from "./pages/Log"; +import Settings from "./pages/Settings"; type AuthState = { loading: boolean; authenticated: boolean; username: string }; @@ -54,6 +55,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/web/src/api.ts b/web/src/api.ts index ab5f473..ced595e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -87,6 +87,8 @@ export const api = { login: (username: string, password: string) => request<{ username: string }>("POST", "/auth/login", { username, password }), logout: () => request<{ ok: boolean }>("POST", "/auth/logout"), + changePassword: (oldPassword: string, newPassword: string) => + request<{ ok: boolean }>("PUT", "/auth/password", { old_password: oldPassword, new_password: newPassword }), // dirty / apply dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"), diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 9d53bc4..490d90f 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -8,6 +8,7 @@ const navItems = [ { to: "/samba", label: "SMB / Samba" }, { to: "/nfs", label: "NFS" }, { to: "/log", label: "Historial" }, + { to: "/settings", label: "Ajustes" }, ]; export default function Layout({ username, onLogout }: { username: string; onLogout: () => void }) { diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx new file mode 100644 index 0000000..b315814 --- /dev/null +++ b/web/src/pages/Settings.tsx @@ -0,0 +1,97 @@ +import { FormEvent, useState } from "react"; +import { api } from "../api"; + +export default function Settings() { + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (newPassword.length < 8) { + setError("La nueva contraseña debe tener al menos 8 caracteres."); + return; + } + if (newPassword !== confirm) { + setError("La nueva contraseña y la confirmación no coinciden."); + return; + } + + setLoading(true); + try { + await api.changePassword(oldPassword, newPassword); + setSuccess(true); + setOldPassword(""); + setNewPassword(""); + setConfirm(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Error al cambiar la contraseña."); + } finally { + setLoading(false); + } + } + + return ( +
+

Ajustes

+ +
+

Cambiar contraseña

+ + {error && ( +
{error}
+ )} + {success && ( +
+ Contraseña cambiada correctamente. +
+ )} + +
+
+ + setOldPassword(e.target.value)} + autoComplete="current-password" + /> +
+
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + /> +

Mínimo 8 caracteres.

+
+
+ + setConfirm(e.target.value)} + autoComplete="new-password" + /> +
+
+ +
+
+
+
+ ); +}