fix for security bug
This commit is contained in:
parent
7af0e9af58
commit
58ca71f0b7
5 changed files with 68 additions and 12 deletions
|
|
@ -411,13 +411,47 @@ func (s *Service) RequestPasswordRecovery(ctx context.Context, userID int64) (st
|
||||||
if !settings.HasPassword || settings.RecoveryEmail == "" {
|
if !settings.HasPassword || settings.RecoveryEmail == "" {
|
||||||
return "", domain.ErrPasswordRecoveryNA
|
return "", domain.ErrPasswordRecoveryNA
|
||||||
}
|
}
|
||||||
settings.RecoveryCode = recoveryCode
|
// A fixed recovery code was used here regardless of whether it was
|
||||||
settings.RecoveryCodeExpiresAt = time.Now().Unix() + recoveryCodeTTL
|
// actually emailed anywhere, which let anyone reset 2FA on any account
|
||||||
|
// with a recovery email set. Recovery now requires a real sender and a
|
||||||
|
// freshly generated code delivered to it -- no sender, no recovery.
|
||||||
|
if s.loginEmailSender == nil {
|
||||||
|
return "", domain.ErrPasswordRecoveryNA
|
||||||
|
}
|
||||||
|
code, err := randomDigits(s.loginEmailCodeLength)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
deliveryID, err := otpdelivery.NewDeliveryID()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
expiresAtUnix := time.Now().Unix() + recoveryCodeTTL
|
||||||
|
expiresAt := time.Unix(expiresAtUnix, 0)
|
||||||
|
settings.RecoveryCode = code
|
||||||
|
settings.RecoveryCodeExpiresAt = expiresAtUnix
|
||||||
if s.passwords != nil {
|
if s.passwords != nil {
|
||||||
if err := s.passwords.Save(ctx, userID, settings); err != nil {
|
if err := s.passwords.Save(ctx, userID, settings); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
|
||||||
|
DeliveryID: deliveryID,
|
||||||
|
Purpose: otpdelivery.PurposePasswordRecovery,
|
||||||
|
Channel: otpdelivery.ChannelEmail,
|
||||||
|
Recipient: settings.RecoveryEmail,
|
||||||
|
Code: code,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}); err != nil {
|
||||||
|
if s.passwords != nil {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
settings.RecoveryCode = ""
|
||||||
|
settings.RecoveryCodeExpiresAt = 0
|
||||||
|
_ = s.passwords.Save(cleanupCtx, userID, settings)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return emailPattern(settings.RecoveryEmail), nil
|
return emailPattern(settings.RecoveryEmail), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,10 +569,9 @@ func (s *Service) CancelPasswordEmail(ctx context.Context, userID int64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkRecoveryCode(settings domain.PasswordSettings, code string) error {
|
func checkRecoveryCode(settings domain.PasswordSettings, code string) error {
|
||||||
|
// No standing fixed-code fallback: an unrequested (or already consumed)
|
||||||
|
// recovery must not be satisfiable by any code at all.
|
||||||
if settings.RecoveryCode == "" {
|
if settings.RecoveryCode == "" {
|
||||||
if code == recoveryCode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return domain.ErrPasswordRecoveryNA
|
return domain.ErrPasswordRecoveryNA
|
||||||
}
|
}
|
||||||
if settings.RecoveryCodeExpiresAt > 0 && time.Now().Unix() > settings.RecoveryCodeExpiresAt {
|
if settings.RecoveryCodeExpiresAt > 0 && time.Now().Unix() > settings.RecoveryCodeExpiresAt {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,9 @@ func TestPasswordSRPRoundTrip(t *testing.T) {
|
||||||
func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
|
func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
const userID int64 = 1002
|
const userID int64 = 1002
|
||||||
svc := NewService(memory.NewPasswordStore())
|
sender := &captureMailSender{}
|
||||||
|
svc := NewService(memory.NewPasswordStore(),
|
||||||
|
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 3, 6))
|
||||||
|
|
||||||
initial, err := svc.GetPassword(ctx, userID)
|
initial, err := svc.GetPassword(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -93,7 +95,10 @@ func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
|
||||||
if pattern != "b***b@example.com" {
|
if pattern != "b***b@example.com" {
|
||||||
t.Fatalf("recovery pattern = %q, want masked email", pattern)
|
t.Fatalf("recovery pattern = %q, want masked email", pattern)
|
||||||
}
|
}
|
||||||
if err := svc.RecoverPassword(ctx, userID, recoveryCode, nil); err != nil {
|
if sender.to != "bob@example.com" || sender.code == "" {
|
||||||
|
t.Fatalf("sender = %+v, want delivered code to bob@example.com", sender)
|
||||||
|
}
|
||||||
|
if err := svc.RecoverPassword(ctx, userID, sender.code, nil); err != nil {
|
||||||
t.Fatalf("RecoverPassword clear: %v", err)
|
t.Fatalf("RecoverPassword clear: %v", err)
|
||||||
}
|
}
|
||||||
cleared, err := svc.GetPassword(ctx, userID)
|
cleared, err := svc.GetPassword(ctx, userID)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
passwordHashSize = 256
|
passwordHashSize = 256
|
||||||
recoveryCode = "12345"
|
|
||||||
recoveryCodeTTL = 15 * 60
|
recoveryCodeTTL = 15 * 60
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const (
|
||||||
PurposeLoginEmailChange Purpose = "login_email_change"
|
PurposeLoginEmailChange Purpose = "login_email_change"
|
||||||
PurposeChangePhone Purpose = "change_phone"
|
PurposeChangePhone Purpose = "change_phone"
|
||||||
PurposeConfirmPhone Purpose = "confirm_phone"
|
PurposeConfirmPhone Purpose = "confirm_phone"
|
||||||
|
PurposePasswordRecovery Purpose = "password_recovery"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
|
|
@ -47,7 +48,7 @@ func (r Request) Validate(now time.Time) error {
|
||||||
return fmt.Errorf("delivery id is empty or too long")
|
return fmt.Errorf("delivery id is empty or too long")
|
||||||
}
|
}
|
||||||
switch r.Purpose {
|
switch r.Purpose {
|
||||||
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone, PurposeConfirmPhone:
|
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone, PurposeConfirmPhone, PurposePasswordRecovery:
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
|
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/clock"
|
"github.com/iamxvbaba/td/clock"
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
|
@ -10,9 +11,21 @@ import (
|
||||||
|
|
||||||
appaccount "telesrv/internal/app/account"
|
appaccount "telesrv/internal/app/account"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/otpdelivery"
|
||||||
"telesrv/internal/store/memory"
|
"telesrv/internal/store/memory"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type capturePasswordRecoveryMailSender struct {
|
||||||
|
to string
|
||||||
|
code string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *capturePasswordRecoveryMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
|
||||||
|
s.to = req.Recipient
|
||||||
|
s.code = req.Code
|
||||||
|
return otpdelivery.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestAccountGetPasswordUsesPendingPasswordUser(t *testing.T) {
|
func TestAccountGetPasswordUsesPendingPasswordUser(t *testing.T) {
|
||||||
ctx := pendingPasswordContext()
|
ctx := pendingPasswordContext()
|
||||||
const userID int64 = 42
|
const userID int64 = 42
|
||||||
|
|
@ -65,9 +78,11 @@ func TestAuthRecoverPasswordCompletesPendingSignIn(t *testing.T) {
|
||||||
pendingPassword: true,
|
pendingPassword: true,
|
||||||
}
|
}
|
||||||
sessions := &captureSessions{}
|
sessions := &captureSessions{}
|
||||||
|
sender := &capturePasswordRecoveryMailSender{}
|
||||||
router := New(Config{}, Deps{
|
router := New(Config{}, Deps{
|
||||||
Auth: auth,
|
Auth: auth,
|
||||||
Account: appaccount.NewService(passwords),
|
Account: appaccount.NewService(passwords,
|
||||||
|
appaccount.WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 3, 6)),
|
||||||
Users: staticUsersService{user: domain.User{ID: userID, AccessHash: 7, Phone: "15550000042", FirstName: "Alice"}},
|
Users: staticUsersService{user: domain.User{ID: userID, AccessHash: 7, Phone: "15550000042", FirstName: "Alice"}},
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
}, zaptest.NewLogger(t), clock.System)
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
@ -75,7 +90,10 @@ func TestAuthRecoverPasswordCompletesPendingSignIn(t *testing.T) {
|
||||||
if _, err := router.onAuthRequestPasswordRecovery(ctx); err != nil {
|
if _, err := router.onAuthRequestPasswordRecovery(ctx); err != nil {
|
||||||
t.Fatalf("auth.requestPasswordRecovery: %v", err)
|
t.Fatalf("auth.requestPasswordRecovery: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := router.onAuthRecoverPassword(ctx, &tg.AuthRecoverPasswordRequest{Code: "12345"}); err != nil {
|
if sender.to != "alice@example.com" || sender.code == "" {
|
||||||
|
t.Fatalf("sender = %+v, want delivered recovery code to alice@example.com", sender)
|
||||||
|
}
|
||||||
|
if _, err := router.onAuthRecoverPassword(ctx, &tg.AuthRecoverPasswordRequest{Code: sender.code}); err != nil {
|
||||||
t.Fatalf("auth.recoverPassword: %v", err)
|
t.Fatalf("auth.recoverPassword: %v", err)
|
||||||
}
|
}
|
||||||
if auth.completePasswordCount != 1 || auth.completedPasswordKey != authKeyID {
|
if auth.completePasswordCount != 1 || auth.completedPasswordKey != authKeyID {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue