UserDetailsService with a Database

The User Entity

ShopEase stores customers in a users table (JPA mapping details are in the Hibernate & JPA material):

package com.shopease.user;

import jakarta.persistence.*;

@Entity
@Table(name = "users")              // "user" is a reserved word in PostgreSQL
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String passwordHash;     // BCrypt hash, never the raw password

    @Column(nullable = false)
    private String role;             // "CUSTOMER" or "ADMIN"

    private boolean enabled = true;

    protected User() {}

    public User(String email, String passwordHash, String role) {
        this.email = email;
        this.passwordHash = passwordHash;
        this.role = role;
    }

    public Long getId() { return id; }
    public String getEmail() { return email; }
    public String getPasswordHash() { return passwordHash; }
    public String getRole() { return role; }
    public boolean isEnabled() { return enabled; }
}
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
}

Implementing UserDetailsService

package com.shopease.security;

import com.shopease.user.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class ShopEaseUserDetailsService implements UserDetailsService {

    private final UserRepository users;

    public ShopEaseUserDetailsService(UserRepository users) {
        this.users = users;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        com.shopease.user.User user = users.findByEmail(email.toLowerCase())
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getEmail())
                .password(user.getPasswordHash())
                .roles(user.getRole())
                .disabled(!user.isEnabled())
                .build();
    }
}
  • Annotating it with @Service is enough: with one UserDetailsService bean and one PasswordEncoder bean, Spring wires the DaoAuthenticationProvider for you.
  • Our entity is also called User, so the Spring builder is written with its full name.
  • The "username" can be anything unique: here it is the email address.

Note: Spring returns the same "Bad credentials" error for an unknown email and a wrong password, so attackers cannot find out which emails are registered.

Simulating the Lookup

Example

What DaoAuthenticationProvider does with your service, in plain Java (with a PBKDF2 encoder standing in for BCrypt):

import java.security.MessageDigest;
import java.util.*;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

public class Main {
    record UserDetails(String username, byte[] hash, byte[] salt, List<String> authorities, boolean enabled) {}

    static byte[] hash(String raw, byte[] salt) throws Exception {
        return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
                .generateSecret(new PBEKeySpec(raw.toCharArray(), salt, 10_000, 256)).getEncoded();
    }

    static final Map<String, UserDetails> DB = new HashMap<>();

    static String login(String email, String password) throws Exception {
        UserDetails u = DB.get(email.toLowerCase());                 // loadUserByUsername
        if (u == null) return "401 Bad credentials";                 // same message as wrong password
        if (!u.enabled()) return "401 User is disabled";
        boolean ok = MessageDigest.isEqual(u.hash(), hash(password, u.salt()));   // passwordEncoder.matches
        return ok ? "200 authenticated " + u.username() + " " + u.authorities() : "401 Bad credentials";
    }

    public static void main(String[] args) throws Exception {
        byte[] s1 = {1, 2, 3, 4, 5, 6, 7, 8}, s2 = {8, 7, 6, 5, 4, 3, 2, 1};
        DB.put("[email protected]", new UserDetails("[email protected]", hash("asha123", s1), s1, List.of("ROLE_CUSTOMER"), true));
        DB.put("[email protected]", new UserDetails("[email protected]", hash("old123", s2), s2, List.of("ROLE_CUSTOMER"), false));

        System.out.println(login("[email protected]", "asha123"));
        System.out.println(login("[email protected]", "asha124"));
        System.out.println(login("[email protected]", "x"));
        System.out.println(login("[email protected]", "old123"));
    }
}

Output

200 authenticated [email protected] [ROLE_CUSTOMER]
401 Bad credentials
401 Bad credentials
401 User is disabled