CVE-2021-21329 (Python, published 2021-03-08T18:15Z)
Description: value: RATCF is an open-source framework for hosting Cyber-Security Capture the Flag events. In affected versions of RATCF users with multi factor authentication enabled are able to log in without a valid token. This is fixed in commit cebb67b.
Severity: MEDIUM (CVSS v3 base score: 9.8)
Weakness: CWE-287 \u2014 Improper Authentication
When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
Repository: https://github.com/ractf/core
Files changed:
src/authentication/basic_auth.py
src/authentication/migrations/0004_auto_20200810_1111.py
src/authentication/models.py
src/authentication/serializers.py
src/authentication/urls.py
src/authentication/views.py
src/member/migrations/0004_auto_20200810_1111.py
src/member/models.py
src/member/serializers.py
Commit message: Refactor 2FA and prepare for fido support


Vulnerable code:
    #TODO: These fields don't do anything yet, but they will eventually correlate to the kwargs in login_user
    username = serializers.CharField(max_length=50)
    password = serializers.CharField(max_length=50)
    otp = serializers.CharField(max_length=6)

    def login_user(self, username, password, otp, context, **kwargs):
        if user.totp_status == TOTPStatus.ENABLED:
            if not otp or otp == '':
                login_reject.send(sender=self.__class__, username=username, reason='no_2fa')
                raise FormattedException(m='2fa_required', d={'reason': '2fa_required'},
                                         status_code=HTTP_401_UNAUTHORIZED)
            totp = pyotp.TOTP(user.totp_secret)
            if not totp.verify(otp, valid_window=1):
                login_reject.send(sender=self.__class__, username=username, reason='incorrect_2fa')
                raise FormattedException(m='incorrect_2fa', d={'reason': 'incorrect_2fa'},
                                         status_code=HTTP_401_UNAUTHORIZED)
    issued = models.DateTimeField(default=timezone.now)
    otp = serializers.CharField(max_length=6, allow_null=True, allow_blank=True)
        if user is not None:
            data['user'] = user
    path('invites/', include(router.urls), name='invites')
import string
import pyotp
from rest_framework.authtoken.views import ObtainAuthToken
from backend import renderers
from team.models import Team
from authentication.models import InviteCode, PasswordResetToken
hide_password = method_decorator(sensitive_post_parameters("password",))
    renderer_classes = (renderers.RACTFJSONRenderer,)
        totp_secret = pyotp.random_base32()
        request.user.totp_secret = totp_secret
        request.user.totp_status = TOTPStatus.VERIFYING
        request.user.save()
        return FormattedResponse({"totp_secret": totp_secret})
        totp = pyotp.TOTP(request.user.totp_secret)
        valid = totp.verify(request.data["otp"], valid_window=1)
        if valid:
            request.user.totp_status = TOTPStatus.ENABLED
            request.user.save()
        return FormattedResponse({"valid": valid})
        request.user.totp_status = TOTPStatus.DISABLED
            for i in range(active_codes, serializer.validated_data["amount"]+active_codes):
                code = f"{''.join([random.choice(string.ascii_letters+string.digits) for _ in range(8)])}{hex(i)[2:]}"
        error_messages={"unique": _("A user with that username already exists."),},
    totp_secret = models.CharField(null=True, max_length=16)
    totp_status = models.IntegerField(
        choices=[(status, status.value) for status in TOTPStatus],
        default=TOTPStatus.DISABLED,
    )
    def is_2fa_enabled(self):
        return self.totp_status == TOTPStatus.ENABLED
        return self.totp_status != TOTPStatus.ENABLED and config.get(
        fields = ['id', 'username', 'is_staff', 'bio', 'discord', 'discordid', 'twitter', 'reddit', 'team',
                  'points', 'is_visible', 'is_active', 'solves', 'team_name', 'leaderboard_points', 'date_joined',
                  'incorrect_solves']
                  'leaderboard_points', 'date_joined', 'incorrect_solves']
                  'points', 'is_visible', 'is_active', 'team_name', 'leaderboard_points', 'date_joined']
                  'totp_status', 'points', 'solves', 'team_name', 'leaderboard_points', 'date_joined',
        read_only_fields = ['id', 'is_staff', 'team', 'email', 'totp_status', 'points',
                            'leaderboard_points', 'date_joined', 'incorrect_solves']

Fixed code:
    def login_user(self, username, password, context, **kwargs):
# Generated by Django 3.0.5 on 2020-08-10 11:11

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import pyotp


class Migration(migrations.Migration):

    dependencies = [
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
        ('authentication', '0003_passwordresettoken'),
    ]

    operations = [
        migrations.AlterField(
            model_name='passwordresettoken',
            name='issued',
            field=models.DateTimeField(auto_now_add=True),
        ),
        migrations.CreateModel(
            name='TOTPDevice',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('created', models.DateTimeField(auto_now_add=True)),
                ('last_used', models.DateTimeField(null=True)),
                ('totp_secret', models.CharField(default=pyotp.random_base32, max_length=16, null=True)),
                ('verified', models.BooleanField(default=False)),
                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='totp_device', to=settings.AUTH_USER_MODEL)),
            ],
        ),
        migrations.CreateModel(
            name='BackupCode',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('code', models.CharField(max_length=8)),
                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='backup_codes', to=settings.AUTH_USER_MODEL)),
            ],
            options={
                'unique_together': {('user', 'code')},
            },
        ),
    ]
import pyotp
    issued = models.DateTimeField(auto_now_add=True)


class BackupCode(models.Model):
    user = models.ForeignKey(get_user_model(), related_name='backup_codes', on_delete=models.CASCADE)
    code = models.CharField(max_length=8)

    class Meta:
        unique_together = [
            ('user', 'code')
        ]

    @staticmethod
    def generate(user):
        BackupCode.objects.filter(user=user).delete()
        codes = [BackupCode(user=user, code=pyotp.random_base32(8)) for i in range(10)]
        BackupCode.objects.bulk_create(codes)
        return BackupCode.objects.filter(user=user).values_list('code', flat=True)


class TOTPDevice(models.Model):
    user = models.OneToOneField(get_user_model(), related_name='totp_device', on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True)
    last_used = models.DateTimeField(null=True)
    totp_secret = models.CharField(null=True, max_length=16, default=pyotp.random_base32)
    verified = models.BooleanField(default=False)

    def validate_token(self, token):
        return pyotp.TOTP(self.totp_secret).verify(token, valid_window=1)
        data['user'] = user
        return data


class LoginTwoFactorSerializer(serializers.Serializer):
    username = serializers.CharField()
    password = serializers.CharField(trim_whitespace=False)
    tfa = serializers.CharField(max_length=255, allow_null=True, allow_blank=True)

    def validate(self, data):
        user = providers.get_provider('login').login_user(**data, context=self.context)
        data['user'] = user
    path('login_2fa/', views.LoginTwoFactorView.as_view(), name='login-2fa'),
    path('invites/', include(router.urls), name='invites'),
    path('regenerate_backup_codes', views.RegenerateBackupCodesView.as_view(), name='regenerate-backup-codes')
import string
from authentication.models import InviteCode, PasswordResetToken, TOTPDevice, BackupCode
from team.models import Team
hide_password = method_decorator(sensitive_post_parameters("password", ))
        if user.has_2fa():
            return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={'reason': '2fa_required'}, m='2fa_required')

        totp_device = TOTPDevice(user=request.user)
        totp_device.save()
        return FormattedResponse({"totp_secret": totp_device.totp_secret})
        if request.user.totp_device is not None and request.user.totp_device.validate_token(request.data["otp"]):
            request.user.totp_device.verified = True
            request.user.totp_device.save()
            backup_codes = BackupCode.generate(request.user)
            return FormattedResponse({"valid": True, "backup_codes": backup_codes})
        return FormattedResponse({"valid": False})
        request.user.totp_device.delete()
class LoginTwoFactorView(APIView):
    permission_classes = (~permissions.IsAuthenticated,)
    serializer_class = serializers.LoginTwoFactorSerializer
    throttle_scope = "login"

    @hide_password
    def dispatch(self, *args, **kwargs):
        return super(LoginTwoFactorView, self).dispatch(*args, **kwargs)

    def issue_token(self, user):
        token = providers.get_provider('token').issue_token(user)
        return FormattedResponse({'token': token})

    def post(self, request, *args, **kwargs):
        serializer = self.serializer_class(data=request.data, context={'request': request})
        serializer.is_valid(raise_exception=True)
        user = serializer.validated_data['user']
        if user is None:
            return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={'reason': 'login_failed'}, m='login_failed')

        if user.totp_status != TOTPStatus.ENABLED:
            return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={'reason': '2fa_not_enabled'}, m='2fa_not_enabled')

        token = serializer.data['tfa']

        if len(token) == 6:
            for device in user.totp_devices:
                if device.validate_token(token):
                    return self.issue_token(user)
        elif len(token) == 8:
            for code in user.backup_codes:
                if token == code.code:
                    code.delete()
                    return self.issue_token(user)

        return self.issue_token(user)


class RegenerateBackupCodesView(APIView):
    permission_classes = (permissions.IsAuthenticated & HasTwoFactor,)
    serializer_class = serializers.LoginTwoFactorSerializer
    throttle_scope = "2fa"

    def post(self, request, *args, **kwargs):
        backup_codes = BackupCode.generate(request.user)
        return FormattedResponse({"backup_codes": backup_codes})


            for i in range(active_codes, serializer.validated_data["amount"] + active_codes):
                code = f"{''.join([random.choice(string.ascii_letters + string.digits) for _ in range(8)])}{hex(i)[2:]}"
# Generated by Django 3.0.5 on 2020-08-10 11:11

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('member', '0003_auto_20200808_1649'),
    ]

    operations = [
        migrations.RemoveField(
            model_name='member',
            name='totp_secret',
        ),
        migrations.RemoveField(
            model_name='member',
            name='totp_status',
        ),
        migrations.AddField(
            model_name='member',
            name='state_actor',
            field=models.BooleanField(default=False),
        ),
    ]
import random
        error_messages={"unique": _("A user with that username already exists.")},
    state_actor = models.BooleanField(default=False)
    def has_2fa(self):
        return self.totp_device is not None and self.totp_device.verified
        return not self.has_2fa() and config.get(
        fields = ['id', 'username', 'is_staff', 'bio', 'discord', 'discordid', 'state_actor', 'twitter', 'reddit',
                  'team', 'points', 'is_visible', 'is_active', 'solves', 'team_name', 'leaderboard_points',
                  'date_joined', 'incorrect_solves']
                  'leaderboard_points', 'date_joined', 'state_actor', 'incorrect_solves']
                  'points', 'is_visible', 'is_active', 'team_name', 'leaderboard_points', 'state_actor', 'date_joined']
    has_2fa = serializers.BooleanField(source='has_2fa')
                  'has_2fa', 'points', 'solves', 'team_name', 'leaderboard_points', 'date_joined',
        read_only_fields = ['id', 'is_staff', 'team', 'email', 'points', 'leaderboard_points', 'date_joined',
                            'incorrect_solves']