// Passwort-Zurücksetzen-Tokens (Migration 0019). Ein Token ist einmal // verwendbar (used_at) und läuft ab (expires_at) — GetValidPasswordResetToken // liefert ErrNotFound für "nicht gefunden", "abgelaufen" und "schon // verwendet" gleichermaßen, damit ein Angreifer über die Fehlermeldung // nichts über den Zustand eines geratenen Tokens lernt. package store import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // PasswordResetTokenDuration ist die Gültigkeitsdauer eines frisch // erzeugten Zurücksetzen-Links. const PasswordResetTokenDuration = 1 * time.Hour type PasswordResetToken struct { ID string UserID string Token string ExpiresAt time.Time UsedAt *time.Time CreatedAt time.Time } // CreatePasswordResetToken legt einen neuen Zurücksetzen-Token für // einen Nutzer an. token muss bereits kryptographisch zufällig erzeugt // sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst. func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) { var t PasswordResetToken err := s.db(ctx).QueryRow(ctx, ` INSERT INTO password_reset_token (user_id, token, expires_at) VALUES ($1, $2, $3) RETURNING id, user_id, token, expires_at, used_at, created_at `, userID, token, time.Now().Add(PasswordResetTokenDuration)).Scan( &t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt, ) if err != nil { return PasswordResetToken{}, fmt.Errorf("store: create password reset token: %w", err) } return t, nil } // GetValidPasswordResetToken liest einen Token nur, wenn er existiert, // noch nicht abgelaufen und noch nicht verwendet ist. func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) { var t PasswordResetToken err := s.db(ctx).QueryRow(ctx, ` SELECT id, user_id, token, expires_at, used_at, created_at FROM password_reset_token WHERE token = $1 AND used_at IS NULL AND expires_at > now() `, token).Scan(&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return PasswordResetToken{}, ErrNotFound } if err != nil { return PasswordResetToken{}, fmt.Errorf("store: get valid password reset token: %w", err) } return t, nil } // MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe // Link kein zweites Mal ein Passwort setzen kann. func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error { tag, err := s.db(ctx).Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id) if err != nil { return fmt.Errorf("store: mark password reset token used: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil }