Login Flow
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
| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
email | String | Yes | @NotBlank, @Email | User email address |
password | String | Yes | @NotBlank | User 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"
}| Field | Type | Description |
|---|---|---|
mfaRequired | boolean | Always true when MFA challenge is issued |
challengeId | UUID | Challenge identifier for the verification step |
availableMethods | List<String> | MFA methods available: TOTP, SMS, EMAIL, BACKUP_CODE |
preferredMethod | String | The user's primary MFA method |
expiresAt | Instant | Challenge expiration (5 minutes from creation) |
maskedPhoneNumber | String | Last 4 digits of phone if SMS is available |
backupCodesAvailable | boolean | Whether unused backup codes exist |
userEmail | String | Masked 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
| Field | Type | Required | Description |
|---|---|---|---|
challengeId | UUID | Yes | Challenge ID from the login response |
code | String | Yes | Verification code (TOTP, SMS, or backup code) |
codeType | Enum | Yes | TOTP, 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
| Code | HTTP Status | Description |
|---|---|---|
AUTHENTICATION_FAILED | 401 | Invalid email or password |
ACCOUNT_LOCKED | 423 | Account locked due to too many failed attempts |
MFA_CHALLENGE_EXPIRED | 400 | MFA challenge has expired (5-minute window) |
MFA_INVALID_CODE | 401 | Invalid MFA verification code |
MFA_CHALLENGE_NOT_FOUND | 400 | Challenge 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)