MATIH Platform is in active MVP development. Documentation reflects current implementation status.
6. Identity & Access Management
Authentication
Login Flow

Login Flow

Production - POST /api/v1/auth/login, POST /api/v1/auth/mfa/verify

The login flow authenticates users with email and password credentials. If multi-factor authentication is enabled for the user, the flow splits into a two-step process requiring MFA verification before tokens are issued.


6.2.1Simple Login (No MFA)

When MFA is not enabled, the login endpoint returns tokens directly after credential validation.

Request

curl -X POST http://localhost:8081/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane.smith@example.com",
    "password": "SecureP@ssw0rd!"
  }'

Request Schema

FieldTypeRequiredValidationDescription
emailStringYes@NotBlank, @EmailUser email address
passwordStringYes@NotBlankUser password

Response (200 OK)

{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ0ZW5hbnRfaWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEiLCJyb2xlcyI6WyJST0xFX1VTRVIiXSwic3ViIjoiamFuZS5zbWl0aEBleGFtcGxlLmNvbSIsImlzcyI6Im1hdGloLXBsYXRmb3JtIiwiaWF0IjoxNzA3NzMyMjAwLCJleHAiOjE3MDc3MzMxMDB9.xxxxx",
  "refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "user": {
    "id": 1,
    "email": "jane.smith@example.com",
    "firstName": "Jane",
    "lastName": "Smith",
    "displayName": "Jane Smith",
    "tenantId": "00000000-0000-0000-0000-000000000001",
    "roles": ["ROLE_USER"],
    "emailVerified": true
  }
}

6.2.2Login with MFA

When MFA is enabled, the login endpoint returns an MfaChallengeResponse instead of tokens. The client must then submit the MFA code to complete authentication.

Step 1: Initial Login

curl -X POST http://localhost:8081/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@example.com",
    "password": "SecureP@ssw0rd!"
  }'

MFA Challenge Response (200 OK)

{
  "mfaRequired": true,
  "challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "availableMethods": ["TOTP", "SMS", "BACKUP_CODE"],
  "preferredMethod": "TOTP",
  "expiresAt": "2026-02-12T10:35:00Z",
  "maskedPhoneNumber": "****5678",
  "backupCodesAvailable": true,
  "userEmail": "a***n@example.com"
}
FieldTypeDescription
mfaRequiredbooleanAlways true when MFA challenge is issued
challengeIdUUIDChallenge identifier for the verification step
availableMethodsList<String>MFA methods available: TOTP, SMS, EMAIL, BACKUP_CODE
preferredMethodStringThe user's primary MFA method
expiresAtInstantChallenge expiration (5 minutes from creation)
maskedPhoneNumberStringLast 4 digits of phone if SMS is available
backupCodesAvailablebooleanWhether unused backup codes exist
userEmailStringMasked email for display

Step 2: MFA Verification

curl -X POST http://localhost:8081/api/v1/auth/mfa/verify \
  -H "Content-Type: application/json" \
  -d '{
    "challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "code": "123456",
    "codeType": "TOTP"
  }'

MFA Verify Request Schema

FieldTypeRequiredDescription
challengeIdUUIDYesChallenge ID from the login response
codeStringYesVerification code (TOTP, SMS, or backup code)
codeTypeEnumYesTOTP, SMS, or BACKUP_CODE

The response is a standard AuthResponse with access and refresh tokens.


6.2.3Implementation Details

AuthenticationService.login()

The login method in AuthenticationService performs these steps:

@Transactional
public Object login(LoginRequest request, String userAgent, String ipAddress) {
    // 1. Look up user by email
    User user = userRepository.findByEmail(request.getEmail())
        .orElseThrow(() -> new AuthenticationException("Invalid email or password"));
 
    // 2. Check if account is locked
    if (user.isAccountLocked()) {
        throw new LockedException("Account is locked. Please try again later.");
    }
 
    // 3. Authenticate with Spring Security
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
    );
 
    // 4. Reset failed login attempts on success
    user.resetFailedLoginAttempts();
    userRepository.save(user);
 
    // 5. Check MFA requirement
    if (user.isMfaEnabled()) {
        return createMfaChallenge(user, userAgent, ipAddress);
    }
 
    // 6. Complete login (generate tokens)
    return completeLogin(user, authentication, userAgent, ipAddress);
}

Failed Login Handling

When credentials are invalid, the service increments the failed attempt counter:

private void handleFailedLogin(User user) {
    user.incrementFailedLoginAttempts();
 
    if (user.getFailedLoginAttempts() >= securityProperties.getLockout().getMaxAttempts()) {
        user.lock(securityProperties.getLockout().getLockoutDurationMinutes());
        log.warn("Account locked due to too many failed attempts: {}", user.getEmail());
    }
 
    userRepository.save(user);
}

Account Lock Check

The User.isAccountLocked() method checks both the lock flag and expiration:

public boolean isAccountLocked() {
    if (!locked) return false;
    if (lockedUntil != null && Instant.now().isAfter(lockedUntil)) {
        unlock();  // Auto-unlock when lockout period expires
        return false;
    }
    return true;
}

6.2.4MFA Challenge Creation

When MFA is required, the service creates a challenge record and returns available methods:

private MfaChallengeResponse createMfaChallenge(User user, String userAgent, String ipAddress) {
    // Get verified MFA credentials
    List<UserMfaCredential> credentials =
        mfaCredentialRepository.findByUserIdAndVerifiedTrue(user.getId());
 
    List<String> availableMethods = new ArrayList<>();
    String preferredMethod = null;
    String maskedPhone = null;
 
    for (UserMfaCredential credential : credentials) {
        availableMethods.add(credential.getMfaType().name());
        if (credential.isPrimary()) {
            preferredMethod = credential.getMfaType().name();
        }
        if (credential.getMfaType() == MfaType.SMS) {
            maskedPhone = maskPhoneNumber(credential.getPhoneNumber());
        }
    }
 
    // Check for backup codes
    boolean backupCodesAvailable =
        backupCodeRepository.existsByUserIdAndUsedFalse(user.getId());
    if (backupCodesAvailable) {
        availableMethods.add("BACKUP_CODE");
    }
 
    // Create challenge record (expires in 5 minutes)
    MfaChallenge challenge = MfaChallenge.createForUser(
        user, challengeType, MFA_CHALLENGE_VALIDITY_MINUTES, ipAddress, userAgent);
    mfaChallengeRepository.save(challenge);
 
    return MfaChallengeResponse.builder()
        .mfaRequired(true)
        .challengeId(challenge.getId())
        .availableMethods(availableMethods)
        .preferredMethod(preferredMethod)
        .expiresAt(challenge.getExpiresAt())
        .maskedPhoneNumber(maskedPhone)
        .backupCodesAvailable(backupCodesAvailable)
        .userEmail(maskEmail(user.getEmail()))
        .build();
}

Error Codes

CodeHTTP StatusDescription
AUTHENTICATION_FAILED401Invalid email or password
ACCOUNT_LOCKED423Account locked due to too many failed attempts
MFA_CHALLENGE_EXPIRED400MFA challenge has expired (5-minute window)
MFA_INVALID_CODE401Invalid MFA verification code
MFA_CHALLENGE_NOT_FOUND400Challenge ID not found or already completed

Security Notes

  • The error message for invalid credentials is always generic ("Invalid email or password") to prevent email enumeration
  • Account lockout uses time-based auto-unlock -- no admin intervention required after the lockout period expires
  • MFA challenges expire after 5 minutes and are marked as completed after successful verification
  • The client IP and User-Agent are captured from every login request for audit logging and anomaly detection
  • Masked email format: j***e@example.com (first and last character of local part)
  • Masked phone format: ****5678 (last 4 digits only)