Search code examples
javaspring-bootauthenticationtotp

spring boot mfa totp code verification on login not working


I am trying to implement mfa authentication in my app using totp. Bellow is the library i use. All is going well for registering the user, i receive the qr code, scan it and get every 30 secs the code in google authenticator. When i am trying to login to verify the code, the code verification doesnt work (in auth service, method Verify). I've spent several hours but cant figure it out, tried different users, logs but without success.

<dependency>
            <groupId>dev.samstevens.totp</groupId>
            <artifactId>totp</artifactId>
            <version>1.7.1</version>
        </dependency>

this is my code

AuthContoller.java


import com.example.jsonfaker.model.dto.LoginRequest;
import com.example.jsonfaker.model.dto.SignupRequest;
import com.example.jsonfaker.model.dto.VerifyRequest;
import com.example.jsonfaker.service.Exporter;
import com.example.jsonfaker.service.UserAuthService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
@RequestMapping("/auth")
@CrossOrigin
public class AuthController {
    private final Exporter exporter;

    private final UserAuthService userAuthService;

    public AuthController(Exporter exporter, UserAuthService userAuthService) {
        this.exporter = exporter;
        this.userAuthService = userAuthService;
    }

    @PostMapping("/login")
    public ResponseEntity<String> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
        String response = userAuthService.login(loginRequest);
        return ResponseEntity
                .ok()
                .body(response);
    }

    @PostMapping("/register2FA")
    public ResponseEntity<byte[]> registerUser2FA(@Valid @RequestBody SignupRequest signupRequest) throws Exception {

        userAuthService.register2FA(signupRequest);
        byte[] qrCodeBytes = userAuthService.mfaAccountSetup(signupRequest.getUsername());

        return ResponseEntity
                .ok()
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .header(HttpHeaders.CONTENT_DISPOSITION, "inline;filename=\""+exporter.exportFileNameQR() + ".png\"")
                .body(qrCodeBytes);
    }

    @PostMapping("/register")
    public ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signupRequest) throws Exception {
        userAuthService.simpleRegister(signupRequest);
        return ResponseEntity.ok(HttpStatus.CREATED);
    }

    @PostMapping("/verify")
    public ResponseEntity<String> authenticateUser2FA(@Valid @RequestBody VerifyRequest verifyRequest) throws Exception {
        String response = userAuthService.verify(verifyRequest.getUsername(), verifyRequest.getCode());
        return ResponseEntity
                .ok()
                .body(response);
    }


}

this is my token manager

import dev.samstevens.totp.code.*;
import dev.samstevens.totp.exceptions.QrGenerationException;
import dev.samstevens.totp.qr.QrData;
import dev.samstevens.totp.qr.QrGenerator;
import dev.samstevens.totp.secret.SecretGenerator;
import dev.samstevens.totp.time.SystemTimeProvider;
import dev.samstevens.totp.time.TimeProvider;
import dev.samstevens.totp.util.Utils;
import org.springframework.stereotype.Service;

@Service("mfaTokenManager")
public class DefaultMFATokenManager implements MFATokenManager {


    private final SecretGenerator secretGenerator;


    private final QrGenerator qrGenerator;


    private final CodeVerifier codeVerifier;

    public DefaultMFATokenManager(SecretGenerator secretGenerator, QrGenerator qrGenerator, CodeVerifier codeVerifier) {
        this.secretGenerator = secretGenerator;
        this.qrGenerator = qrGenerator;
        this.codeVerifier = codeVerifier;
    }

    @Override
    public String generateSecretKey() {
        return secretGenerator.generate();
    }

    @Override
    public String getQRCode(String secret) throws QrGenerationException {
        QrData data = new QrData.Builder().label("MFA")
                .secret(secret)
                .issuer("Daniel token")
                .algorithm(HashingAlgorithm.SHA1)
                .digits(6)
                .period(30)
                .build();
        return Utils.getDataUriForImage(
                qrGenerator.generate(data),
                qrGenerator.getImageMimeType()
        );
    }

    @Override
    public boolean verifyTotp(String code, String secret) {
        TimeProvider timeProvider = new SystemTimeProvider();
        CodeGenerator codeGenerator = new DefaultCodeGenerator();
        CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
        System.out.println(timeProvider.getTime());
        System.out.println(codeGenerator);

        return verifier.isValidCode(secret, code);
    }
}

this is my auth service


import com.example.jsonfaker.model.Roles;
import com.example.jsonfaker.model.SystemUser;
import com.example.jsonfaker.model.dto.LoginRequest;
import com.example.jsonfaker.model.dto.SignupRequest;
import com.example.jsonfaker.model.dto.TokenResponse;
import com.example.jsonfaker.repository.RolesRepository;
import com.example.jsonfaker.repository.SystemUserRepository;
import com.example.jsonfaker.security.AuthoritiesConstants;
import com.example.jsonfaker.security.jwt.JwtUtils;
import com.example.jsonfaker.twoFA.MFATokenManager;
import com.example.jsonfaker.twoFA.MfaTokenData;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.stream.Collectors;

import static java.util.Objects.nonNull;

@Service
public class UserAuthService {
    private final SystemUserRepository systemUserRepository;
    private final RolesRepository rolesRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final MFATokenManager mfaTokenManager;
    private final AuthenticationManager authenticationManager;
    private final LoginUserService loginUserService;
    private final JwtUtils jwtUtils;

    public UserAuthService(SystemUserRepository systemUserRepository, RolesRepository rolesRepository, BCryptPasswordEncoder bCryptPasswordEncoder, MFATokenManager mfaTokenManager, AuthenticationManager authenticationManager, LoginUserService loginUserService, JwtUtils jwtUtils) {
        this.systemUserRepository = systemUserRepository;
        this.rolesRepository = rolesRepository;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        this.mfaTokenManager = mfaTokenManager;
        this.authenticationManager = authenticationManager;
        this.loginUserService = loginUserService;
        this.jwtUtils = jwtUtils;
    }

    public void simpleRegister(SignupRequest signupRequest) throws Exception {
        if(systemUserRepository.findByUsername(signupRequest.getUsername()).isPresent()){
            throw new Exception("User with this username exists");
        }

        Roles simpleUserRole = new Roles();
        simpleUserRole.setName(AuthoritiesConstants.USER);

        SystemUser user = new SystemUser();
        user.setPassword(bCryptPasswordEncoder.encode(signupRequest.getPassword()));
        user.setUsername(signupRequest.getUsername());
        user.setAuthorities(rolesRepository.findAllByName("ROLE_USER").stream().collect(Collectors.toSet()));
        user.setSecret(mfaTokenManager.generateSecretKey());
        systemUserRepository.save(user);

    }

    public void register2FA(SignupRequest signupRequest) throws Exception {
        if(systemUserRepository.findByUsername(signupRequest.getUsername()).isPresent()){
            throw new Exception("User with this username exists");
        }
        Roles simpleUserRole = new Roles();
        simpleUserRole.setName(AuthoritiesConstants.USER);

        SystemUser user = new SystemUser();
        user.setPassword(bCryptPasswordEncoder.encode(signupRequest.getPassword()));
        user.setUsername(signupRequest.getUsername());
        user.setAuthorities(rolesRepository.findAllByName("ROLE_USER").stream().collect(Collectors.toSet()));
        user.setTwoFAisEnabled(Boolean.TRUE);
        user.setSecret(mfaTokenManager.generateSecretKey());
        systemUserRepository.save(user);
    }

    public byte[] mfaAccountSetup(String username) throws Exception {
        SystemUser user = systemUserRepository.findByUsername(username).get();
        if (!nonNull(user)){
            throw new Exception("Unable to find user with this username");
        }
        if(!user.isTwoFAisEnabled()){
            throw new Exception("2FA is not enabled for this account");
        }
        MfaTokenData token =  new MfaTokenData(mfaTokenManager.getQRCode(user.getSecret()), user.getSecret());
        System.out.println("Mfa code :" +token.getMfaCode());

        String base64Image = token.getQrCode().split(",")[1];
        byte[] imageBytes = javax.xml.bind.DatatypeConverter.parseBase64Binary(base64Image);
        return imageBytes;
    }

    public String login(LoginRequest loginRequest){
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getUsername(),
                        loginRequest.getPassword()
                )
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);

        if(systemUserRepository.findByUsername(loginRequest.getUsername()).get().isTwoFAisEnabled()){
            return "verify code now";
        }

        SystemUser userDetails = (SystemUser) authentication.getPrincipal();

        String jwt = jwtUtils.generateJwtToken(userDetails);

        return new TokenResponse(jwt).toString();

    }

    public String verify(String username, String code) throws Exception {

        SystemUser user = systemUserRepository.findByUsername(username).get();
        if (!nonNull(user)){
            throw new Exception("Unable to find user with this username");
        }

        if (!mfaTokenManager.verifyTotp(code, user.getSecret())){
            return "unable to auth";
        }
        return "token here";

    }
}


Solution

  • Finally found the problem, on my phone the time was delayed by 2 minutes, I've set it to be the same as on my computer and worked. The problem is that when validating the token the app uses an interval of 30 seconds for each token generation, and if the delay on the phone or other device is bigger than 30 sec in future or past the timestamp doesnt match the one used for verification.

    Here is the documentation for the library i used, make sure to read it carefully before using it. https://github.com/samdjstevens/java-totp

    Here is the article which i followed in my project: https://www.javadevjournal.com/spring-security/two-factor-authentication-with-spring-security/

    Useful reading before starting a project using TOTP: https://www.freecodecamp.org/news/how-time-based-one-time-passwords-work-and-why-you-should-use-them-in-your-app-fdd2b9ed43c3/

    A youtube video about 2FA: https://www.youtube.com/watch?v=ZXFYT-BG2So