feat: implement multi-factor authentication (MFA) support
This PR adds comprehensive MFA support to Dify, including: - Two-factor authentication using TOTP - Backup codes for account recovery - MFA management in user settings - Enhanced security for user accounts Continuation of PR #22206 with improved implementation and tests. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>pull/22455/head
parent
7bf3d2c8bf
commit
34ce28cb3d
@ -0,0 +1,128 @@
|
||||
from typing import cast
|
||||
|
||||
import flask_login
|
||||
from flask import request
|
||||
from flask_restful import Resource, reqparse
|
||||
|
||||
from controllers.console.auth.error import (
|
||||
TokenValidationError,
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from services.mfa_service import MFAService
|
||||
|
||||
|
||||
class MFASetupInitApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
"""Initialize MFA setup - generate secret and QR code (GET method for compatibility)."""
|
||||
return self.post()
|
||||
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
"""Initialize MFA setup - generate secret and QR code."""
|
||||
account = cast(Account, flask_login.current_user)
|
||||
|
||||
try:
|
||||
mfa_status = MFAService.get_mfa_status(account)
|
||||
if mfa_status["enabled"]:
|
||||
return {"error": "MFA is already enabled"}, 400
|
||||
|
||||
setup_data = MFAService.generate_mfa_setup_data(account)
|
||||
return {
|
||||
"secret": setup_data["secret"],
|
||||
"qr_code": setup_data["qr_code"]
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
class MFASetupCompleteApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
"""Complete MFA setup with TOTP verification."""
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("totp_token", type=str, required=True, help="TOTP token is required")
|
||||
args = parser.parse_args()
|
||||
|
||||
account = cast(Account, flask_login.current_user)
|
||||
|
||||
try:
|
||||
result = MFAService.setup_mfa(account, args["totp_token"])
|
||||
return {
|
||||
"message": "MFA setup completed successfully",
|
||||
"backup_codes": result["backup_codes"],
|
||||
"setup_at": result["setup_at"].isoformat()
|
||||
}
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}, 400
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
class MFADisableApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
"""Disable MFA with password verification."""
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("password", type=str, required=True, help="Password is required")
|
||||
args = parser.parse_args()
|
||||
|
||||
account = cast(Account, flask_login.current_user)
|
||||
|
||||
try:
|
||||
mfa_status = MFAService.get_mfa_status(account)
|
||||
if not mfa_status["enabled"]:
|
||||
return {"error": "MFA is not enabled"}, 400
|
||||
|
||||
if MFAService.disable_mfa(account, args["password"]):
|
||||
return {"message": "MFA disabled successfully"}
|
||||
else:
|
||||
return {"error": "Invalid password"}, 400
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
class MFAStatusApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
"""Get current MFA status."""
|
||||
account = cast(Account, flask_login.current_user)
|
||||
|
||||
try:
|
||||
status = MFAService.get_mfa_status(account)
|
||||
return status
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
class MFAVerifyApi(Resource):
|
||||
def post(self):
|
||||
"""Verify MFA token during login (public endpoint)."""
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("email", type=str, required=True, help="Email is required")
|
||||
parser.add_argument("mfa_token", type=str, required=True, help="MFA token is required")
|
||||
args = parser.parse_args()
|
||||
|
||||
from models.engine import db
|
||||
account = db.session.query(Account).filter_by(email=args["email"]).first()
|
||||
|
||||
if not account:
|
||||
return {"error": "Account not found"}, 404
|
||||
|
||||
if not MFAService.is_mfa_required(account):
|
||||
return {"error": "MFA not required for this account"}, 400
|
||||
|
||||
try:
|
||||
if MFAService.authenticate_with_mfa(account, args["mfa_token"]):
|
||||
return {"message": "MFA verification successful"}
|
||||
else:
|
||||
return {"error": "Invalid MFA token"}, 400
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
@ -0,0 +1,43 @@
|
||||
"""add account mfa settings table
|
||||
|
||||
Revision ID: xyz789abc123
|
||||
Revises: 58eb7bdb93fe
|
||||
Create Date: 2025-07-13 09:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'xyz789abc123'
|
||||
down_revision = '58eb7bdb93fe'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('account_mfa_settings',
|
||||
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||
sa.Column('account_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('secret', sa.String(length=255), nullable=True),
|
||||
sa.Column('backup_codes', sa.Text(), nullable=True),
|
||||
sa.Column('setup_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
|
||||
sa.PrimaryKeyConstraint('id', name='account_mfa_settings_pkey'),
|
||||
sa.UniqueConstraint('account_id', name='unique_account_mfa_settings')
|
||||
)
|
||||
op.create_index('account_mfa_settings_account_id_idx', 'account_mfa_settings', ['account_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('account_mfa_settings_account_id_idx', table_name='account_mfa_settings')
|
||||
op.drop_table('account_mfa_settings')
|
||||
# ### end Alembic commands ###
|
||||
@ -0,0 +1,224 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import Account, AccountMFASettings
|
||||
from models.engine import db
|
||||
|
||||
|
||||
class MFAService:
|
||||
@staticmethod
|
||||
def generate_secret() -> str:
|
||||
"""Generate a new TOTP secret for the user."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
@staticmethod
|
||||
def generate_backup_codes(count: int = 8) -> list[str]:
|
||||
"""Generate backup codes for account recovery."""
|
||||
codes = []
|
||||
for _ in range(count):
|
||||
code = secrets.token_hex(4).upper()
|
||||
codes.append(code)
|
||||
return codes
|
||||
|
||||
@staticmethod
|
||||
def generate_qr_code(account: Account, secret: str) -> str:
|
||||
"""Generate QR code for TOTP setup."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
provisioning_uri = totp.provisioning_uri(
|
||||
name=account.email,
|
||||
issuer_name="Dify"
|
||||
)
|
||||
|
||||
# Generate QR code
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(provisioning_uri)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Create image
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Convert to base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
return f"data:image/png;base64,{img_str}"
|
||||
|
||||
@staticmethod
|
||||
def verify_totp(secret: str, token: str) -> bool:
|
||||
"""Verify TOTP token."""
|
||||
if not secret:
|
||||
return False
|
||||
try:
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(token, valid_window=1)
|
||||
except Exception as e:
|
||||
print(f"[MFA DEBUG] verify_totp error: {type(e).__name__}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_mfa_settings(account: Account) -> AccountMFASettings:
|
||||
"""Get or create MFA settings for account."""
|
||||
mfa_settings = db.session.query(AccountMFASettings).filter_by(account_id=account.id).first()
|
||||
if not mfa_settings:
|
||||
mfa_settings = AccountMFASettings(account_id=account.id)
|
||||
db.session.add(mfa_settings)
|
||||
db.session.commit()
|
||||
return mfa_settings
|
||||
|
||||
@staticmethod
|
||||
def verify_backup_code(mfa_settings: AccountMFASettings, code: str) -> bool:
|
||||
"""Verify and consume backup code."""
|
||||
if not mfa_settings.backup_codes:
|
||||
return False
|
||||
|
||||
try:
|
||||
backup_codes = json.loads(mfa_settings.backup_codes)
|
||||
if code.upper() in backup_codes:
|
||||
# Remove used backup code
|
||||
backup_codes.remove(code.upper())
|
||||
mfa_settings.backup_codes = json.dumps(backup_codes)
|
||||
db.session.commit()
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def setup_mfa(account: Account, totp_token: str) -> dict:
|
||||
"""Setup MFA for account with TOTP verification."""
|
||||
mfa_settings = MFAService.get_or_create_mfa_settings(account)
|
||||
|
||||
if mfa_settings.enabled:
|
||||
raise ValueError("MFA is already enabled for this account")
|
||||
|
||||
if not mfa_settings.secret:
|
||||
raise ValueError("MFA secret not generated")
|
||||
|
||||
# Verify TOTP token
|
||||
if not MFAService.verify_totp(mfa_settings.secret, totp_token):
|
||||
raise ValueError("Invalid TOTP token")
|
||||
|
||||
# Generate backup codes
|
||||
backup_codes = MFAService.generate_backup_codes()
|
||||
|
||||
# Enable MFA
|
||||
mfa_settings.enabled = True
|
||||
mfa_settings.backup_codes = json.dumps(backup_codes)
|
||||
mfa_settings.setup_at = datetime.now(timezone.utc)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"backup_codes": backup_codes,
|
||||
"setup_at": mfa_settings.setup_at
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def disable_mfa(account: Account, password: str) -> bool:
|
||||
"""Disable MFA for account after password verification."""
|
||||
from libs.password import compare_password
|
||||
|
||||
# Verify password
|
||||
if account.password is None or not compare_password(password, account.password, account.password_salt):
|
||||
return False
|
||||
|
||||
mfa_settings = db.session.query(AccountMFASettings).filter_by(account_id=account.id).first()
|
||||
if not mfa_settings:
|
||||
return True # Already disabled
|
||||
|
||||
# Disable MFA
|
||||
mfa_settings.enabled = False
|
||||
mfa_settings.secret = None
|
||||
mfa_settings.backup_codes = None
|
||||
mfa_settings.setup_at = None
|
||||
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def generate_mfa_setup_data(account: Account) -> dict:
|
||||
"""Generate MFA setup data including secret and QR code."""
|
||||
mfa_settings = MFAService.get_or_create_mfa_settings(account)
|
||||
|
||||
if mfa_settings.enabled:
|
||||
raise ValueError("MFA is already enabled for this account")
|
||||
|
||||
# Generate new secret
|
||||
secret = MFAService.generate_secret()
|
||||
mfa_settings.secret = secret
|
||||
db.session.commit()
|
||||
|
||||
# Generate QR code
|
||||
qr_code = MFAService.generate_qr_code(account, secret)
|
||||
|
||||
return {
|
||||
"secret": secret,
|
||||
"qr_code": qr_code
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def is_mfa_required(account: Account) -> bool:
|
||||
"""Check if MFA is required for this account."""
|
||||
mfa_settings = db.session.query(AccountMFASettings).filter_by(account_id=account.id).first()
|
||||
return mfa_settings and mfa_settings.enabled and mfa_settings.secret is not None
|
||||
|
||||
@staticmethod
|
||||
def authenticate_with_mfa(account: Account, token: str) -> bool:
|
||||
"""Authenticate user with MFA token (TOTP or backup code)."""
|
||||
print(f"[MFA DEBUG] authenticate_with_mfa called with token: {token}")
|
||||
mfa_settings = db.session.query(AccountMFASettings).filter_by(account_id=account.id).first()
|
||||
|
||||
if not mfa_settings or not mfa_settings.enabled:
|
||||
print("[MFA DEBUG] MFA not enabled, returning True")
|
||||
return True
|
||||
|
||||
print(f"[MFA DEBUG] MFA enabled, secret: {mfa_settings.secret[:10]}...")
|
||||
|
||||
# Try TOTP first
|
||||
print("[MFA DEBUG] Trying TOTP verification")
|
||||
if MFAService.verify_totp(mfa_settings.secret, token):
|
||||
print("[MFA DEBUG] TOTP verification successful")
|
||||
return True
|
||||
|
||||
# Try backup code
|
||||
print("[MFA DEBUG] Trying backup code verification")
|
||||
if MFAService.verify_backup_code(mfa_settings, token):
|
||||
print("[MFA DEBUG] Backup code verification successful")
|
||||
return True
|
||||
|
||||
print("[MFA DEBUG] All verifications failed")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_mfa_status(account: Account) -> dict:
|
||||
"""Get MFA status for account."""
|
||||
mfa_settings = db.session.query(AccountMFASettings).filter_by(account_id=account.id).first()
|
||||
|
||||
if not mfa_settings:
|
||||
return {
|
||||
"enabled": False,
|
||||
"setup_at": None,
|
||||
"has_backup_codes": False
|
||||
}
|
||||
|
||||
return {
|
||||
"enabled": mfa_settings.enabled,
|
||||
"setup_at": mfa_settings.setup_at.isoformat() if mfa_settings.setup_at else None,
|
||||
"has_backup_codes": mfa_settings.backup_codes is not None
|
||||
}
|
||||
@ -0,0 +1,296 @@
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask
|
||||
from flask_restful import Api
|
||||
|
||||
from controllers.console.auth.login import LoginApi
|
||||
from models.account import Account, AccountMFASettings
|
||||
|
||||
|
||||
class TestLoginMFAIntegration:
|
||||
|
||||
def test_login_without_mfa_success(self, test_client, setup_account):
|
||||
"""Test successful login without MFA enabled."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
mock_auth.return_value = setup_account
|
||||
|
||||
with patch('services.mfa_service.MFAService.is_mfa_required') as mock_mfa:
|
||||
mock_mfa.return_value = False
|
||||
|
||||
response = test_client.post('/console/api/login', json={
|
||||
"email": setup_account.email,
|
||||
"password": "test_password"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "success"
|
||||
assert "access_token" in data["data"]
|
||||
|
||||
@patch('controllers.console.auth.login.FeatureService.get_system_features')
|
||||
@patch('controllers.console.auth.login.dify_config')
|
||||
@patch('controllers.console.auth.login.BillingService.is_email_in_freeze')
|
||||
@patch('controllers.console.auth.login.AccountService.is_login_error_rate_limit')
|
||||
@patch('controllers.console.auth.login.AccountService.authenticate')
|
||||
@patch('controllers.console.auth.login.MFAService.is_mfa_required')
|
||||
def test_login_with_mfa_required_no_token(self, mock_is_mfa_required, mock_authenticate,
|
||||
mock_rate_limit, mock_freeze_check, mock_dify_config,
|
||||
mock_system_features, test_client, setup_account):
|
||||
"""Test login returns mfa_required when MFA is enabled but no token provided."""
|
||||
# Setup mocks
|
||||
mock_dify_config.BILLING_ENABLED = False
|
||||
mock_freeze_check.return_value = False
|
||||
mock_rate_limit.return_value = False
|
||||
mock_authenticate.return_value = setup_account
|
||||
mock_is_mfa_required.return_value = True
|
||||
|
||||
with patch('controllers.console.auth.login.setup_required') as mock_setup, \
|
||||
patch('controllers.console.auth.login.email_password_login_enabled') as mock_email_enabled:
|
||||
mock_setup.return_value = lambda f: f
|
||||
mock_email_enabled.return_value = lambda f: f
|
||||
|
||||
response = test_client.post('/console/api/login', json={
|
||||
"email": "test@example.com",
|
||||
"password": "test_password"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["result"] == "fail"
|
||||
assert data["code"] == "mfa_required"
|
||||
|
||||
@patch('controllers.console.auth.login.FeatureService.get_system_features')
|
||||
@patch('controllers.console.auth.login.dify_config')
|
||||
@patch('controllers.console.auth.login.BillingService.is_email_in_freeze')
|
||||
@patch('controllers.console.auth.login.AccountService.is_login_error_rate_limit')
|
||||
@patch('controllers.console.auth.login.AccountService.authenticate')
|
||||
@patch('controllers.console.auth.login.MFAService.is_mfa_required')
|
||||
@patch('controllers.console.auth.login.MFAService.authenticate_with_mfa')
|
||||
def test_login_with_mfa_invalid_token(self, mock_auth_mfa, mock_is_mfa_required, mock_authenticate,
|
||||
mock_rate_limit, mock_freeze_check, mock_dify_config,
|
||||
mock_system_features, test_client, setup_account):
|
||||
"""Test login fails with invalid MFA token."""
|
||||
# Setup mocks
|
||||
mock_dify_config.BILLING_ENABLED = False
|
||||
mock_freeze_check.return_value = False
|
||||
mock_rate_limit.return_value = False
|
||||
mock_authenticate.return_value = setup_account
|
||||
mock_is_mfa_required.return_value = True
|
||||
mock_auth_mfa.return_value = False # Invalid token
|
||||
|
||||
with patch('controllers.console.auth.login.setup_required') as mock_setup, \
|
||||
patch('controllers.console.auth.login.email_password_login_enabled') as mock_email_enabled:
|
||||
mock_setup.return_value = lambda f: f
|
||||
mock_email_enabled.return_value = lambda f: f
|
||||
|
||||
response = test_client.post('/console/api/login', json={
|
||||
"email": "test@example.com",
|
||||
"password": "test_password",
|
||||
"mfa_code": "invalid_token"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["result"] == "fail"
|
||||
assert data["code"] == "mfa_token_invalid"
|
||||
assert data["data"] == "The MFA token is invalid or expired."
|
||||
|
||||
@patch('controllers.console.auth.login.FeatureService.get_system_features')
|
||||
@patch('controllers.console.auth.login.dify_config')
|
||||
@patch('controllers.console.auth.login.BillingService.is_email_in_freeze')
|
||||
@patch('controllers.console.auth.login.AccountService.is_login_error_rate_limit')
|
||||
@patch('controllers.console.auth.login.AccountService.authenticate')
|
||||
@patch('controllers.console.auth.login.MFAService.is_mfa_required')
|
||||
@patch('controllers.console.auth.login.MFAService.authenticate_with_mfa')
|
||||
@patch('controllers.console.auth.login.TenantService.get_join_tenants')
|
||||
@patch('controllers.console.auth.login.AccountService.login')
|
||||
@patch('controllers.console.auth.login.AccountService.reset_login_error_rate_limit')
|
||||
@patch('controllers.console.auth.login.extract_remote_ip')
|
||||
def test_login_with_mfa_valid_token_success(self, mock_extract_ip, mock_reset_limit,
|
||||
mock_login_service, mock_get_tenants, mock_auth_mfa,
|
||||
mock_is_mfa_required, mock_authenticate,
|
||||
mock_rate_limit, mock_freeze_check, mock_dify_config,
|
||||
mock_system_features, test_client, setup_account):
|
||||
"""Test successful login with valid MFA token."""
|
||||
# Setup mocks
|
||||
mock_dify_config.BILLING_ENABLED = False
|
||||
mock_freeze_check.return_value = False
|
||||
mock_rate_limit.return_value = False
|
||||
mock_authenticate.return_value = setup_account
|
||||
mock_is_mfa_required.return_value = True
|
||||
mock_auth_mfa.return_value = True # Valid token
|
||||
mock_get_tenants.return_value = [Mock()] # At least one tenant
|
||||
mock_extract_ip.return_value = "127.0.0.1"
|
||||
|
||||
token_pair_mock = Mock()
|
||||
token_pair_mock.model_dump.return_value = {
|
||||
"access_token": "test_access_token",
|
||||
"refresh_token": "test_refresh_token"
|
||||
}
|
||||
mock_login_service.return_value = token_pair_mock
|
||||
|
||||
with patch('controllers.console.auth.login.setup_required') as mock_setup, \
|
||||
patch('controllers.console.auth.login.email_password_login_enabled') as mock_email_enabled:
|
||||
mock_setup.return_value = lambda f: f
|
||||
mock_email_enabled.return_value = lambda f: f
|
||||
|
||||
response = test_client.post('/console/api/login', json={
|
||||
"email": "test@example.com",
|
||||
"password": "test_password",
|
||||
"mfa_code": "123456"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["result"] == "success"
|
||||
assert "access_token" in data["data"]
|
||||
|
||||
# Verify MFA authentication was called
|
||||
mock_auth_mfa.assert_called_once_with(setup_account, "123456")
|
||||
|
||||
@patch('controllers.console.auth.login.FeatureService.get_system_features')
|
||||
@patch('controllers.console.auth.login.dify_config')
|
||||
@patch('controllers.console.auth.login.BillingService.is_email_in_freeze')
|
||||
@patch('controllers.console.auth.login.AccountService.is_login_error_rate_limit')
|
||||
@patch('controllers.console.auth.login.AccountService.authenticate')
|
||||
@patch('controllers.console.auth.login.MFAService.is_mfa_required')
|
||||
@patch('controllers.console.auth.login.MFAService.authenticate_with_mfa')
|
||||
@patch('controllers.console.auth.login.TenantService.get_join_tenants')
|
||||
@patch('controllers.console.auth.login.AccountService.login')
|
||||
@patch('controllers.console.auth.login.AccountService.reset_login_error_rate_limit')
|
||||
@patch('controllers.console.auth.login.extract_remote_ip')
|
||||
def test_login_with_mfa_backup_code_success(self, mock_extract_ip, mock_reset_limit,
|
||||
mock_login_service, mock_get_tenants, mock_auth_mfa,
|
||||
mock_is_mfa_required, mock_authenticate,
|
||||
mock_rate_limit, mock_freeze_check, mock_dify_config,
|
||||
mock_system_features, test_client, setup_account):
|
||||
"""Test successful login with valid backup code."""
|
||||
# Setup mocks
|
||||
mock_dify_config.BILLING_ENABLED = False
|
||||
mock_freeze_check.return_value = False
|
||||
mock_rate_limit.return_value = False
|
||||
mock_authenticate.return_value = setup_account
|
||||
mock_is_mfa_required.return_value = True
|
||||
mock_auth_mfa.return_value = True # Valid backup code
|
||||
mock_get_tenants.return_value = [Mock()] # At least one tenant
|
||||
mock_extract_ip.return_value = "127.0.0.1"
|
||||
|
||||
token_pair_mock = Mock()
|
||||
token_pair_mock.model_dump.return_value = {
|
||||
"access_token": "test_access_token",
|
||||
"refresh_token": "test_refresh_token"
|
||||
}
|
||||
mock_login_service.return_value = token_pair_mock
|
||||
|
||||
with patch('controllers.console.auth.login.setup_required') as mock_setup, \
|
||||
patch('controllers.console.auth.login.email_password_login_enabled') as mock_email_enabled:
|
||||
mock_setup.return_value = lambda f: f
|
||||
mock_email_enabled.return_value = lambda f: f
|
||||
|
||||
response = test_client.post('/console/api/login', json={
|
||||
"email": "test@example.com",
|
||||
"password": "test_password",
|
||||
"mfa_code": "BACKUP123" # Backup code format
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["result"] == "success"
|
||||
assert "access_token" in data["data"]
|
||||
|
||||
# Verify MFA authentication was called with backup code
|
||||
mock_auth_mfa.assert_called_once_with(setup_account, "BACKUP123")
|
||||
|
||||
@patch('controllers.console.auth.login.FeatureService.get_system_features')
|
||||
@patch('controllers.console.auth.login.dify_config')
|
||||
@patch('controllers.console.auth.login.BillingService.is_email_in_freeze')
|
||||
@patch('controllers.console.auth.login.AccountService.is_login_error_rate_limit')
|
||||
@patch('controllers.console.auth.login.AccountService.authenticate')
|
||||
@patch('controllers.console.auth.login.MFAService.is_mfa_required')
|
||||
def test_login_mfa_flow_order(self, mock_is_mfa_required, mock_authenticate,
|
||||
mock_rate_limit, mock_freeze_check, mock_dify_config,
|
||||
mock_system_features, test_client):
|
||||
"""Test that MFA check happens after password authentication."""
|
||||
# Setup mocks - password auth fails
|
||||
mock_dify_config.BILLING_ENABLED = False
|
||||
mock_freeze_check.return_value = False
|
||||
mock_rate_limit.return_value = False
|
||||
|
||||
# Mock password authentication failure
|
||||
from services.errors.account import AccountPasswordError
|
||||
mock_authenticate.side_effect = AccountPasswordError()
|
||||
|
||||
with patch('controllers.console.auth.login.setup_required') as mock_setup, \
|
||||
patch('controllers.console.auth.login.email_password_login_enabled') as mock_email_enabled, \
|
||||
patch('controllers.console.auth.login.AccountService.add_login_error_rate_limit') as mock_add_limit:
|
||||
mock_setup.return_value = lambda f: f
|
||||
mock_email_enabled.return_value = lambda f: f
|
||||
|
||||
response = test_client.post('/console/api/login', json={
|
||||
"email": "test@example.com",
|
||||
"password": "wrong_password",
|
||||
"mfa_code": "123456"
|
||||
})
|
||||
|
||||
# Password error should trigger EmailOrPasswordMismatchError
|
||||
assert response.status_code == 400
|
||||
|
||||
# MFA check should not be called if password auth fails
|
||||
mock_is_mfa_required.assert_not_called()
|
||||
|
||||
|
||||
class TestMFAEndToEndFlow:
|
||||
"""End-to-end tests for complete MFA flow."""
|
||||
|
||||
def setup_method(self):
|
||||
self.app = Flask(__name__)
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
@patch('services.mfa_service.MFAService.generate_secret')
|
||||
@patch('services.mfa_service.MFAService.generate_qr_code')
|
||||
@patch('services.mfa_service.MFAService.verify_totp')
|
||||
@patch('services.mfa_service.MFAService.generate_backup_codes')
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_complete_mfa_setup_flow(self, mock_session, mock_gen_codes, mock_verify, mock_gen_qr, mock_gen_secret):
|
||||
"""Test complete MFA setup flow from init to completion."""
|
||||
from services.mfa_service import MFAService
|
||||
from models.account import Account
|
||||
|
||||
# Mock account
|
||||
account = Mock(spec=Account)
|
||||
account.id = "test-id"
|
||||
account.email = "test@example.com"
|
||||
|
||||
# Setup mocks
|
||||
mock_gen_secret.return_value = "TESTSECRET123"
|
||||
mock_gen_qr.return_value = "data:image/png;base64,test"
|
||||
mock_verify.return_value = True
|
||||
mock_gen_codes.return_value = ["CODE1", "CODE2", "CODE3"]
|
||||
|
||||
# Step 1: Initialize MFA setup
|
||||
with patch('services.mfa_service.MFAService.get_or_create_mfa_settings') as mock_get_settings:
|
||||
mfa_settings = Mock()
|
||||
mfa_settings.enabled = False
|
||||
mfa_settings.secret = None
|
||||
mock_get_settings.return_value = mfa_settings
|
||||
|
||||
setup_data = MFAService.generate_mfa_setup_data(account)
|
||||
|
||||
assert setup_data["secret"] == "TESTSECRET123"
|
||||
assert setup_data["qr_code"] == "data:image/png;base64,test"
|
||||
assert mfa_settings.secret == "TESTSECRET123"
|
||||
|
||||
# Step 2: Complete MFA setup
|
||||
with patch('services.mfa_service.MFAService.get_or_create_mfa_settings') as mock_get_settings:
|
||||
mfa_settings.secret = "TESTSECRET123"
|
||||
mock_get_settings.return_value = mfa_settings
|
||||
|
||||
result = MFAService.setup_mfa(account, "123456")
|
||||
|
||||
assert mfa_settings.enabled is True
|
||||
assert result["backup_codes"] == ["CODE1", "CODE2", "CODE3"]
|
||||
assert mfa_settings.setup_at is not None
|
||||
@ -0,0 +1,266 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.account_service import AccountService
|
||||
from services.mfa_service import MFAService
|
||||
|
||||
|
||||
class TestMFAEndpoints:
|
||||
"""Test MFA endpoints using integration test approach."""
|
||||
|
||||
@pytest.fixture
|
||||
def auth_header(self, setup_account):
|
||||
"""Get authentication header with JWT token."""
|
||||
token = AccountService.get_account_jwt_token(setup_account)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def test_mfa_status_success(self, test_client, setup_account, auth_header):
|
||||
"""Test successful MFA status check."""
|
||||
with patch.object(MFAService, 'get_mfa_status') as mock_status:
|
||||
mock_status.return_value = {"enabled": False, "setup_at": None}
|
||||
|
||||
response = test_client.get(
|
||||
'/console/api/account/mfa/status',
|
||||
headers=auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["enabled"] is False
|
||||
assert data["setup_at"] is None
|
||||
mock_status.assert_called_once_with(setup_account)
|
||||
|
||||
def test_mfa_setup_init_success(self, test_client, setup_account, auth_header):
|
||||
"""Test successful MFA setup initialization."""
|
||||
with patch.object(MFAService, 'get_mfa_status') as mock_status:
|
||||
with patch.object(MFAService, 'generate_mfa_setup_data') as mock_generate:
|
||||
mock_status.return_value = {"enabled": False}
|
||||
mock_generate.return_value = {
|
||||
"secret": "TEST_SECRET",
|
||||
"qr_code": "data:image/png;base64,test"
|
||||
}
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup',
|
||||
headers=auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["secret"] == "TEST_SECRET"
|
||||
assert data["qr_code"] == "data:image/png;base64,test"
|
||||
mock_generate.assert_called_once_with(setup_account)
|
||||
|
||||
def test_mfa_setup_init_already_enabled(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA setup initialization when already enabled."""
|
||||
with patch.object(MFAService, 'get_mfa_status') as mock_status:
|
||||
mock_status.return_value = {"enabled": True, "setup_at": "2024-01-01T00:00:00"}
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup',
|
||||
headers=auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert data["error"] == "MFA is already enabled"
|
||||
|
||||
def test_mfa_setup_complete_success(self, test_client, setup_account, auth_header):
|
||||
"""Test successful MFA setup completion."""
|
||||
with patch.object(MFAService, 'setup_mfa') as mock_setup:
|
||||
mock_setup.return_value = {
|
||||
"message": "MFA has been successfully enabled",
|
||||
"backup_codes": ["CODE1", "CODE2", "CODE3", "CODE4", "CODE5", "CODE6", "CODE7", "CODE8"],
|
||||
"setup_at": "2024-01-01T00:00:00"
|
||||
}
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup/complete',
|
||||
headers=auth_header,
|
||||
json={"totp_token": "123456"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["message"] == "MFA has been successfully enabled"
|
||||
assert len(data["backup_codes"]) == 8
|
||||
mock_setup.assert_called_once_with(setup_account, "123456")
|
||||
|
||||
def test_mfa_setup_complete_missing_token(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA setup completion with missing token."""
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup/complete',
|
||||
headers=auth_header,
|
||||
json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert "totp_token is required" in data["error"]
|
||||
|
||||
def test_mfa_setup_complete_invalid_token(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA setup completion with invalid token."""
|
||||
with patch.object(MFAService, 'setup_mfa') as mock_setup:
|
||||
mock_setup.side_effect = ValueError("Invalid TOTP token")
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup/complete',
|
||||
headers=auth_header,
|
||||
json={"totp_token": "999999"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert "Invalid TOTP token" in data["error"]
|
||||
|
||||
def test_mfa_disable_success(self, test_client, setup_account, auth_header):
|
||||
"""Test successful MFA disable."""
|
||||
with patch.object(MFAService, 'disable_mfa') as mock_disable:
|
||||
mock_disable.return_value = {"message": "MFA has been disabled"}
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/disable',
|
||||
headers=auth_header,
|
||||
json={"password": "test_password"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["message"] == "MFA has been disabled"
|
||||
mock_disable.assert_called_once_with(setup_account, "test_password")
|
||||
|
||||
def test_mfa_disable_wrong_password(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA disable with wrong password."""
|
||||
with patch.object(MFAService, 'disable_mfa') as mock_disable:
|
||||
mock_disable.side_effect = ValueError("Invalid password")
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/disable',
|
||||
headers=auth_header,
|
||||
json={"password": "wrong_password"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert "Invalid password" in data["error"]
|
||||
|
||||
def test_mfa_disable_not_enabled(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA disable when not enabled."""
|
||||
with patch.object(MFAService, 'disable_mfa') as mock_disable:
|
||||
mock_disable.side_effect = ValueError("MFA is not enabled")
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/disable',
|
||||
headers=auth_header,
|
||||
json={"password": "test_password"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert "MFA is not enabled" in data["error"]
|
||||
|
||||
def test_mfa_verify_success(self, test_client):
|
||||
"""Test successful MFA verification during login."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
with patch.object(MFAService, 'is_mfa_required') as mock_required:
|
||||
with patch.object(MFAService, 'authenticate_with_mfa') as mock_verify:
|
||||
# Mock user exists
|
||||
from models.account import Account
|
||||
mock_account = Account(
|
||||
id="test-id",
|
||||
email="test@example.com",
|
||||
name="Test User"
|
||||
)
|
||||
mock_auth.return_value = mock_account
|
||||
mock_required.return_value = True
|
||||
mock_verify.return_value = True
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/mfa/verify',
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"mfa_code": "123456",
|
||||
"remember_me": True
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "success"
|
||||
|
||||
def test_mfa_verify_invalid_token(self, test_client):
|
||||
"""Test MFA verification with invalid token."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
with patch.object(MFAService, 'is_mfa_required') as mock_required:
|
||||
with patch.object(MFAService, 'authenticate_with_mfa') as mock_verify:
|
||||
# Mock user exists
|
||||
from models.account import Account
|
||||
mock_account = Account(
|
||||
id="test-id",
|
||||
email="test@example.com",
|
||||
name="Test User"
|
||||
)
|
||||
mock_auth.return_value = mock_account
|
||||
mock_required.return_value = True
|
||||
mock_verify.return_value = False
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/mfa/verify',
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"mfa_code": "999999",
|
||||
"remember_me": True
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "fail"
|
||||
assert data["code"] == "mfa_token_invalid"
|
||||
|
||||
def test_mfa_verify_not_required(self, test_client):
|
||||
"""Test MFA verification when MFA is not required."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
with patch.object(MFAService, 'is_mfa_required') as mock_required:
|
||||
# Mock user exists
|
||||
from models.account import Account
|
||||
mock_account = Account(
|
||||
id="test-id",
|
||||
email="test@example.com",
|
||||
name="Test User"
|
||||
)
|
||||
mock_auth.return_value = mock_account
|
||||
mock_required.return_value = False
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/mfa/verify',
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"mfa_code": "123456",
|
||||
"remember_me": True
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "fail"
|
||||
assert data["code"] == "mfa_not_required"
|
||||
|
||||
def test_mfa_verify_account_not_found(self, test_client):
|
||||
"""Test MFA verification with non-existent account."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
mock_auth.return_value = None
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/mfa/verify',
|
||||
json={
|
||||
"email": "nonexistent@example.com",
|
||||
"mfa_code": "123456",
|
||||
"remember_me": True
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "fail"
|
||||
assert data["code"] == "mfa_verify_failed"
|
||||
@ -0,0 +1,90 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from models.account import Account
|
||||
from services.mfa_service import MFAService
|
||||
|
||||
|
||||
class TestMFASimpleIntegration:
|
||||
"""Simple integration tests for MFA functionality."""
|
||||
|
||||
def test_mfa_setup_flow(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA setup flow end-to-end."""
|
||||
# Step 1: Check initial MFA status
|
||||
response = test_client.get(
|
||||
f"/console/api/account/mfa/status",
|
||||
headers=auth_header
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["enabled"] is False
|
||||
|
||||
# Step 2: Initialize MFA setup
|
||||
response = test_client.post(
|
||||
f"/console/api/account/mfa/setup",
|
||||
headers=auth_header
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert "secret" in data
|
||||
assert "qr_code" in data
|
||||
secret = data["secret"]
|
||||
|
||||
# Step 3: Complete MFA setup with mocked TOTP
|
||||
with mock.patch.object(MFAService, 'verify_totp', return_value=True):
|
||||
response = test_client.post(
|
||||
f"/console/api/account/mfa/setup/complete",
|
||||
headers=auth_header,
|
||||
json={"totp_token": "123456"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert "backup_codes" in data
|
||||
assert len(data["backup_codes"]) == 8
|
||||
|
||||
# Step 4: Verify MFA is now enabled
|
||||
response = test_client.get(
|
||||
f"/console/api/account/mfa/status",
|
||||
headers=auth_header
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["enabled"] is True
|
||||
|
||||
def test_mfa_disable_flow(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA disable flow."""
|
||||
# First, set up MFA for the account
|
||||
with mock.patch.object(MFAService, 'verify_totp', return_value=True):
|
||||
# Initialize setup
|
||||
response = test_client.post(
|
||||
f"/console/api/account/mfa/setup",
|
||||
headers=auth_header
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Complete setup
|
||||
response = test_client.post(
|
||||
f"/console/api/account/mfa/setup/complete",
|
||||
headers=auth_header,
|
||||
json={"totp_token": "123456"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Now disable MFA
|
||||
response = test_client.post(
|
||||
f"/console/api/account/mfa/disable",
|
||||
headers=auth_header,
|
||||
json={"password": "password"} # Default test password
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert "disabled successfully" in data["message"]
|
||||
|
||||
# Verify MFA is disabled
|
||||
response = test_client.get(
|
||||
f"/console/api/account/mfa/status",
|
||||
headers=auth_header
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["enabled"] is False
|
||||
@ -0,0 +1,266 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.account_service import AccountService
|
||||
from services.mfa_service import MFAService
|
||||
|
||||
|
||||
class TestMFAEndpoints:
|
||||
"""Test MFA endpoints using integration test approach."""
|
||||
|
||||
@pytest.fixture
|
||||
def auth_header(self, setup_account):
|
||||
"""Get authentication header with JWT token."""
|
||||
token = AccountService.get_account_jwt_token(setup_account)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def test_mfa_status_success(self, test_client, setup_account, auth_header):
|
||||
"""Test successful MFA status check."""
|
||||
with patch.object(MFAService, 'get_mfa_status') as mock_status:
|
||||
mock_status.return_value = {"enabled": False, "setup_at": None}
|
||||
|
||||
response = test_client.get(
|
||||
'/console/api/account/mfa/status',
|
||||
headers=auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["enabled"] is False
|
||||
assert data["setup_at"] is None
|
||||
mock_status.assert_called_once_with(setup_account)
|
||||
|
||||
def test_mfa_setup_init_success(self, test_client, setup_account, auth_header):
|
||||
"""Test successful MFA setup initialization."""
|
||||
with patch.object(MFAService, 'get_mfa_status') as mock_status:
|
||||
with patch.object(MFAService, 'generate_mfa_setup_data') as mock_generate:
|
||||
mock_status.return_value = {"enabled": False}
|
||||
mock_generate.return_value = {
|
||||
"secret": "TEST_SECRET",
|
||||
"qr_code": "data:image/png;base64,test"
|
||||
}
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup',
|
||||
headers=auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["secret"] == "TEST_SECRET"
|
||||
assert data["qr_code"] == "data:image/png;base64,test"
|
||||
mock_generate.assert_called_once_with(setup_account)
|
||||
|
||||
def test_mfa_setup_init_already_enabled(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA setup initialization when already enabled."""
|
||||
with patch.object(MFAService, 'get_mfa_status') as mock_status:
|
||||
mock_status.return_value = {"enabled": True, "setup_at": "2024-01-01T00:00:00"}
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup',
|
||||
headers=auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert data["error"] == "MFA is already enabled"
|
||||
|
||||
def test_mfa_setup_complete_success(self, test_client, setup_account, auth_header):
|
||||
"""Test successful MFA setup completion."""
|
||||
with patch.object(MFAService, 'setup_mfa') as mock_setup:
|
||||
mock_setup.return_value = {
|
||||
"message": "MFA has been successfully enabled",
|
||||
"backup_codes": ["CODE1", "CODE2", "CODE3", "CODE4", "CODE5", "CODE6", "CODE7", "CODE8"],
|
||||
"setup_at": "2024-01-01T00:00:00"
|
||||
}
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup/complete',
|
||||
headers=auth_header,
|
||||
json={"totp_token": "123456"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["message"] == "MFA has been successfully enabled"
|
||||
assert len(data["backup_codes"]) == 8
|
||||
mock_setup.assert_called_once_with(setup_account, "123456")
|
||||
|
||||
def test_mfa_setup_complete_missing_token(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA setup completion with missing token."""
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup/complete',
|
||||
headers=auth_header,
|
||||
json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert "totp_token is required" in data["error"]
|
||||
|
||||
def test_mfa_setup_complete_invalid_token(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA setup completion with invalid token."""
|
||||
with patch.object(MFAService, 'setup_mfa') as mock_setup:
|
||||
mock_setup.side_effect = ValueError("Invalid TOTP token")
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/setup/complete',
|
||||
headers=auth_header,
|
||||
json={"totp_token": "999999"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert "Invalid TOTP token" in data["error"]
|
||||
|
||||
def test_mfa_disable_success(self, test_client, setup_account, auth_header):
|
||||
"""Test successful MFA disable."""
|
||||
with patch.object(MFAService, 'disable_mfa') as mock_disable:
|
||||
mock_disable.return_value = {"message": "MFA has been disabled"}
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/disable',
|
||||
headers=auth_header,
|
||||
json={"password": "test_password"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["message"] == "MFA has been disabled"
|
||||
mock_disable.assert_called_once_with(setup_account, "test_password")
|
||||
|
||||
def test_mfa_disable_wrong_password(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA disable with wrong password."""
|
||||
with patch.object(MFAService, 'disable_mfa') as mock_disable:
|
||||
mock_disable.side_effect = ValueError("Invalid password")
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/disable',
|
||||
headers=auth_header,
|
||||
json={"password": "wrong_password"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert "Invalid password" in data["error"]
|
||||
|
||||
def test_mfa_disable_not_enabled(self, test_client, setup_account, auth_header):
|
||||
"""Test MFA disable when not enabled."""
|
||||
with patch.object(MFAService, 'disable_mfa') as mock_disable:
|
||||
mock_disable.side_effect = ValueError("MFA is not enabled")
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/account/mfa/disable',
|
||||
headers=auth_header,
|
||||
json={"password": "test_password"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json
|
||||
assert "MFA is not enabled" in data["error"]
|
||||
|
||||
def test_mfa_verify_success(self, test_client):
|
||||
"""Test successful MFA verification during login."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
with patch.object(MFAService, 'is_mfa_required') as mock_required:
|
||||
with patch.object(MFAService, 'authenticate_with_mfa') as mock_verify:
|
||||
# Mock user exists
|
||||
from models.account import Account
|
||||
mock_account = Account(
|
||||
id="test-id",
|
||||
email="test@example.com",
|
||||
name="Test User"
|
||||
)
|
||||
mock_auth.return_value = mock_account
|
||||
mock_required.return_value = True
|
||||
mock_verify.return_value = True
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/mfa/verify',
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"mfa_code": "123456",
|
||||
"remember_me": True
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "success"
|
||||
|
||||
def test_mfa_verify_invalid_token(self, test_client):
|
||||
"""Test MFA verification with invalid token."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
with patch.object(MFAService, 'is_mfa_required') as mock_required:
|
||||
with patch.object(MFAService, 'authenticate_with_mfa') as mock_verify:
|
||||
# Mock user exists
|
||||
from models.account import Account
|
||||
mock_account = Account(
|
||||
id="test-id",
|
||||
email="test@example.com",
|
||||
name="Test User"
|
||||
)
|
||||
mock_auth.return_value = mock_account
|
||||
mock_required.return_value = True
|
||||
mock_verify.return_value = False
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/mfa/verify',
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"mfa_code": "999999",
|
||||
"remember_me": True
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "fail"
|
||||
assert data["code"] == "mfa_token_invalid"
|
||||
|
||||
def test_mfa_verify_not_required(self, test_client):
|
||||
"""Test MFA verification when MFA is not required."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
with patch.object(MFAService, 'is_mfa_required') as mock_required:
|
||||
# Mock user exists
|
||||
from models.account import Account
|
||||
mock_account = Account(
|
||||
id="test-id",
|
||||
email="test@example.com",
|
||||
name="Test User"
|
||||
)
|
||||
mock_auth.return_value = mock_account
|
||||
mock_required.return_value = False
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/mfa/verify',
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"mfa_code": "123456",
|
||||
"remember_me": True
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "fail"
|
||||
assert data["code"] == "mfa_not_required"
|
||||
|
||||
def test_mfa_verify_account_not_found(self, test_client):
|
||||
"""Test MFA verification with non-existent account."""
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
mock_auth.return_value = None
|
||||
|
||||
response = test_client.post(
|
||||
'/console/api/mfa/verify',
|
||||
json={
|
||||
"email": "nonexistent@example.com",
|
||||
"mfa_code": "123456",
|
||||
"remember_me": True
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["result"] == "fail"
|
||||
assert data["code"] == "mfa_verify_failed"
|
||||
@ -0,0 +1,137 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
|
||||
from models.account import Account, AccountStatus
|
||||
from extensions.ext_database import db
|
||||
from services.mfa_service import MFAService
|
||||
|
||||
|
||||
class TestMFAEndpointsFixed:
|
||||
"""Test MFA endpoints using proper Flask test client approach."""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_flask_app(self, app):
|
||||
"""Set up Flask app with proper login manager."""
|
||||
# This fixture uses the app from conftest which already has LoginManager configured
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def test_account(self, setup_flask_app):
|
||||
"""Create a test account."""
|
||||
with setup_flask_app.app_context():
|
||||
account = Account(
|
||||
id="test-account-id",
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
password="hashed_password",
|
||||
status=AccountStatus.ACTIVE.value,
|
||||
password_salt="salt"
|
||||
)
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
yield account
|
||||
# Cleanup
|
||||
db.session.delete(account)
|
||||
db.session.commit()
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(self, setup_flask_app, test_account):
|
||||
"""Get authentication headers by simulating login."""
|
||||
with setup_flask_app.test_client() as client:
|
||||
# Mock the authentication to return our test account
|
||||
with patch('services.account_service.AccountService.authenticate') as mock_auth:
|
||||
mock_auth.return_value = test_account
|
||||
|
||||
# Perform login to get token
|
||||
response = client.post('/console/api/login', json={
|
||||
'email': test_account.email,
|
||||
'password': 'test_password'
|
||||
})
|
||||
|
||||
# Extract token from response
|
||||
token = response.json.get('data', {}).get('access_token')
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
def test_mfa_status_success(self, setup_flask_app, test_account, auth_headers):
|
||||
"""Test successful MFA status check."""
|
||||
with setup_flask_app.test_client() as client:
|
||||
with setup_flask_app.app_context():
|
||||
# Mock the MFA service
|
||||
with patch.object(MFAService, 'get_mfa_status') as mock_status:
|
||||
mock_status.return_value = {"enabled": False, "setup_at": None}
|
||||
|
||||
response = client.get(
|
||||
'/console/api/account/mfa/status',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["enabled"] is False
|
||||
assert data["setup_at"] is None
|
||||
|
||||
def test_mfa_setup_init_success(self, setup_flask_app, test_account, auth_headers):
|
||||
"""Test successful MFA setup initialization."""
|
||||
with setup_flask_app.test_client() as client:
|
||||
with setup_flask_app.app_context():
|
||||
# Mock MFA service methods
|
||||
with patch.object(MFAService, 'get_mfa_status') as mock_status:
|
||||
with patch.object(MFAService, 'generate_mfa_setup_data') as mock_generate:
|
||||
mock_status.return_value = {"enabled": False}
|
||||
mock_generate.return_value = {
|
||||
"secret": "TEST_SECRET",
|
||||
"qr_code": "data:image/png;base64,test"
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
'/console/api/account/mfa/setup',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["secret"] == "TEST_SECRET"
|
||||
assert data["qr_code"] == "data:image/png;base64,test"
|
||||
|
||||
def test_mfa_setup_complete_success(self, setup_flask_app, test_account, auth_headers):
|
||||
"""Test successful MFA setup completion."""
|
||||
with setup_flask_app.test_client() as client:
|
||||
with setup_flask_app.app_context():
|
||||
# Mock MFA service
|
||||
with patch.object(MFAService, 'setup_mfa') as mock_setup:
|
||||
mock_setup.return_value = {
|
||||
"message": "MFA has been successfully enabled",
|
||||
"backup_codes": ["CODE1", "CODE2", "CODE3", "CODE4", "CODE5", "CODE6", "CODE7", "CODE8"],
|
||||
"setup_at": "2024-01-01T00:00:00"
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
'/console/api/account/mfa/setup/complete',
|
||||
headers=auth_headers,
|
||||
json={"totp_token": "123456"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["message"] == "MFA has been successfully enabled"
|
||||
assert len(data["backup_codes"]) == 8
|
||||
|
||||
def test_mfa_disable_success(self, setup_flask_app, test_account, auth_headers):
|
||||
"""Test successful MFA disable."""
|
||||
with setup_flask_app.test_client() as client:
|
||||
with setup_flask_app.app_context():
|
||||
# Mock MFA service
|
||||
with patch.object(MFAService, 'disable_mfa') as mock_disable:
|
||||
mock_disable.return_value = {"message": "MFA has been disabled"}
|
||||
|
||||
response = client.post(
|
||||
'/console/api/account/mfa/disable',
|
||||
headers=auth_headers,
|
||||
json={"password": "test_password"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["message"] == "MFA has been disabled"
|
||||
@ -0,0 +1,52 @@
|
||||
"""Minimal unit tests for MFA controllers to verify they're importable and basic structure."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from controllers.console.auth.mfa import (
|
||||
MFASetupInitApi,
|
||||
MFASetupCompleteApi,
|
||||
MFADisableApi,
|
||||
MFAStatusApi,
|
||||
MFAVerifyApi
|
||||
)
|
||||
|
||||
|
||||
class TestMFAControllersMinimal:
|
||||
"""Minimal tests to verify MFA controllers are properly defined."""
|
||||
|
||||
def test_mfa_controllers_exist(self):
|
||||
"""Test that all MFA controller classes exist."""
|
||||
assert MFASetupInitApi is not None
|
||||
assert MFASetupCompleteApi is not None
|
||||
assert MFADisableApi is not None
|
||||
assert MFAStatusApi is not None
|
||||
assert MFAVerifyApi is not None
|
||||
|
||||
def test_mfa_controllers_have_methods(self):
|
||||
"""Test that MFA controllers have expected methods."""
|
||||
# Setup Init has both GET and POST
|
||||
assert hasattr(MFASetupInitApi, 'get')
|
||||
assert hasattr(MFASetupInitApi, 'post')
|
||||
|
||||
# Setup Complete has POST
|
||||
assert hasattr(MFASetupCompleteApi, 'post')
|
||||
|
||||
# Disable has POST
|
||||
assert hasattr(MFADisableApi, 'post')
|
||||
|
||||
# Status has GET
|
||||
assert hasattr(MFAStatusApi, 'get')
|
||||
|
||||
# Verify has POST
|
||||
assert hasattr(MFAVerifyApi, 'post')
|
||||
|
||||
def test_mfa_controller_inheritance(self):
|
||||
"""Test that MFA controllers inherit from Resource."""
|
||||
from flask_restful import Resource
|
||||
|
||||
assert issubclass(MFASetupInitApi, Resource)
|
||||
assert issubclass(MFASetupCompleteApi, Resource)
|
||||
assert issubclass(MFADisableApi, Resource)
|
||||
assert issubclass(MFAStatusApi, Resource)
|
||||
assert issubclass(MFAVerifyApi, Resource)
|
||||
@ -0,0 +1,370 @@
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from models.account import Account, AccountMFASettings
|
||||
from services.mfa_service import MFAService
|
||||
|
||||
|
||||
class TestMFAService(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.account = Mock(spec=Account)
|
||||
self.account.id = "test-account-id"
|
||||
self.account.email = "test@example.com"
|
||||
self.account.password = "hashed_password"
|
||||
self.account.password_salt = "salt"
|
||||
|
||||
self.mfa_settings = Mock(spec=AccountMFASettings)
|
||||
self.mfa_settings.account_id = self.account.id
|
||||
self.mfa_settings.enabled = False
|
||||
self.mfa_settings.secret = None
|
||||
self.mfa_settings.backup_codes = None
|
||||
self.mfa_settings.setup_at = None
|
||||
|
||||
def test_generate_secret(self):
|
||||
"""Test secret generation."""
|
||||
secret = MFAService.generate_secret()
|
||||
self.assertIsInstance(secret, str)
|
||||
self.assertEqual(len(secret), 32) # Base32 length
|
||||
|
||||
def test_generate_backup_codes(self):
|
||||
"""Test backup codes generation."""
|
||||
codes = MFAService.generate_backup_codes()
|
||||
self.assertEqual(len(codes), 8)
|
||||
for code in codes:
|
||||
self.assertIsInstance(code, str)
|
||||
self.assertEqual(len(code), 8) # 4 hex bytes = 8 chars
|
||||
|
||||
@patch('pyotp.TOTP')
|
||||
def test_verify_totp_valid(self, mock_totp_class):
|
||||
"""Test TOTP verification with valid token."""
|
||||
mock_totp = Mock()
|
||||
mock_totp.verify.return_value = True
|
||||
mock_totp_class.return_value = mock_totp
|
||||
|
||||
result = MFAService.verify_totp("test_secret", "123456")
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_totp.verify.assert_called_once_with("123456", valid_window=1)
|
||||
|
||||
@patch('pyotp.TOTP')
|
||||
def test_verify_totp_invalid(self, mock_totp_class):
|
||||
"""Test TOTP verification with invalid token."""
|
||||
mock_totp = Mock()
|
||||
mock_totp.verify.return_value = False
|
||||
mock_totp_class.return_value = mock_totp
|
||||
|
||||
result = MFAService.verify_totp("test_secret", "invalid")
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_verify_totp_no_secret(self):
|
||||
"""Test TOTP verification with no secret."""
|
||||
result = MFAService.verify_totp(None, "123456")
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_get_or_create_mfa_settings_existing(self, mock_session):
|
||||
"""Test getting existing MFA settings."""
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = self.mfa_settings
|
||||
|
||||
result = MFAService.get_or_create_mfa_settings(self.account)
|
||||
|
||||
self.assertEqual(result, self.mfa_settings)
|
||||
mock_session.query.assert_called_once()
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_get_or_create_mfa_settings_new(self, mock_session):
|
||||
"""Test creating new MFA settings."""
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
result = MFAService.get_or_create_mfa_settings(self.account)
|
||||
|
||||
# Check that new settings were created
|
||||
self.assertIsInstance(result, AccountMFASettings)
|
||||
self.assertEqual(result.account_id, self.account.id)
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_verify_backup_code_valid(self, mock_session):
|
||||
"""Test backup code verification with valid code."""
|
||||
self.mfa_settings.backup_codes = json.dumps(["ABCD1234", "EFGH5678"])
|
||||
|
||||
result = MFAService.verify_backup_code(self.mfa_settings, "abcd1234") # Test case insensitive
|
||||
|
||||
self.assertTrue(result)
|
||||
# Check that the code was removed
|
||||
remaining_codes = json.loads(self.mfa_settings.backup_codes)
|
||||
self.assertNotIn("ABCD1234", remaining_codes)
|
||||
self.assertIn("EFGH5678", remaining_codes)
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_verify_backup_code_invalid(self):
|
||||
"""Test backup code verification with invalid code."""
|
||||
self.mfa_settings.backup_codes = json.dumps(["ABCD1234", "EFGH5678"])
|
||||
|
||||
result = MFAService.verify_backup_code(self.mfa_settings, "INVALID")
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_verify_backup_code_no_codes(self):
|
||||
"""Test backup code verification with no backup codes."""
|
||||
self.mfa_settings.backup_codes = None
|
||||
|
||||
result = MFAService.verify_backup_code(self.mfa_settings, "ABCD1234")
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch('services.mfa_service.MFAService.get_or_create_mfa_settings')
|
||||
@patch('services.mfa_service.MFAService.verify_totp')
|
||||
@patch('services.mfa_service.MFAService.generate_backup_codes')
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_setup_mfa_success(self, mock_session, mock_gen_codes, mock_verify, mock_get_settings):
|
||||
"""Test successful MFA setup."""
|
||||
mock_get_settings.return_value = self.mfa_settings
|
||||
self.mfa_settings.secret = "test_secret"
|
||||
mock_verify.return_value = True
|
||||
mock_gen_codes.return_value = ["CODE1", "CODE2"]
|
||||
|
||||
result = MFAService.setup_mfa(self.account, "123456")
|
||||
|
||||
self.assertTrue(self.mfa_settings.enabled)
|
||||
self.assertEqual(self.mfa_settings.backup_codes, json.dumps(["CODE1", "CODE2"]))
|
||||
self.assertIsNotNone(self.mfa_settings.setup_at)
|
||||
self.assertEqual(result["backup_codes"], ["CODE1", "CODE2"])
|
||||
|
||||
@patch('services.mfa_service.MFAService.get_or_create_mfa_settings')
|
||||
def test_setup_mfa_already_enabled(self, mock_get_settings):
|
||||
"""Test MFA setup when already enabled."""
|
||||
self.mfa_settings.enabled = True
|
||||
mock_get_settings.return_value = self.mfa_settings
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
MFAService.setup_mfa(self.account, "123456")
|
||||
|
||||
self.assertIn("already enabled", str(context.exception))
|
||||
|
||||
@patch('services.mfa_service.MFAService.get_or_create_mfa_settings')
|
||||
def test_setup_mfa_no_secret(self, mock_get_settings):
|
||||
"""Test MFA setup without secret."""
|
||||
mock_get_settings.return_value = self.mfa_settings
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
MFAService.setup_mfa(self.account, "123456")
|
||||
|
||||
self.assertIn("secret not generated", str(context.exception))
|
||||
|
||||
@patch('services.mfa_service.MFAService.get_or_create_mfa_settings')
|
||||
@patch('services.mfa_service.MFAService.verify_totp')
|
||||
def test_setup_mfa_invalid_token(self, mock_verify, mock_get_settings):
|
||||
"""Test MFA setup with invalid TOTP token."""
|
||||
mock_get_settings.return_value = self.mfa_settings
|
||||
self.mfa_settings.secret = "test_secret"
|
||||
mock_verify.return_value = False
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
MFAService.setup_mfa(self.account, "invalid")
|
||||
|
||||
self.assertIn("Invalid TOTP token", str(context.exception))
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_is_mfa_required_enabled(self, mock_session):
|
||||
"""Test MFA requirement check when enabled."""
|
||||
self.mfa_settings.enabled = True
|
||||
self.mfa_settings.secret = "test_secret"
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = self.mfa_settings
|
||||
|
||||
result = MFAService.is_mfa_required(self.account)
|
||||
|
||||
self.assertTrue(result)
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_is_mfa_required_disabled(self, mock_session):
|
||||
"""Test MFA requirement check when disabled."""
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = self.mfa_settings
|
||||
|
||||
result = MFAService.is_mfa_required(self.account)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_is_mfa_required_no_settings(self, mock_session):
|
||||
"""Test MFA requirement check with no settings."""
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
result = MFAService.is_mfa_required(self.account)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
@patch('services.mfa_service.MFAService.verify_totp')
|
||||
@patch('services.mfa_service.MFAService.verify_backup_code')
|
||||
def test_authenticate_with_mfa_totp_success(self, mock_verify_backup, mock_verify_totp, mock_session):
|
||||
"""Test MFA authentication with valid TOTP."""
|
||||
self.mfa_settings.enabled = True
|
||||
self.mfa_settings.secret = "test_secret"
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = self.mfa_settings
|
||||
mock_verify_totp.return_value = True
|
||||
|
||||
result = MFAService.authenticate_with_mfa(self.account, "123456")
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_verify_totp.assert_called_once_with("test_secret", "123456")
|
||||
mock_verify_backup.assert_not_called()
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
@patch('services.mfa_service.MFAService.verify_totp')
|
||||
@patch('services.mfa_service.MFAService.verify_backup_code')
|
||||
def test_authenticate_with_mfa_backup_success(self, mock_verify_backup, mock_verify_totp, mock_session):
|
||||
"""Test MFA authentication with valid backup code."""
|
||||
self.mfa_settings.enabled = True
|
||||
self.mfa_settings.secret = "test_secret"
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = self.mfa_settings
|
||||
mock_verify_totp.return_value = False
|
||||
mock_verify_backup.return_value = True
|
||||
|
||||
result = MFAService.authenticate_with_mfa(self.account, "BACKUP123")
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_verify_totp.assert_called_once_with("test_secret", "BACKUP123")
|
||||
mock_verify_backup.assert_called_once_with(self.mfa_settings, "BACKUP123")
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_authenticate_with_mfa_disabled(self, mock_session):
|
||||
"""Test MFA authentication when disabled."""
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = self.mfa_settings
|
||||
|
||||
result = MFAService.authenticate_with_mfa(self.account, "123456")
|
||||
|
||||
self.assertTrue(result)
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_get_mfa_status_enabled(self, mock_session):
|
||||
"""Test getting MFA status when enabled."""
|
||||
self.mfa_settings.enabled = True
|
||||
self.mfa_settings.setup_at = datetime(2025, 1, 1, 12, 0, 0)
|
||||
self.mfa_settings.backup_codes = json.dumps(["CODE1", "CODE2"])
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = self.mfa_settings
|
||||
|
||||
result = MFAService.get_mfa_status(self.account)
|
||||
|
||||
expected = {
|
||||
"enabled": True,
|
||||
"setup_at": "2025-01-01T12:00:00",
|
||||
"has_backup_codes": True
|
||||
}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_get_mfa_status_no_settings(self, mock_session):
|
||||
"""Test getting MFA status with no settings."""
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
result = MFAService.get_mfa_status(self.account)
|
||||
|
||||
expected = {
|
||||
"enabled": False,
|
||||
"setup_at": None,
|
||||
"has_backup_codes": False
|
||||
}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
@patch('qrcode.QRCode')
|
||||
@patch('pyotp.TOTP')
|
||||
def test_generate_qr_code(self, mock_totp_class, mock_qr_class):
|
||||
"""Test QR code generation."""
|
||||
# Mock TOTP
|
||||
mock_totp = Mock()
|
||||
mock_totp.provisioning_uri.return_value = "otpauth://totp/test"
|
||||
mock_totp_class.return_value = mock_totp
|
||||
|
||||
# Mock QR code
|
||||
mock_qr = Mock()
|
||||
mock_img = Mock()
|
||||
mock_qr.make_image.return_value = mock_img
|
||||
mock_qr_class.return_value = mock_qr
|
||||
|
||||
# Mock image buffer
|
||||
with patch('io.BytesIO') as mock_buffer, \
|
||||
patch('base64.b64encode') as mock_b64:
|
||||
mock_b64.return_value.decode.return_value = "base64data"
|
||||
|
||||
result = MFAService.generate_qr_code(self.account, "test_secret")
|
||||
|
||||
self.assertEqual(result, "data:image/png;base64,base64data")
|
||||
mock_totp.provisioning_uri.assert_called_once_with(
|
||||
name=self.account.email,
|
||||
issuer_name="Dify"
|
||||
)
|
||||
|
||||
@patch('libs.password.compare_password')
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_disable_mfa_success(self, mock_session, mock_compare_password):
|
||||
"""Test successful MFA disable."""
|
||||
mock_compare_password.return_value = True
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = self.mfa_settings
|
||||
|
||||
result = MFAService.disable_mfa(self.account, "correct_password")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertFalse(self.mfa_settings.enabled)
|
||||
self.assertIsNone(self.mfa_settings.secret)
|
||||
self.assertIsNone(self.mfa_settings.backup_codes)
|
||||
self.assertIsNone(self.mfa_settings.setup_at)
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@patch('libs.password.compare_password')
|
||||
def test_disable_mfa_wrong_password(self, mock_compare_password):
|
||||
"""Test MFA disable with wrong password."""
|
||||
mock_compare_password.return_value = False
|
||||
|
||||
result = MFAService.disable_mfa(self.account, "wrong_password")
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch('libs.password.compare_password')
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_disable_mfa_no_settings(self, mock_session, mock_compare_password):
|
||||
"""Test MFA disable when no settings exist."""
|
||||
mock_compare_password.return_value = True
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
result = MFAService.disable_mfa(self.account, "correct_password")
|
||||
|
||||
self.assertTrue(result) # Already disabled
|
||||
|
||||
@patch('services.mfa_service.MFAService.get_or_create_mfa_settings')
|
||||
@patch('services.mfa_service.MFAService.generate_secret')
|
||||
@patch('services.mfa_service.MFAService.generate_qr_code')
|
||||
@patch('services.mfa_service.db.session')
|
||||
def test_generate_mfa_setup_data_success(self, mock_session, mock_gen_qr, mock_gen_secret, mock_get_settings):
|
||||
"""Test successful MFA setup data generation."""
|
||||
mock_get_settings.return_value = self.mfa_settings
|
||||
mock_gen_secret.return_value = "NEWSECRET123"
|
||||
mock_gen_qr.return_value = "data:image/png;base64,qrdata"
|
||||
|
||||
result = MFAService.generate_mfa_setup_data(self.account)
|
||||
|
||||
self.assertEqual(result["secret"], "NEWSECRET123")
|
||||
self.assertEqual(result["qr_code"], "data:image/png;base64,qrdata")
|
||||
self.assertEqual(self.mfa_settings.secret, "NEWSECRET123")
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@patch('services.mfa_service.MFAService.get_or_create_mfa_settings')
|
||||
def test_generate_mfa_setup_data_already_enabled(self, mock_get_settings):
|
||||
"""Test MFA setup data generation when already enabled."""
|
||||
self.mfa_settings.enabled = True
|
||||
mock_get_settings.return_value = self.mfa_settings
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
MFAService.generate_mfa_setup_data(self.account)
|
||||
|
||||
self.assertIn("already enabled", str(context.exception))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -0,0 +1 @@
|
||||
store-dir=../.pnpm-store
|
||||
@ -0,0 +1,346 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock the service base to avoid ky import issues
|
||||
jest.mock('@/service/base', () => ({
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
del: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock the translation hook
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
import MFAPage from './mfa-page'
|
||||
|
||||
// Mock the Toast component
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Modal component
|
||||
jest.mock('@/app/components/base/modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ isOpen, onClose, children }: any) =>
|
||||
isOpen ? <div data-testid="modal">{children}</div> : null,
|
||||
}))
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
// Create a test wrapper component
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('MFAPage Component', () => {
|
||||
let wrapper: ReturnType<typeof createWrapper>
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
test('renders loading state initially', () => {
|
||||
const { get } = require('@/service/base')
|
||||
get.mockImplementation(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders enable button when MFA is disabled', async () => {
|
||||
const { get } = require('@/service/base')
|
||||
get.mockResolvedValue({ enabled: false })
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('mfa.enable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('renders disable button when MFA is enabled', async () => {
|
||||
const { get } = require('@/service/base')
|
||||
get.mockResolvedValue({
|
||||
enabled: true,
|
||||
setup_at: '2025-01-01T12:00:00'
|
||||
})
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('mfa.disable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('opens setup modal when enable button is clicked', async () => {
|
||||
const { get, post } = require('@/service/base')
|
||||
get.mockResolvedValue({ enabled: false })
|
||||
post.mockResolvedValue({
|
||||
secret: 'TEST_SECRET',
|
||||
qr_code: 'data:image/png;base64,test'
|
||||
})
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('mfa.enable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('mfa.enable'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('mfa.setupTitle')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('completes MFA setup successfully', async () => {
|
||||
const { get, post } = require('@/service/base')
|
||||
const Toast = require('@/app/components/base/toast').default
|
||||
|
||||
get.mockResolvedValue({ enabled: false })
|
||||
post.mockImplementation((url) => {
|
||||
if (url.includes('/setup') && !url.includes('/complete')) {
|
||||
return Promise.resolve({
|
||||
secret: 'TEST_SECRET',
|
||||
qr_code: 'data:image/png;base64,test'
|
||||
})
|
||||
} else if (url.includes('/setup/complete')) {
|
||||
return Promise.resolve({
|
||||
message: 'MFA setup successfully',
|
||||
backup_codes: ['CODE1', 'CODE2', 'CODE3', 'CODE4', 'CODE5', 'CODE6', 'CODE7', 'CODE8'],
|
||||
setup_at: '2025-01-01T12:00:00'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
// Click enable
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('mfa.enable'))
|
||||
})
|
||||
|
||||
// Wait for QR code to be displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('MFA QR Code')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Enter TOTP code
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
// Simulate entering '123456'
|
||||
'123456'.split('').forEach((digit, index) => {
|
||||
fireEvent.change(inputs[index], { target: { value: digit } })
|
||||
})
|
||||
|
||||
// Click verify button
|
||||
const verifyButton = screen.getByRole('button', { name: /verify|mfa.verify/i })
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'mfa.setupSuccess'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('shows error when setup fails', async () => {
|
||||
const { get, post } = require('@/service/base')
|
||||
const Toast = require('@/app/components/base/toast').default
|
||||
|
||||
get.mockResolvedValue({ enabled: false })
|
||||
post.mockImplementation((url) => {
|
||||
if (url.includes('/setup') && !url.includes('/complete')) {
|
||||
return Promise.resolve({
|
||||
secret: 'TEST_SECRET',
|
||||
qr_code: 'data:image/png;base64,test'
|
||||
})
|
||||
} else if (url.includes('/setup/complete')) {
|
||||
return Promise.reject(new Error('Invalid TOTP token'))
|
||||
}
|
||||
})
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
// Click enable
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('mfa.enable'))
|
||||
})
|
||||
|
||||
// Wait for QR code
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('MFA QR Code')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Enter wrong TOTP code
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
'000000'.split('').forEach((digit, index) => {
|
||||
fireEvent.change(inputs[index], { target: { value: digit } })
|
||||
})
|
||||
|
||||
// Click verify
|
||||
const verifyButton = screen.getByRole('button', { name: /verify|mfa.verify/i })
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Invalid TOTP token'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('disables MFA successfully', async () => {
|
||||
const { get, post } = require('@/service/base')
|
||||
const Toast = require('@/app/components/base/toast').default
|
||||
|
||||
get.mockResolvedValue({
|
||||
enabled: true,
|
||||
setup_at: '2025-01-01T12:00:00'
|
||||
})
|
||||
post.mockImplementation((url) => {
|
||||
if (url.includes('/disable')) {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
message: 'MFA disabled successfully'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
// Click disable
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('mfa.disable'))
|
||||
})
|
||||
|
||||
// Modal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Enter password
|
||||
const passwordInput = screen.getByPlaceholderText('mfa.enterYourPassword')
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
|
||||
// Click confirm
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'mfa.disabledSuccessfully'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('shows error when disable fails with wrong password', async () => {
|
||||
const { get, post } = require('@/service/base')
|
||||
const Toast = require('@/app/components/base/toast').default
|
||||
|
||||
get.mockResolvedValue({
|
||||
enabled: true,
|
||||
setup_at: '2025-01-01T12:00:00'
|
||||
})
|
||||
post.mockImplementation((url) => {
|
||||
if (url.includes('/disable')) {
|
||||
return Promise.reject(new Error('Invalid password'))
|
||||
}
|
||||
})
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
// Click disable
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('mfa.disable'))
|
||||
})
|
||||
|
||||
// Enter wrong password
|
||||
const passwordInput = screen.getByPlaceholderText('mfa.enterYourPassword')
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } })
|
||||
|
||||
// Click confirm
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Invalid password'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('handles backup codes display correctly', async () => {
|
||||
const { get, post } = require('@/service/base')
|
||||
|
||||
get.mockResolvedValue({ enabled: false })
|
||||
post.mockImplementation((url) => {
|
||||
if (url.includes('/setup') && !url.includes('/complete')) {
|
||||
return Promise.resolve({
|
||||
secret: 'TEST_SECRET',
|
||||
qr_code: 'data:image/png;base64,test'
|
||||
})
|
||||
} else if (url.includes('/setup/complete')) {
|
||||
return Promise.resolve({
|
||||
message: 'MFA setup successfully',
|
||||
backup_codes: ['ABCD1234', 'EFGH5678', 'IJKL9012', 'MNOP3456', 'QRST7890', 'UVWX1234', 'YZAB5678', 'CDEF9012'],
|
||||
setup_at: '2025-01-01T12:00:00'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
render(<MFAPage />, { wrapper })
|
||||
|
||||
// Setup MFA
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('mfa.enable'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('MFA QR Code')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Enter TOTP code
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
'123456'.split('').forEach((digit, index) => {
|
||||
fireEvent.change(inputs[index], { target: { value: digit } })
|
||||
})
|
||||
|
||||
// Verify
|
||||
const verifyButton = screen.getByRole('button', { name: /verify|mfa.verify/i })
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
// Check backup codes are displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('mfa.backupCodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('ABCD1234')).toBeInTheDocument()
|
||||
expect(screen.getByText('EFGH5678')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,306 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiShieldKeyholeLine, RiCheckboxCircleFill, RiLoader2Line } from '@remixicon/react'
|
||||
import Toast from '../../base/toast'
|
||||
import Button from '../../base/button'
|
||||
import Input from '../../base/input'
|
||||
import Modal from '../../base/modal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { get, post } from '@/service/base'
|
||||
|
||||
// API service functions
|
||||
const mfaService = {
|
||||
getStatus: async () => {
|
||||
return get<{
|
||||
enabled: boolean
|
||||
setup_at: string | null
|
||||
}>('/account/mfa/status')
|
||||
},
|
||||
|
||||
initSetup: async () => {
|
||||
return post<{
|
||||
secret: string
|
||||
qr_code: string
|
||||
}>('/account/mfa/setup', { body: {} })
|
||||
},
|
||||
|
||||
completeSetup: async (totpToken: string, password: string) => {
|
||||
return post<{
|
||||
message: string
|
||||
backup_codes: string[]
|
||||
setup_at: string
|
||||
}>('/account/mfa/setup/complete', {
|
||||
body: { totp_token: totpToken }
|
||||
})
|
||||
},
|
||||
|
||||
disable: async (password: string) => {
|
||||
return post('/account/mfa/disable', {
|
||||
body: { password }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default function MFAPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// State
|
||||
const [isSetupModalOpen, setIsSetupModalOpen] = useState(false)
|
||||
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false)
|
||||
const [setupStep, setSetupStep] = useState<'qr' | 'verify' | 'backup'>('qr')
|
||||
const [totpToken, setTotpToken] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [qrData, setQrData] = useState<{ secret: string; qr_code: string } | null>(null)
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([])
|
||||
|
||||
// Query MFA status
|
||||
const { data: mfaStatus, isLoading } = useQuery({
|
||||
queryKey: ['mfa-status'],
|
||||
queryFn: mfaService.getStatus,
|
||||
})
|
||||
|
||||
|
||||
// Mutations
|
||||
const initSetupMutation = useMutation({
|
||||
mutationFn: mfaService.initSetup,
|
||||
onSuccess: (data) => {
|
||||
setQrData(data)
|
||||
setIsSetupModalOpen(true)
|
||||
setSetupStep('qr')
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('common.somethingWentWrong') })
|
||||
},
|
||||
})
|
||||
|
||||
const completeSetupMutation = useMutation({
|
||||
mutationFn: ({ totpToken, password }: { totpToken: string; password: string }) =>
|
||||
mfaService.completeSetup(totpToken, password),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backup_codes)
|
||||
setSetupStep('backup')
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('mfa.invalidToken') })
|
||||
},
|
||||
})
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: mfaService.disable,
|
||||
onSuccess: () => {
|
||||
setIsDisableModalOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
|
||||
Toast.notify({ type: 'success', message: t('mfa.disabledSuccess') })
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('mfa.invalidPassword') })
|
||||
},
|
||||
})
|
||||
|
||||
const handleSetupStart = () => {
|
||||
initSetupMutation.mutate()
|
||||
}
|
||||
|
||||
const handleVerifyToken = () => {
|
||||
if (totpToken.length !== 6) {
|
||||
Toast.notify({ type: 'error', message: t('mfa.tokenLength') })
|
||||
return
|
||||
}
|
||||
completeSetupMutation.mutate({ totpToken, password: '' })
|
||||
}
|
||||
|
||||
const handleDisable = () => {
|
||||
disableMutation.mutate(password)
|
||||
}
|
||||
|
||||
const handleCopyBackupCodes = () => {
|
||||
const codesText = backupCodes.join('\n')
|
||||
navigator.clipboard.writeText(codesText)
|
||||
Toast.notify({ type: 'success', message: t('mfa.copied') })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<RiLoader2Line className="animate-spin w-6 h-6 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="mb-2 rounded-xl bg-background-section p-6">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg-alt shadow-lg backdrop-blur-sm">
|
||||
<RiShieldKeyholeLine className="h-5 w-5 text-text-accent" />
|
||||
</div>
|
||||
<div className="system-sm-medium mb-1 text-text-secondary">{t('mfa.description')}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('mfa.securityTip')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-components-icon-bg-blue-ghost">
|
||||
<RiShieldKeyholeLine className="h-5 w-5 text-components-icon-text-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-primary">{t('mfa.authenticatorApp')}</div>
|
||||
<div className="system-xs-regular mt-0.5 text-text-tertiary">{t('mfa.authenticatorDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{mfaStatus?.enabled && (
|
||||
<RiCheckboxCircleFill className="h-5 w-5 text-text-success" />
|
||||
)}
|
||||
<Button
|
||||
variant={mfaStatus?.enabled ? 'secondary' : 'primary'}
|
||||
onClick={() => {
|
||||
if (mfaStatus?.enabled) {
|
||||
setIsDisableModalOpen(true);
|
||||
} else {
|
||||
handleSetupStart();
|
||||
}
|
||||
}}
|
||||
loading={initSetupMutation.isPending}
|
||||
>
|
||||
{mfaStatus?.enabled ? t('mfa.disable') : t('mfa.enable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mfaStatus?.enabled && mfaStatus?.setup_at && (
|
||||
<div className="mt-3 system-xs-regular text-text-tertiary">
|
||||
{t('mfa.enabledAt', { date: new Date(mfaStatus.setup_at).toLocaleDateString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Setup Modal */}
|
||||
<Modal
|
||||
isShow={isSetupModalOpen}
|
||||
onClose={() => setIsSetupModalOpen(false)}
|
||||
title={t('mfa.setupTitle')}
|
||||
className="!max-w-md"
|
||||
>
|
||||
{setupStep === 'qr' && qrData && (
|
||||
<div className="space-y-4">
|
||||
<p className="system-sm-regular text-text-secondary">{t('mfa.scanQRCode')}</p>
|
||||
<div className="flex justify-center">
|
||||
<img src={qrData.qr_code} alt="MFA QR Code" className="w-[200px] h-[200px]" />
|
||||
</div>
|
||||
<div className="p-3 bg-components-panel-bg-blur rounded-lg border border-components-panel-border">
|
||||
<p className="system-xs-regular text-text-tertiary mb-1">{t('mfa.secretKey')}</p>
|
||||
<code className="system-xs-regular font-mono break-all text-text-secondary">{qrData.secret}</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => setSetupStep('verify')}
|
||||
>
|
||||
{t('mfa.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupStep === 'verify' && (
|
||||
<div className="space-y-4">
|
||||
<p className="system-sm-regular text-text-secondary">{t('mfa.enterToken')}</p>
|
||||
<Input
|
||||
value={totpToken}
|
||||
onChange={e => setTotpToken(e.target.value)}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
className="text-center text-2xl font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleVerifyToken}
|
||||
loading={completeSetupMutation.isPending}
|
||||
disabled={totpToken.length !== 6}
|
||||
>
|
||||
{t('mfa.verify')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupStep === 'backup' && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-util-colors-warning-warning-100 border border-util-colors-warning-warning-300 rounded-lg">
|
||||
<p className="system-sm-semibold text-util-colors-warning-warning-700 mb-2">{t('mfa.backupCodesTitle')}</p>
|
||||
<p className="system-xs-regular text-util-colors-warning-warning-600">{t('mfa.backupCodesWarning')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-components-panel-bg-blur rounded-lg border border-components-panel-border">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<code key={index} className="system-sm-regular font-mono text-text-secondary">{code}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={handleCopyBackupCodes}
|
||||
>
|
||||
{t('mfa.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setIsSetupModalOpen(false)
|
||||
Toast.notify({ type: 'success', message: t('mfa.enabledSuccess') })
|
||||
}}
|
||||
>
|
||||
{t('mfa.done')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Disable Modal */}
|
||||
<Modal
|
||||
isShow={isDisableModalOpen}
|
||||
onClose={() => setIsDisableModalOpen(false)}
|
||||
title={t('mfa.disableTitle')}
|
||||
className="!max-w-md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="system-sm-regular text-text-secondary">{t('mfa.disableDescription')}</p>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={t('common.account.password')}
|
||||
/>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setIsDisableModalOpen(false)}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="warning"
|
||||
className="flex-1"
|
||||
onClick={handleDisable}
|
||||
loading={disableMutation.isPending}
|
||||
disabled={!password}
|
||||
>
|
||||
{t('mfa.disable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,353 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// Mock the service base to avoid ky import issues
|
||||
jest.mock('@/service/base', () => ({
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
del: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock translation
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock login service
|
||||
jest.mock('@/service/common', () => ({
|
||||
login: jest.fn(),
|
||||
}))
|
||||
|
||||
import MFAVerification from './mfa-verification'
|
||||
|
||||
describe('MFAVerification Component', () => {
|
||||
const mockRouter = {
|
||||
replace: jest.fn(),
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
inviteToken: undefined,
|
||||
isInvite: false,
|
||||
locale: 'en-US',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(useRouter as jest.Mock).mockReturnValue(mockRouter)
|
||||
})
|
||||
|
||||
test('renders MFA verification form', () => {
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('mfa.mfaRequired')).toBeInTheDocument()
|
||||
expect(screen.getByText('mfa.mfaRequiredDescription')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('mfa.authenticatorCode')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('123456')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles TOTP code input correctly', () => {
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('123456')
|
||||
|
||||
// Test numeric input
|
||||
fireEvent.change(input, { target: { value: '123456' } })
|
||||
expect(input).toHaveValue('123456')
|
||||
|
||||
// Test non-numeric input is filtered
|
||||
fireEvent.change(input, { target: { value: 'abc123' } })
|
||||
expect(input).toHaveValue('123')
|
||||
})
|
||||
|
||||
test('handles backup code input correctly', () => {
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
// Switch to backup code mode
|
||||
const switchButton = screen.getByText('mfa.useBackupCode')
|
||||
fireEvent.click(switchButton)
|
||||
|
||||
const input = screen.getByPlaceholderText('A1B2C3D4')
|
||||
|
||||
// Test alphanumeric input with automatic uppercase
|
||||
fireEvent.change(input, { target: { value: 'abcd1234' } })
|
||||
expect(input).toHaveValue('ABCD1234')
|
||||
|
||||
// Test special characters are filtered
|
||||
fireEvent.change(input, { target: { value: 'ab-cd@12' } })
|
||||
expect(input).toHaveValue('ABCD12')
|
||||
})
|
||||
|
||||
test('switches between TOTP and backup code modes', () => {
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
// Initially in TOTP mode
|
||||
expect(screen.getByLabelText('mfa.authenticatorCode')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('123456')).toBeInTheDocument()
|
||||
|
||||
// Switch to backup code
|
||||
fireEvent.click(screen.getByText('mfa.useBackupCode'))
|
||||
expect(screen.getByLabelText('mfa.backupCode')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('A1B2C3D4')).toBeInTheDocument()
|
||||
|
||||
// Switch back to TOTP
|
||||
fireEvent.click(screen.getByText('mfa.authenticatorCode'))
|
||||
expect(screen.getByLabelText('mfa.authenticatorCode')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('123456')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('disables verify button when code length is incorrect', () => {
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('123456')
|
||||
const verifyButton = screen.getByText('mfa.verify')
|
||||
|
||||
// Initially button should be disabled (no code entered)
|
||||
expect(verifyButton.closest('button')).toBeDisabled()
|
||||
|
||||
// Try with incomplete code
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
expect(verifyButton.closest('button')).toBeDisabled()
|
||||
|
||||
// Try with complete code
|
||||
fireEvent.change(input, { target: { value: '123456' } })
|
||||
expect(verifyButton.closest('button')).not.toBeDisabled()
|
||||
|
||||
// For backup code mode
|
||||
const switchButton = screen.getByText('mfa.useBackupCode')
|
||||
fireEvent.click(switchButton)
|
||||
|
||||
const backupInput = screen.getByPlaceholderText('A1B2C3D4')
|
||||
|
||||
// Button should be disabled with incomplete backup code
|
||||
fireEvent.change(backupInput, { target: { value: 'ABCD' } })
|
||||
expect(verifyButton.closest('button')).toBeDisabled()
|
||||
|
||||
// Button should be enabled with complete backup code
|
||||
fireEvent.change(backupInput, { target: { value: 'ABCD1234' } })
|
||||
expect(verifyButton.closest('button')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
test('successful MFA verification with TOTP', async () => {
|
||||
const { login } = require('@/service/common')
|
||||
login.mockResolvedValue({
|
||||
result: 'success',
|
||||
data: {
|
||||
access_token: 'test_token',
|
||||
refresh_token: 'refresh_token'
|
||||
}
|
||||
})
|
||||
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('123456')
|
||||
fireEvent.change(input, { target: { value: '123456' } })
|
||||
|
||||
const verifyButton = screen.getByText('mfa.verify')
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(login).toHaveBeenCalledWith({
|
||||
url: '/login',
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
mfa_code: '123456',
|
||||
is_backup_code: false,
|
||||
language: 'en-US',
|
||||
remember_me: true,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('console_token')).toBe('test_token')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('refresh_token')
|
||||
expect(mockRouter.replace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
})
|
||||
|
||||
test('successful MFA verification with backup code', async () => {
|
||||
const { login } = require('@/service/common')
|
||||
login.mockResolvedValue({
|
||||
result: 'success',
|
||||
data: {
|
||||
access_token: 'test_token',
|
||||
refresh_token: 'refresh_token'
|
||||
}
|
||||
})
|
||||
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
// Switch to backup code
|
||||
fireEvent.click(screen.getByText('mfa.useBackupCode'))
|
||||
|
||||
const input = screen.getByPlaceholderText('A1B2C3D4')
|
||||
fireEvent.change(input, { target: { value: 'ABCD1234' } })
|
||||
|
||||
const verifyButton = screen.getByText('mfa.verify')
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(login).toHaveBeenCalledWith({
|
||||
url: '/login',
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
mfa_code: 'ABCD1234',
|
||||
is_backup_code: true,
|
||||
language: 'en-US',
|
||||
remember_me: true,
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('handles invalid MFA token error', async () => {
|
||||
const { login } = require('@/service/common')
|
||||
const Toast = require('@/app/components/base/toast').default
|
||||
|
||||
login.mockResolvedValue({
|
||||
result: 'fail',
|
||||
code: 'mfa_token_invalid'
|
||||
})
|
||||
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('123456')
|
||||
fireEvent.change(input, { target: { value: '000000' } })
|
||||
|
||||
const verifyButton = screen.getByText('mfa.verify')
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'mfa.invalidToken'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('handles network error', async () => {
|
||||
const { login } = require('@/service/common')
|
||||
const Toast = require('@/app/components/base/toast').default
|
||||
|
||||
login.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('123456')
|
||||
fireEvent.change(input, { target: { value: '123456' } })
|
||||
|
||||
const verifyButton = screen.getByText('mfa.verify')
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Network error'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('handles invite flow correctly', async () => {
|
||||
const { login } = require('@/service/common')
|
||||
login.mockResolvedValue({
|
||||
result: 'success',
|
||||
data: {
|
||||
access_token: 'test_token',
|
||||
refresh_token: 'refresh_token'
|
||||
}
|
||||
})
|
||||
|
||||
const inviteProps = {
|
||||
...defaultProps,
|
||||
isInvite: true,
|
||||
inviteToken: 'invite_token_123'
|
||||
}
|
||||
|
||||
render(<MFAVerification {...inviteProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('123456')
|
||||
fireEvent.change(input, { target: { value: '123456' } })
|
||||
|
||||
const verifyButton = screen.getByText('mfa.verify')
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(login).toHaveBeenCalledWith({
|
||||
url: '/login',
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
mfa_code: '123456',
|
||||
is_backup_code: false,
|
||||
language: 'en-US',
|
||||
remember_me: true,
|
||||
invite_token: 'invite_token_123'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.replace).toHaveBeenCalledWith('/signin/invite-settings?invite_token=invite_token_123')
|
||||
})
|
||||
})
|
||||
|
||||
test('handles Enter key press for submission', () => {
|
||||
const { login } = require('@/service/common')
|
||||
login.mockResolvedValue({
|
||||
result: 'success',
|
||||
data: {
|
||||
access_token: 'test_token',
|
||||
refresh_token: 'refresh_token'
|
||||
}
|
||||
})
|
||||
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('123456')
|
||||
fireEvent.change(input, { target: { value: '123456' } })
|
||||
|
||||
// Press Enter
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(login).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('disables verify button when loading', async () => {
|
||||
const { login } = require('@/service/common')
|
||||
login.mockImplementation(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
render(<MFAVerification {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('123456')
|
||||
fireEvent.change(input, { target: { value: '123456' } })
|
||||
|
||||
const verifyButton = screen.getByText('mfa.verify')
|
||||
fireEvent.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(verifyButton.closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { RiShieldKeyholeLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { login } from '@/service/common'
|
||||
|
||||
type MFAVerificationProps = {
|
||||
email: string
|
||||
password: string
|
||||
inviteToken?: string
|
||||
isInvite: boolean
|
||||
locale: string
|
||||
}
|
||||
|
||||
export default function MFAVerification({ email, password, inviteToken, isInvite, locale }: MFAVerificationProps) {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
const [useBackupCode, setUseBackupCode] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleMFAVerification = async () => {
|
||||
const expectedLength = useBackupCode ? 8 : 6
|
||||
if (!mfaCode || mfaCode.length !== expectedLength) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: useBackupCode
|
||||
? 'Backup code must be 8 characters'
|
||||
: t('mfa.tokenLength')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const loginData: Record<string, any> = {
|
||||
email,
|
||||
password,
|
||||
mfa_code: mfaCode,
|
||||
is_backup_code: useBackupCode,
|
||||
language: locale,
|
||||
remember_me: true,
|
||||
}
|
||||
|
||||
if (isInvite && inviteToken)
|
||||
loginData.invite_token = inviteToken
|
||||
|
||||
console.log('Sending MFA login request with data:', { ...loginData, password: '[HIDDEN]' })
|
||||
|
||||
const res = await login({
|
||||
url: '/login',
|
||||
body: loginData,
|
||||
})
|
||||
|
||||
console.log('MFA login response:', res)
|
||||
console.log('Response type:', typeof res, 'Result:', res.result, 'Data keys:', res.data ? Object.keys(res.data) : 'no data')
|
||||
if (res.result === 'success') {
|
||||
console.log('MFA authentication successful!', 'isInvite:', isInvite, 'useBackupCode:', useBackupCode)
|
||||
if (isInvite) {
|
||||
console.log('Redirecting to invite settings')
|
||||
const params = new URLSearchParams()
|
||||
if (inviteToken)
|
||||
params.append('invite_token', inviteToken)
|
||||
router.replace(`/signin/invite-settings?${params.toString()}`)
|
||||
}
|
||||
else {
|
||||
console.log('Setting tokens and redirecting to /apps')
|
||||
console.log('Access token exists:', !!res.data.access_token, 'Refresh token exists:', !!res.data.refresh_token)
|
||||
localStorage.setItem('console_token', res.data.access_token)
|
||||
localStorage.setItem('refresh_token', res.data.refresh_token)
|
||||
console.log('Tokens set, calling router.replace(/apps)')
|
||||
router.replace('/apps')
|
||||
console.log('router.replace(/apps) called')
|
||||
}
|
||||
}
|
||||
else if (res.code === 'mfa_token_invalid') {
|
||||
console.log('MFA authentication failed: invalid token')
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('mfa.invalidToken'),
|
||||
})
|
||||
}
|
||||
else {
|
||||
console.log('MFA authentication failed:', res)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: res.data || t('mfa.invalidToken'),
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('MFA authentication error:', error)
|
||||
|
||||
// Handle different types of errors
|
||||
let errorMessage = t('mfa.invalidToken')
|
||||
if (error?.response?.status === 401) {
|
||||
errorMessage = t('mfa.invalidToken')
|
||||
} else if (error?.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-state-base-hover">
|
||||
<RiShieldKeyholeLine className="h-8 w-8 text-text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="title-xl-semi-bold mb-2 text-center text-text-primary">
|
||||
{t('mfa.mfaRequired')}
|
||||
</h3>
|
||||
<p className="body-md-regular mb-6 text-center text-text-tertiary">
|
||||
{t('mfa.mfaRequiredDescription')}
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="mfa-code" className="system-md-semibold mb-2 block text-text-secondary">
|
||||
{t(useBackupCode ? 'mfa.backupCode' : 'mfa.authenticatorCode')}
|
||||
</label>
|
||||
<Input
|
||||
id="mfa-code"
|
||||
value={mfaCode}
|
||||
onChange={e => {
|
||||
const value = e.target.value
|
||||
if (useBackupCode) {
|
||||
// For backup codes, allow alphanumeric characters
|
||||
setMfaCode(value.replace(/[^A-Za-z0-9]/g, '').toUpperCase())
|
||||
} else {
|
||||
// For TOTP codes, allow only digits
|
||||
setMfaCode(value.replace(/\D/g, ''))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
handleMFAVerification()
|
||||
}}
|
||||
placeholder={useBackupCode ? 'A1B2C3D4' : '123456'}
|
||||
maxLength={useBackupCode ? 8 : 6}
|
||||
className="text-center text-2xl font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleMFAVerification}
|
||||
disabled={isLoading || !mfaCode || mfaCode.length !== (useBackupCode ? 8 : 6)}
|
||||
className="mb-3 w-full"
|
||||
>
|
||||
{t('mfa.verify')}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode)
|
||||
setMfaCode('')
|
||||
}}
|
||||
className="system-xs-medium text-components-button-secondary-accent-text hover:underline"
|
||||
>
|
||||
{t(useBackupCode ? 'mfa.authenticatorCode' : 'mfa.useBackupCode')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,35 @@
|
||||
const translation = {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account by enabling two-factor authentication.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorDescription: 'Use an authenticator app like Google Authenticator or Authy',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enabledAt: 'Enabled on {{date}}',
|
||||
setupTitle: 'Set up Two-Factor Authentication',
|
||||
scanQRCode: 'Scan this QR code with your authenticator app',
|
||||
secretKey: 'Or enter this key manually',
|
||||
enterToken: 'Enter the 6-digit code from your authenticator app',
|
||||
verify: 'Verify',
|
||||
tokenLength: 'Code must be 6 digits',
|
||||
invalidToken: 'Invalid authentication code',
|
||||
invalidPassword: 'Invalid password',
|
||||
backupCodesTitle: 'Save Your Backup Codes',
|
||||
backupCodesWarning: 'Store these codes in a safe place. Each code can only be used once.',
|
||||
enabledSuccess: 'Two-factor authentication has been enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableDescription: 'Enter your password to disable two-factor authentication',
|
||||
disabledSuccess: 'Two-factor authentication has been disabled',
|
||||
mfaRequired: 'Two-factor authentication required',
|
||||
mfaRequiredDescription: 'Please enter your authentication code to continue',
|
||||
or: 'or',
|
||||
useBackupCode: 'Use a backup code',
|
||||
backupCode: 'Backup code',
|
||||
authenticatorCode: 'Authenticator code',
|
||||
next: 'Next',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -0,0 +1,29 @@
|
||||
import { get, post } from './base'
|
||||
|
||||
export const getMFAStatus = () => {
|
||||
return get<{
|
||||
enabled: boolean
|
||||
setup_at: string | null
|
||||
}>('/console/api/account/mfa/status')
|
||||
}
|
||||
|
||||
export const setupMFA = () => {
|
||||
return post<{
|
||||
secret: string
|
||||
qr_code: string
|
||||
}>('/console/api/account/mfa/setup')
|
||||
}
|
||||
|
||||
export const verifyMFA = (data: { token: string; password: string }) => {
|
||||
return post<{
|
||||
backup_codes: string[]
|
||||
}>('/console/api/account/mfa/verify', {
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
export const disableMFA = (data: { password: string }) => {
|
||||
return post('/console/api/account/mfa/disable', {
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue