AuthenticationService.java
package com.ecommerce.identity.application.service;
import com.ecommerce.identity.application.exception.IdentityError;
import com.ecommerce.identity.application.exception.IdentityException;
import com.ecommerce.identity.application.model.AuthTokens;
import com.ecommerce.identity.application.model.LoginContext;
import com.ecommerce.identity.application.model.UserProfile;
import com.ecommerce.identity.application.port.IdentityStore;
import com.ecommerce.identity.application.port.LoginAttemptStore;
import com.ecommerce.identity.application.port.TokenManager;
import com.ecommerce.identity.domain.model.AccountStatus;
import com.ecommerce.identity.domain.model.RoleCode;
import com.ecommerce.identity.domain.model.StoredRefreshToken;
import com.ecommerce.identity.domain.model.UserAccount;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
@Service
public class AuthenticationService {
private final IdentityStore identityStore;
private final TokenManager tokenManager;
private final PasswordEncoder passwordEncoder;
private final LoginAuditService loginAuditService;
private final LoginAttemptStore loginAttemptStore;
private final String dummyPasswordHash;
public AuthenticationService(
IdentityStore identityStore,
TokenManager tokenManager,
PasswordEncoder passwordEncoder,
LoginAuditService loginAuditService,
LoginAttemptStore loginAttemptStore) {
this.identityStore = identityStore;
this.tokenManager = tokenManager;
this.passwordEncoder = passwordEncoder;
this.loginAuditService = loginAuditService;
this.loginAttemptStore = loginAttemptStore;
this.dummyPasswordHash = passwordEncoder.encode("identity-timing-protection");
}
@Transactional
public UserProfile register(String email, String password, String displayName) {
String normalizedEmail = normalizeEmail(email);
validatePasswordByteLength(password);
if (identityStore.accountExistsByEmail(normalizedEmail)) {
throw new IdentityException(IdentityError.EMAIL_ALREADY_REGISTERED);
}
Instant now = identityStore.currentTime();
try {
UserAccount account = identityStore.createAccount(
normalizedEmail,
passwordEncoder.encode(password),
displayName.strip(),
now
);
identityStore.assignRole(account.id(), RoleCode.CUSTOMER.name(), now);
return toProfile(account, List.of(RoleCode.CUSTOMER.name()));
} catch (DuplicateKeyException exception) {
throw new IdentityException(IdentityError.EMAIL_ALREADY_REGISTERED);
}
}
public AuthTokens login(String email, String password, LoginContext context) {
String normalizedEmail = normalizeEmail(email);
Instant now = identityStore.currentTime();
if (loginAttemptStore.isBlocked(normalizedEmail, now)) {
loginAuditService.record(
null,
normalizedEmail,
false,
IdentityError.LOGIN_TEMPORARILY_LOCKED.code(),
context,
now
);
throw new IdentityException(IdentityError.LOGIN_TEMPORARILY_LOCKED);
}
UserAccount account = identityStore.findAccountByEmail(normalizedEmail).orElse(null);
String hashToCheck = account == null ? dummyPasswordHash : account.passwordHash();
boolean passwordMatches = passwordEncoder.matches(password, hashToCheck);
if (account == null || !passwordMatches) {
LoginAttemptStore.FailureResult failure = loginAttemptStore.recordFailure(normalizedEmail, now);
IdentityError error = failure.blocked()
? IdentityError.LOGIN_TEMPORARILY_LOCKED
: IdentityError.INVALID_CREDENTIALS;
loginAuditService.record(
account == null ? null : account.id(),
normalizedEmail,
false,
error.code(),
context,
now
);
throw new IdentityException(error);
}
loginAttemptStore.clear(normalizedEmail);
if (account.status() != AccountStatus.ACTIVE) {
loginAuditService.record(
account.id(),
normalizedEmail,
false,
IdentityError.ACCOUNT_UNAVAILABLE.code(),
context,
now
);
throw new IdentityException(IdentityError.ACCOUNT_UNAVAILABLE);
}
AuthTokens tokens = issueTokens(account.id(), identityStore.findRoleCodes(account.id()), now);
loginAuditService.record(account.id(), normalizedEmail, true, null, context, now);
return tokens;
}
@Transactional
public AuthTokens refresh(String rawRefreshToken) {
Instant now = identityStore.currentTime();
String tokenHash = tokenManager.hashRefreshToken(rawRefreshToken);
StoredRefreshToken storedToken = identityStore.findRefreshTokenByHash(tokenHash)
.filter(token -> token.isUsableAt(now))
.orElseThrow(() -> new IdentityException(IdentityError.INVALID_REFRESH_TOKEN));
UserAccount account = identityStore.findAccountById(storedToken.userId())
.orElseThrow(() -> new IdentityException(IdentityError.INVALID_REFRESH_TOKEN));
if (account.status() != AccountStatus.ACTIVE) {
throw new IdentityException(IdentityError.ACCOUNT_UNAVAILABLE);
}
if (!identityStore.revokeRefreshToken(storedToken.id(), now)) {
throw new IdentityException(IdentityError.INVALID_REFRESH_TOKEN);
}
return issueTokens(account.id(), identityStore.findRoleCodes(account.id()), now);
}
@Transactional
public void logout(String rawRefreshToken) {
String tokenHash = tokenManager.hashRefreshToken(rawRefreshToken);
identityStore.findRefreshTokenByHash(tokenHash)
.ifPresent(token -> identityStore.revokeRefreshToken(
token.id(), identityStore.currentTime()));
}
@Transactional(readOnly = true)
public UserProfile currentUser(Long userId) {
UserAccount account = identityStore.findAccountById(userId)
.orElseThrow(() -> new IdentityException(IdentityError.ACCOUNT_NOT_FOUND));
return toProfile(account, identityStore.findRoleCodes(account.id()));
}
private AuthTokens issueTokens(Long userId, List<String> roleCodes, Instant now) {
TokenManager.AccessToken accessToken = tokenManager.createAccessToken(userId, roleCodes, now);
TokenManager.RefreshToken refreshToken = tokenManager.createRefreshToken(now);
identityStore.saveRefreshToken(userId, refreshToken.hash(), refreshToken.expiresAt(), now);
return new AuthTokens(
"Bearer",
accessToken.value(),
accessToken.expiresInSeconds(),
refreshToken.value()
);
}
private UserProfile toProfile(UserAccount account, List<String> roles) {
return new UserProfile(
account.id(),
account.email(),
account.displayName(),
account.status().name(),
roles
);
}
private String normalizeEmail(String email) {
return email.strip().toLowerCase(Locale.ROOT);
}
private void validatePasswordByteLength(String password) {
if (password.getBytes(StandardCharsets.UTF_8).length > 72) {
throw new IdentityException(IdentityError.INVALID_PASSWORD);
}
}
}