Part 24 of 59
Password Encoding and User Authentication
Every application needs user registration and login. This article builds a complete authentication system — from storing passwords safely to handling failed login attempts.
Never Store Passwords in Plain Text
Store a one-way hash, not the password. BCrypt is the industry standard:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
// cost factor 12 → ~250ms per hash on modern hardware
// strong enough to slow down brute-force attacks
}
BCrypt properties:
- Different salt every time — two users with the same password get different hashes
- Adjustable cost factor — increase as hardware gets faster
- Self-contained — the hash includes the algorithm, cost factor, and salt
// Encoding
String hash = passwordEncoder.encode("myPassword123!");
// → "$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// Verification
boolean matches = passwordEncoder.matches("myPassword123!", hash);
// → true
The User Entity
@Entity
@Table(name = "users")
@EntityListeners(AuditingEntityListener.class)
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(unique = true, nullable = false, length = 50)
private String username;
@Column(unique = true, nullable = false, length = 100)
private String email;
@Column(name = "password_hash", nullable = false)
private String password; // BCrypt hash
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
@Enumerated(EnumType.STRING)
private Set<Role> roles = new HashSet<>();
private boolean enabled = true;
@Column(name = "account_non_locked")
private boolean accountNonLocked = true;
@Column(name = "failed_login_attempts")
private int failedLoginAttempts = 0;
@Column(name = "locked_until")
private Instant lockedUntil;
@CreatedDate
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private Instant updatedAt;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.name()))
.toList();
}
@Override public String getPassword() { return password; }
@Override public String getUsername() { return username; }
@Override public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() {
if (!accountNonLocked) return false;
if (lockedUntil != null && lockedUntil.isAfter(Instant.now())) return false;
return true;
}
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return enabled; }
// Domain methods
public void incrementFailedAttempts() {
failedLoginAttempts++;
if (failedLoginAttempts >= 5) {
lockedUntil = Instant.now().plus(15, ChronoUnit.MINUTES);
}
}
public void resetFailedAttempts() {
failedLoginAttempts = 0;
lockedUntil = null;
}
}
public enum Role {
USER, MANAGER, ADMIN
}
-- Flyway migration
-- V10__create_users_table.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
account_non_locked BOOLEAN NOT NULL DEFAULT TRUE,
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
PRIMARY KEY (user_id, role)
);
User Registration
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 50) String username,
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 100)
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&]).+$",
message = "Password must contain uppercase, lowercase, digit, and special character"
)
String password
) {}
@Service
@RequiredArgsConstructor
public class UserRegistrationService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public User register(RegisterRequest request) {
if (userRepository.existsByUsername(request.username())) {
throw new DuplicateUsernameException(request.username());
}
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateEmailException(request.email());
}
User user = new User();
user.setUsername(request.username());
user.setEmail(request.email());
user.setPassword(passwordEncoder.encode(request.password())); // hash it
user.setRoles(Set.of(Role.USER));
return userRepository.save(user);
}
}
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserRegistrationService registrationService;
@PostMapping("/register")
public ResponseEntity<UserResponse> register(@RequestBody @Valid RegisterRequest req) {
User user = registrationService.register(req);
return ResponseEntity
.created(URI.create("/api/users/" + user.getId()))
.body(UserResponse.from(user));
}
}
Login Endpoint (Credential Validation)
For JWT-based authentication, you validate credentials and return a token:
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {}
public record LoginResponse(
String accessToken,
String tokenType,
long expiresIn
) {}
@Service
@RequiredArgsConstructor
public class AuthenticationService {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final UserRepository userRepository;
public LoginResponse login(LoginRequest request) {
try {
// Delegates to DaoAuthenticationProvider → UserDetailsService → PasswordEncoder
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(),
request.password()
)
);
UserDetails userDetails = (UserDetails) auth.getPrincipal();
// Reset failed attempts on success
userRepository.findByUsername(userDetails.getUsername())
.ifPresent(user -> {
user.resetFailedAttempts();
userRepository.save(user);
});
String token = jwtService.generateToken(userDetails);
return new LoginResponse(token, "Bearer", jwtService.getExpirationMs() / 1000);
} catch (BadCredentialsException e) {
// Increment failed attempts
userRepository.findByUsername(request.username())
.ifPresent(user -> {
user.incrementFailedAttempts();
userRepository.save(user);
});
throw new BadCredentialsException("Invalid username or password");
}
}
}
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody @Valid LoginRequest req) {
LoginResponse response = authService.login(req);
return ResponseEntity.ok(response);
}
Account Locking
The isAccountNonLocked() check in User.java handles time-based locking automatically. But you need to handle the LockedException thrown by Spring Security:
// In GlobalExceptionHandler
@ExceptionHandler(LockedException.class)
public ResponseEntity<ProblemDetail> handleLocked(LockedException ex, HttpServletRequest req) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(
HttpStatus.TOO_MANY_REQUESTS,
"Account temporarily locked due to too many failed login attempts"
);
p.setType(URI.create("https://devopsmonk.com/errors/account-locked"));
p.setTitle("Account Locked");
p.setProperty("retryAfter", "15 minutes");
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(p);
}
@ExceptionHandler(DisabledException.class)
public ResponseEntity<ProblemDetail> handleDisabled(DisabledException ex, HttpServletRequest req) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN,
"Account is disabled. Contact support."
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(p);
}
Password Change
public record ChangePasswordRequest(
@NotBlank String currentPassword,
@NotBlank @Size(min = 8, max = 100) String newPassword
) {}
@PutMapping("/api/auth/password")
public ResponseEntity<Void> changePassword(
@RequestBody @Valid ChangePasswordRequest req,
@AuthenticationPrincipal UserDetails currentUser) {
authService.changePassword(currentUser.getUsername(), req);
return ResponseEntity.noContent().build();
}
@Transactional
public void changePassword(String username, ChangePasswordRequest req) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UserNotFoundException(username));
if (!passwordEncoder.matches(req.currentPassword(), user.getPassword())) {
throw new BadCredentialsException("Current password is incorrect");
}
user.setPassword(passwordEncoder.encode(req.newPassword()));
userRepository.save(user);
}
Password Reset Flow
A safe password reset flow:
1. User requests reset → POST /api/auth/password-reset/request {email}
2. Server generates time-limited token (not the user's password)
3. Server emails the token (or a link containing it)
4. User submits token + new password → POST /api/auth/password-reset/confirm
5. Server verifies token (not expired, not used)
6. Server updates the password, invalidates the token
@Entity
@Table(name = "password_reset_tokens")
public class PasswordResetToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String token; // random, secure token
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
private boolean used = false;
public boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
}
@Transactional
public void initiatePasswordReset(String email) {
User user = userRepository.findByEmail(email)
.orElse(null); // Don't reveal whether email exists
if (user != null) {
String token = UUID.randomUUID().toString();
PasswordResetToken resetToken = new PasswordResetToken();
resetToken.setToken(token);
resetToken.setUser(user);
resetToken.setExpiresAt(Instant.now().plus(1, ChronoUnit.HOURS));
tokenRepository.save(resetToken);
emailService.sendPasswordResetEmail(user.getEmail(), token);
}
// Always return success — don't reveal email existence
}
@Transactional
public void confirmPasswordReset(String token, String newPassword) {
PasswordResetToken resetToken = tokenRepository.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("Token not found"));
if (resetToken.isExpired()) {
throw new InvalidTokenException("Token has expired");
}
if (resetToken.isUsed()) {
throw new InvalidTokenException("Token has already been used");
}
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(newPassword));
user.resetFailedAttempts();
userRepository.save(user);
resetToken.setUsed(true);
tokenRepository.save(resetToken);
}
Email Verification
Require users to verify their email before logging in:
// Add to User entity
private boolean emailVerified = false;
@Override
public boolean isEnabled() {
return enabled && emailVerified;
}
// On registration, send verification email
@Transactional
public User register(RegisterRequest req) {
User user = createUser(req); // enabled=true, emailVerified=false
String token = generateVerificationToken(user);
emailService.sendVerificationEmail(user.getEmail(), token);
return user;
}
// Verification endpoint
@GetMapping("/api/auth/verify")
public ResponseEntity<Void> verifyEmail(@RequestParam String token) {
authService.verifyEmail(token);
return ResponseEntity.ok().build();
}
Testing Authentication
@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest {
@Autowired MockMvc mockMvc;
@Autowired UserRepository userRepository;
@Autowired PasswordEncoder passwordEncoder;
@Autowired ObjectMapper mapper;
@Test
void shouldRegisterNewUser() throws Exception {
var req = new RegisterRequest("newuser", "new@example.com", "SecurePass1!");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("newuser"));
}
@Test
void shouldLoginWithValidCredentials() throws Exception {
// Setup
User user = new User();
user.setUsername("testuser");
user.setEmail("test@example.com");
user.setPassword(passwordEncoder.encode("Password1!"));
user.setRoles(Set.of(Role.USER));
userRepository.save(user);
var loginReq = new LoginRequest("testuser", "Password1!");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(loginReq)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").isNotEmpty())
.andExpect(jsonPath("$.tokenType").value("Bearer"));
}
@Test
void shouldReturn401ForWrongPassword() throws Exception {
var loginReq = new LoginRequest("testuser", "WrongPassword!");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(loginReq)))
.andExpect(status().isUnauthorized());
}
}
What You’ve Learned
- Store passwords with BCryptPasswordEncoder — never plain text, never MD5/SHA1
UserDetails+UserDetailsServiceare the contracts Spring Security uses for authenticationauthenticationManager.authenticate()validates credentials and returns anAuthentication- Track failed attempts and lock accounts temporarily to prevent brute-force attacks
- Password reset requires a time-limited, single-use token sent via email — never the password itself
- Always respond the same way to “email not found” and “password wrong” in public endpoints — don’t reveal which
Next: Article 25 — JWT Authentication — generate and validate JWTs, build a complete stateless login system.