fix: enforce expiry for web password resets
This commit is contained in:
parent
086bc4ea4a
commit
657cc23e7a
|
|
@ -193,24 +193,18 @@ func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonErr(w, 400, err)
|
jsonErr(w, 400, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var userID, expiresAt string
|
_, err := s.resetPasswordWithToken(req.Token, req.NewPassword)
|
||||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
if err == errResetTokenInvalid {
|
||||||
req.Token).Scan(&userID, &expiresAt)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 400, "invalid or expired token")
|
jsonErr(w, 400, "invalid or expired token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
if err == errResetTokenExpired {
|
||||||
if err != nil || time.Now().After(exp) {
|
|
||||||
jsonErr(w, 400, "token expired")
|
jsonErr(w, 400, "token expired")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, "internal error")
|
jsonErr(w, 500, "internal error")
|
||||||
return
|
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"})
|
jsonOK(w, map[string]string{"status": "password reset"})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+token)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var userID string
|
userID, err := s.resetPasswordWithToken(token, newPass)
|
||||||
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
|
if err == errResetTokenInvalid || err == errResetTokenExpired {
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
if err != nil {
|
||||||
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
|
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)
|
log.Printf("reset: user %s reset password", userID)
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write([]byte(resetDoneHTML(locale)))
|
w.Write([]byte(resetDoneHTML(locale)))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,10 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
dbPath := filepath.Join(dir, "legacy.db")
|
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{} {
|
func postJSON(t *testing.T, url, token string, body interface{}) map[string]interface{} {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
status, out := postJSONStatus(t, url, token, body)
|
status, out := postJSONStatus(t, url, token, body)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue