Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions backend/internal/application/auth/mfa_replay_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package auth

import (
"context"
"testing"
"time"

"github.com/google/uuid"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/opendefender/openrisk/internal/domain"
"github.com/opendefender/openrisk/internal/infrastructure/repository"
"github.com/opendefender/openrisk/pkg/crypto"
"github.com/opendefender/openrisk/pkg/otp"
)

// #849 — a TOTP code is accepted once, wherever it is presented.

var replayKey = []byte("0123456789abcdef0123456789abcdef")

type replayFixture struct {
repo *MockMFARepository
user, tenant uuid.UUID
plain string
}

func newReplayFixture(t *testing.T, verified bool) *replayFixture {
t.Helper()
plain, err := otp.GenerateTOTPSecret()
require.NoError(t, err)
enc, err := crypto.EncryptAES256GCM(plain, replayKey)
require.NoError(t, err)
f := &replayFixture{repo: NewMockMFARepository(), user: uuid.New(), tenant: uuid.New(), plain: plain}
require.NoError(t, f.repo.CreateMFASecret(context.Background(), &domain.MFASecret{
UserID: f.user, TenantID: f.tenant, SecretEncrypted: enc, IsVerified: verified,
}))
return f
}

// codeAt returns the code for the step `offset` steps from now.
func (f *replayFixture) codeAt(t *testing.T, offset int) string {
t.Helper()
c, err := totp.GenerateCode(f.plain, time.Now().Add(time.Duration(offset)*30*time.Second))
require.NoError(t, err)
return c
}

func (f *replayFixture) challenge(code string) error {
_, err := NewChallengeMFAUseCase(f.repo, replayKey).Execute(context.Background(),
ChallengeMFAInput{UserID: f.user, TenantID: f.tenant, Code: code})
return err
}

func TestChallengeMFA_ReplayedCodeIsRefused(t *testing.T) {
f := newReplayFixture(t, true)
code := f.codeAt(t, 0)

require.NoError(t, f.challenge(code), "the first use opens the session")
err := f.challenge(code)

require.Error(t, err, "the same code must not open a second session")
// Refused exactly like a wrong code: no hint that it was once valid.
wrong := f.challenge("000000")
if wrong != nil {
assert.Equal(t, wrong.Error(), err.Error())
}
}

func TestChallengeMFA_CodeFromAnEarlierStepIsRefused(t *testing.T) {
f := newReplayFixture(t, true)
require.NoError(t, f.challenge(f.codeAt(t, 0)))

// The previous step's code is still inside the ±30 s window, but older than
// the one just accepted.
assert.Error(t, f.challenge(f.codeAt(t, -1)))
}

func TestChallengeMFA_ANewerCodeIsStillAccepted(t *testing.T) {
f := newReplayFixture(t, true)
require.NoError(t, f.challenge(f.codeAt(t, 0)))

assert.NoError(t, f.challenge(f.codeAt(t, 1)), "the next step's code is fresh")
}

func TestVerifyMFA_EnrolmentCodeCannotThenOpenALogin(t *testing.T) {
f := newReplayFixture(t, false)
code := f.codeAt(t, 0)

_, err := NewVerifyMFAUseCase(f.repo, repository.GormUserRepository{}, replayKey).Execute(context.Background(),
VerifyMFAInput{UserID: f.user, TenantID: f.tenant, Code: code})
require.NoError(t, err)

assert.Error(t, f.challenge(code))
}

func TestDisableMFA_ReplayedCodeIsRefused(t *testing.T) {
f := newDisableFixture(t, domain.RoleUser, "")
plain := f.ssoAccount(t)
code, err := totp.GenerateCode(plain, time.Now())
require.NoError(t, err)

// The code was just used to sign in...
_, err = NewChallengeMFAUseCase(f.repo, disableTOTPKey).Execute(context.Background(),
ChallengeMFAInput{UserID: f.user.ID, TenantID: f.tenant, Code: code})
require.NoError(t, err)

// ...so someone who watched it typed cannot turn MFA off with it.
assert.ErrorIs(t, f.runWithCode("", code), ErrMFADisableCodeIncorrect)
f.assertStillEnrolled(t)
}
48 changes: 38 additions & 10 deletions backend/internal/application/auth/mfa_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,13 @@ func (uc *VerifyMFAUseCase) Execute(ctx context.Context, input VerifyMFAInput) (
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
}

// Verify TOTP code (±1 window)
if !otp.VerifyTOTP(decryptedSecret, input.Code) {
// Verify TOTP code (±1 window), once: the enrolment code cannot then open
// a login (#849).
accepted, err := acceptTOTP(ctx, uc.mfaRepo, input.UserID, input.TenantID, decryptedSecret, input.Code)
if err != nil {
return nil, err
}
if !accepted {
return nil, domain.NewValidationError("invalid TOTP code")
}

Expand Down Expand Up @@ -350,7 +355,14 @@ func (uc *DisableMFAUseCase) Execute(ctx context.Context, input DisableMFAInput)
if err != nil {
return nil, fmt.Errorf("auth.DisableMFA: decrypt secret: %w", err)
}
if input.Code == "" || !otp.VerifyTOTP(plain, input.Code) {
if input.Code == "" {
return nil, ErrMFADisableCodeIncorrect
}
accepted, err := acceptTOTP(ctx, uc.mfaRepo, input.UserID, input.TenantID, plain, input.Code)
if err != nil {
return nil, err
}
if !accepted {
return nil, ErrMFADisableCodeIncorrect
}
}
Expand Down Expand Up @@ -447,13 +459,13 @@ func (uc *ChallengeMFAUseCase) Execute(ctx context.Context, input ChallengeMFAIn
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
}

// Try TOTP first
if otp.VerifyTOTP(decryptedSecret, input.Code) {
// Mark as last used
now := time.Now()
mfaSecret.LastUsedAt = &now
_ = uc.mfaRepo.UpdateMFASecret(ctx, mfaSecret)

// Try TOTP first. Accepting it also records its step and last_used_at, so
// the same code cannot open a second session (#849).
accepted, err := acceptTOTP(ctx, uc.mfaRepo, input.UserID, input.TenantID, decryptedSecret, input.Code)
if err != nil {
return nil, err
}
if accepted {
return &ChallengeMFAOutput{
Verified: true,
Message: "MFA verified successfully",
Expand Down Expand Up @@ -482,3 +494,19 @@ func (uc *ChallengeMFAUseCase) Execute(ctx context.Context, input ChallengeMFAIn

return nil, domain.NewValidationError("invalid MFA code")
}

// acceptTOTP accepts a TOTP code at most once (#849). A code that matches but
// whose step was already used, or is older than the last one used, is refused
// exactly like a wrong code: telling them apart would tell an observer the
// code they replayed was once valid.
func acceptTOTP(ctx context.Context, repo repository.MFARepository, userID, tenantID uuid.UUID, plainSecret, code string) (bool, error) {
step, ok := otp.MatchTOTPStep(plainSecret, code, time.Now())
if !ok {
return false, nil
}
consumed, err := repo.ConsumeTOTPStep(ctx, userID, tenantID, step)
if err != nil {
return false, fmt.Errorf("auth: record TOTP step: %w", err)
}
return consumed, nil
}
9 changes: 9 additions & 0 deletions backend/internal/application/auth/mfa_usecase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ func (m *MockMFARepository) UpdateMFASecret(ctx context.Context, secret *domain.
return nil
}

func (m *MockMFARepository) ConsumeTOTPStep(ctx context.Context, userID, tenantID uuid.UUID, step int64) (bool, error) {
secret := m.secrets[userID.String()+":"+tenantID.String()]
if secret == nil || (secret.LastTOTPStep != nil && *secret.LastTOTPStep >= step) {
return false, nil
}
secret.LastTOTPStep = &step
return true, nil
}

func (m *MockMFARepository) DisableMFA(ctx context.Context, userID, tenantID uuid.UUID) error {
key := userID.String() + ":" + tenantID.String()
delete(m.secrets, key)
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/domain/mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ type MFASecret struct {
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

// LastTOTPStep is the time step of the last code accepted (#849). A code is
// only accepted for a later step, so a code seen on screen cannot be used
// twice. Written only by MFARepository.ConsumeTOTPStep, never by a Save.
LastTOTPStep *int64 `gorm:"column:last_totp_step" json:"-"`
}

// TableName specifies the table name for MFASecret
Expand Down
13 changes: 12 additions & 1 deletion backend/internal/infrastructure/authmfa/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package authmfa
import (
"context"
"errors"
"time"

"github.com/google/uuid"
"github.com/opendefender/openrisk/internal/infrastructure/repository"
Expand Down Expand Up @@ -43,7 +44,17 @@ func (g *Gate) VerifyRequired(ctx context.Context, user, tenant uuid.UUID, code
if err != nil {
return ErrInvalidCode
}
if !otp.VerifyTOTP(plain, code) {
// Once only (#849): a code already accepted — here, at login, anywhere —
// cannot confirm a second sensitive action.
step, ok := otp.MatchTOTPStep(plain, code, time.Now())
if !ok {
return ErrInvalidCode
}
consumed, err := g.mfaRepo.ConsumeTOTPStep(ctx, user, tenant, step)
if err != nil {
return err
}
if !consumed {
return ErrInvalidCode
}
return nil
Expand Down
70 changes: 70 additions & 0 deletions backend/internal/infrastructure/authmfa/gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package authmfa

import (
"context"
"testing"
"time"

"github.com/google/uuid"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"

"github.com/opendefender/openrisk/internal/domain"
"github.com/opendefender/openrisk/internal/infrastructure/repository"
"github.com/opendefender/openrisk/internal/testsupport/sqliteschema"
"github.com/opendefender/openrisk/pkg/crypto"
"github.com/opendefender/openrisk/pkg/otp"
)

// #849 — the step-up gate accepts a code once, like login does.

var gateKey = []byte("0123456789abcdef0123456789abcdef")

func newGate(t *testing.T) (*Gate, uuid.UUID, uuid.UUID, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:gate_"+uuid.NewString()+"?mode=memory&cache=private"),
&gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.Exec(`CREATE TABLE mfa_secrets (id TEXT PRIMARY KEY)`).Error)
require.NoError(t, sqliteschema.Reconcile(db, "mfa_secrets", &domain.MFASecret{}))

plain, err := otp.GenerateTOTPSecret()
require.NoError(t, err)
enc, err := crypto.EncryptAES256GCM(plain, gateKey)
require.NoError(t, err)
user, tenant := uuid.New(), uuid.New()
require.NoError(t, db.Create(&domain.MFASecret{
ID: uuid.New(), UserID: user, TenantID: tenant, SecretEncrypted: enc, IsVerified: true,
}).Error)
return NewGate(repository.NewGormMFARepository(db), gateKey), user, tenant, plain
}

func TestGate_ReplayedCodeIsRefused(t *testing.T) {
gate, user, tenant, plain := newGate(t)
code, err := totp.GenerateCode(plain, time.Now())
require.NoError(t, err)

require.NoError(t, gate.VerifyRequired(context.Background(), user, tenant, code))
assert.ErrorIs(t, gate.VerifyRequired(context.Background(), user, tenant, code), ErrInvalidCode,
"one code confirms one sensitive action")
}

func TestGate_CodeIsScopedToItsTenant(t *testing.T) {
gate, user, tenant, plain := newGate(t)
code, err := totp.GenerateCode(plain, time.Now())
require.NoError(t, err)

// Another tenant id finds no secret, so it is exempt and consumes nothing...
require.NoError(t, gate.VerifyRequired(context.Background(), user, uuid.New(), code))
// ...and the code is still good for its own tenant.
assert.NoError(t, gate.VerifyRequired(context.Background(), user, tenant, code))
}
Loading
Loading