feat: add password management, reading persistence on feed switch, and spin animation

This commit is contained in:
2026-08-24 21:53:54 -07:00
parent d7ed8876dc
commit 5ea176d5ad
12 changed files with 424 additions and 76 deletions
+21
View File
@@ -101,6 +101,27 @@ func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, http.StatusOK, user)
}
func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
if !ok {
h.writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req models.ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request payload")
return
}
if err := h.auth.UpdatePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil {
h.writeError(w, http.StatusBadRequest, err.Error())
return
}
h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"})
}
// Stats Handler
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
stats, err := h.db.GetStats()
+1
View File
@@ -44,6 +44,7 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
r.Use(authService.Middleware)
r.Get("/auth/me", h.GetMe)
r.Put("/auth/password", h.UpdatePassword)
r.Get("/stats", h.GetStats)
// Categories
+23
View File
@@ -84,6 +84,29 @@ func (s *AuthService) Authenticate(username, password string) (string, error) {
return tokenString, nil
}
func (s *AuthService) UpdatePassword(userID, oldPassword, newPassword string) error {
var currentHash string
err := s.db.QueryRow("SELECT password_hash FROM users WHERE id = ?", userID).Scan(&currentHash)
if err != nil {
return errors.New("user not found")
}
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(oldPassword)); err != nil {
return errors.New("incorrect current password")
}
if len(strings.TrimSpace(newPassword)) < 6 {
return errors.New("new password must be at least 6 characters long")
}
newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash new password: %w", err)
}
return s.db.UpdateUserPassword(userID, string(newHash))
}
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+87
View File
@@ -0,0 +1,87 @@
package auth
import (
"os"
"path/filepath"
"testing"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/config"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
)
func TestAuthService(t *testing.T) {
tempDir, err := os.MkdirTemp("", "rsspipe-auth-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() {
_ = os.RemoveAll(tempDir)
}()
dbPath := filepath.Join(tempDir, "auth_test.db")
database, err := db.InitDB(dbPath)
if err != nil {
t.Fatalf("InitDB failed: %v", err)
}
defer func() {
_ = database.Close()
}()
cfg := &config.Config{
AdminUsername: "admin",
AdminPassword: "initial_password_123",
JWTSecret: "super-secret-jwt-key-for-test",
}
authService := NewAuthService(database, cfg)
// 1. Bootstrap Admin
if err := authService.BootstrapAdmin(); err != nil {
t.Fatalf("BootstrapAdmin failed: %v", err)
}
// 2. Authenticate with initial password
token, err := authService.Authenticate("admin", "initial_password_123")
if err != nil {
t.Fatalf("Authenticate failed: %v", err)
}
if token == "" {
t.Fatalf("expected non-empty token")
}
// 3. Validate Token
claims, err := authService.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken failed: %v", err)
}
if claims.Username != "admin" {
t.Errorf("expected username admin, got %s", claims.Username)
}
// 4. Update Password
if err := authService.UpdatePassword(claims.UserID, "wrong_old_pwd", "new_secure_pwd_456"); err == nil {
t.Errorf("expected error with incorrect old password")
}
if err := authService.UpdatePassword(claims.UserID, "initial_password_123", "short"); err == nil {
t.Errorf("expected error with too short new password")
}
if err := authService.UpdatePassword(claims.UserID, "initial_password_123", "new_secure_pwd_456"); err != nil {
t.Fatalf("UpdatePassword failed: %v", err)
}
// 5. Authenticate with new password
newToken, err := authService.Authenticate("admin", "new_secure_pwd_456")
if err != nil {
t.Fatalf("Authenticate with new password failed: %v", err)
}
if newToken == "" {
t.Fatalf("expected non-empty token for new password")
}
// 6. Old password should now fail
if _, err := authService.Authenticate("admin", "initial_password_123"); err == nil {
t.Errorf("expected failure when authenticating with old password")
}
}
+9
View File
@@ -173,6 +173,15 @@ func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) {
return u, nil
}
func (d *DB) UpdateUserPassword(userID, passwordHash string) error {
now := time.Now().UTC()
_, err := d.Exec(
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
passwordHash, now, userID,
)
return err
}
// Category operations
func (d *DB) ListCategories() ([]models.Category, error) {
query := `
+13
View File
@@ -102,4 +102,17 @@ func TestDatabaseOperations(t *testing.T) {
if stats.TotalFeeds != 1 || stats.TotalUnread != 1 || stats.TotalStarred != 1 || stats.TotalCategories != 1 {
t.Errorf("unexpected stats: %+v", stats)
}
// 8. Test UpdateUserPassword
newHash := "$2a$10$updatedhashvalue"
if err := database.UpdateUserPassword(u.ID, newHash); err != nil {
t.Fatalf("UpdateUserPassword failed: %v", err)
}
updatedUser, err := database.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername failed: %v", err)
}
if updatedUser.PasswordHash != newHash {
t.Errorf("expected hash %s, got %s", newHash, updatedUser.PasswordHash)
}
}
+6
View File
@@ -116,3 +116,9 @@ type MarkAllReadRequest struct {
CategoryID *string `json:"category_id,omitempty"`
Before *string `json:"before,omitempty"`
}
// ChangePasswordRequest is the body for PUT /api/auth/password
type ChangePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}