feat: add password management, reading persistence on feed switch, and spin animation
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤tHash)
|
||||
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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user