fix: enforce expiry for web password resets

This commit is contained in:
mirivlad 2026-07-10 03:17:27 +08:00
parent 086bc4ea4a
commit 657cc23e7a
4 changed files with 123 additions and 15 deletions

View File

@ -193,24 +193,18 @@ func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, err)
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
req.Token).Scan(&userID, &expiresAt)
if err != nil {
_, err := s.resetPasswordWithToken(req.Token, req.NewPassword)
if err == errResetTokenInvalid {
jsonErr(w, 400, "invalid or expired token")
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
if err == errResetTokenExpired {
jsonErr(w, 400, "token expired")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token)
jsonOK(w, map[string]string{"status": "password reset"})
}

View File

@ -212,15 +212,17 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+token)))
return
}
var userID string
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
if err != nil {
userID, err := s.resetPasswordWithToken(token, newPass)
if err == errResetTokenInvalid || err == errResetTokenExpired {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "common.error"), "/forgot")))
return
}
log.Printf("reset: user %s reset password", userID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(resetDoneHTML(locale)))

View File

@ -0,0 +1,69 @@
package server
import (
"database/sql"
"errors"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
errResetTokenInvalid = errors.New("invalid reset token")
errResetTokenExpired = errors.New("expired reset token")
)
func (s *Server) resetPasswordWithToken(token, newPassword string) (string, error) {
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
var userID, expiresAt string
err = tx.QueryRow(`SELECT user_id, expires_at FROM server_email_tokens
WHERE token=? AND purpose='reset'`, token).Scan(&userID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return "", errResetTokenInvalid
}
if err != nil {
return "", err
}
expiresAtTime, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || !time.Now().Before(expiresAtTime) {
return "", errResetTokenExpired
}
deleted, err := tx.Exec(`DELETE FROM server_email_tokens
WHERE token=? AND purpose='reset' AND expires_at=?`, token, expiresAt)
if err != nil {
return "", err
}
deletedCount, err := deleted.RowsAffected()
if err != nil {
return "", err
}
if deletedCount != 1 {
return "", errResetTokenInvalid
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return "", err
}
updated, err := tx.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
if err != nil {
return "", err
}
updatedCount, err := updated.RowsAffected()
if err != nil {
return "", err
}
if updatedCount != 1 {
return "", errResetTokenInvalid
}
if err := tx.Commit(); err != nil {
return "", err
}
return userID, nil
}

View File

@ -6,8 +6,10 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -343,6 +345,33 @@ func TestDeviceRegisterRequiresValidVaultID(t *testing.T) {
}
}
func TestWebResetRejectsExpiredToken(t *testing.T) {
s, _ := newSyncHTTPServer(t)
defer s.Close()
oldPassword := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", oldPassword)
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_email_tokens
(token, user_id, purpose, expires_at, created_at)
VALUES (?, ?, 'reset', ?, ?)`, "expired-reset-token", "user-a", time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), now); err != nil {
t.Fatalf("insert reset token: %v", err)
}
response := postWebReset(t, s, "expired-reset-token", "new secret password")
if response.Code != http.StatusFound || response.Header().Get("Location") != "/forgot" {
t.Fatalf("expired reset response status=%d location=%q, want 302 /forgot", response.Code, response.Header().Get("Location"))
}
var passwordHash string
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", "user-a").Scan(&passwordHash); err != nil {
t.Fatalf("read password hash: %v", err)
}
if bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(oldPassword)) != nil {
t.Fatal("expired reset changed the password")
}
}
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "legacy.db")
@ -508,6 +537,20 @@ func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string)
}
}
func postWebReset(t *testing.T, s *Server, token, password string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
"token": {token},
"password": {password},
"confirm": {password},
}
request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
s.mux.ServeHTTP(response, request)
return response
}
func postJSON(t *testing.T, url, token string, body interface{}) map[string]interface{} {
t.Helper()
status, out := postJSONStatus(t, url, token, body)