When you create a user class you want to use as the application’s user, implement both
UserDetails and Principal. Principal only needs getName(); UserDetails needs every
method on the interface (most IDEs can generate the stubs). getName() refers to the user’s
unique identifier — here, email.
@Getter@Setter@Builder@AllArgsConstructor@NoArgsConstructor@Entity@Table(name = "_user")@EntityListeners(AuditingEntityListener.class)public class User implements UserDetails, Principal {
@Id @GeneratedValue private Integer id;
private String firstname;
private String lastname;
private LocalDate dateOfBirth;
@Column(unique = true) private String email;
private String password;
private boolean accountLocked;
private boolean enabled;
@CreatedDate @Column(nullable = false, updatable = false) // track when the user was created, via JPA auditing private LocalDateTime createdDate;
@LastModifiedDate @Column(insertable = false) // track when the user was last updated private LocalDateTime lastModifiedDate;
@Override public String getName() { return email; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; }
@Override public String getPassword() { return password; }
@Override public String getUsername() { return email; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return !accountLocked; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return enabled; }
private String fullName() { return firstname + " " + lastname; }}Since we’re using JPA auditing on the entity, annotate the main application class with
@EnableJpaAuditing:
@SpringBootApplication@EnableJpaAuditingpublic class BookNetworkApiApplication {
public static void main(String[] args) { SpringApplication.run(BookNetworkApiApplication.class, args); }}Roles
The user entity needs a List<Role> roles field, and getAuthorities() needs to be implemented
against it. Roles and users have a many-to-many relationship:
@Getter@Setter@Builder@AllArgsConstructor@NoArgsConstructor@Entity@EntityListeners(AuditingEntityListener.class)public class Role { @Id @GeneratedValue private Integer id;
@Column(unique = true) private String name;
@ManyToMany(mappedBy = "roles") // many-to-many relationship between roles and users @JsonIgnore // ignore users being fetched when we load the roles private List<User> users;
@CreatedDate @Column(nullable = false, updatable = false) private LocalDateTime createdDate;
@LastModifiedDate @Column(insertable = false) private LocalDateTime lastModifiedDate;}Add the roles field and getAuthorities() to User:
@ManyToMany(fetch = FetchType.EAGER) private List<Role> roles;
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return this.roles .stream() .map(r -> new SimpleGrantedAuthority(r.getName())) .collect(Collectors.toList()); }Repositories
// role repositorypublic interface RoleRepository extends JpaRepository<Role, Integer> {
Optional<Role> findByName(String role);
}// user repositorypublic interface UserRepository extends JpaRepository<User, Integer> { Optional<User> findByEmail(String email);}Since we’ll generate JWTs, we also need a Token entity and repository:
// token entity@Data@Builder@NoArgsConstructor@AllArgsConstructor@Entitypublic class Token {
@Id @GeneratedValue public Integer id;
@Column(unique = true) public String token;
@Enumerated(EnumType.STRING) public TokenType tokenType = TokenType.BEARER;
public boolean revoked;
public boolean expired;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") public User user;}// token repositorypublic interface TokenRepository extends JpaRepository<Token, Integer> { Optional<Token> findByToken(String token);}Security configuration
@Configuration@EnableWebSecurity@RequiredArgsConstructor@EnableMethodSecurity(securedEnabled = true) // because of role-based authpublic class SecurityConfig {
private JwtFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider;
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(withDefaults()) .csrf(AbstractHttpConfigurer::disable) // JWT auth doesn't need CSRF protection .authorizeHttpRequests(req -> req.requestMatchers( "/auth/**", "/v2/api-docs", "/v3-api-docs", "/v3-api-docs/**", "/swagger-resources", "/swagger-resources/**", "/configuration/ui", "/configuration/security", "/swagger-ui/**", "/webjars/**", "/swagger-ui.html" ).permitAll() .anyRequest() .authenticated() ) .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) // JWT is stateless, no session needed .authenticationProvider(authenticationProvider) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // JWT processing runs before username/password auth return http.build(); }}@EnableMethodSecurity(securedEnabled = true) enables @Secured on controller methods for
role-based authorization. jwtAuthFilter handles JWT processing; authenticationProvider is a
custom provider used by Spring Security for JWT auth.
Authentication provider
A BeansConfig class wires up the beans the app needs:
import lombok.RequiredArgsConstructor;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.authentication.AuthenticationProvider;import org.springframework.security.authentication.dao.DaoAuthenticationProvider;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration@RequiredArgsConstructorpublic class BeansConfig {
private final UserDetailsService userDetailsService;
@Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService); authProvider.setPasswordEncoder(passwordEncoder());
return authProvider; }
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }}DaoAuthenticationProvider is the standard provider for username/password auth backed by a
UserDetailsService. It uses userDetailsService to load user details by username, and
passwordEncoder to compare the submitted password (hashed) against the stored hash.
User details service
import com.thesarfo.book.user.UserRepository;import lombok.RequiredArgsConstructor;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;import org.springframework.transaction.annotation.Transactional;
@Service@RequiredArgsConstructorpublic class UserDetailsServiceImpl implements UserDetailsService { private final UserRepository repository; @Override @Transactional public UserDetails loadUserByUsername(String userEmail) throws UsernameNotFoundException { return repository.findByEmail(userEmail) .orElseThrow(() -> new UsernameNotFoundException("User not found")); }}JwtService
public class JwtService {
@Value("${application.security.jwt.expiration}") private long jwtExpiration;
@Value("${application.security.jwt.secret-key}") private String secretKey;
public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); }
private <T> T extractClaim(String token, Function<Claims, T> claimResolver) { final Claims claims = extractAllClaims(token); return claimResolver.apply(claims); }
private Claims extractAllClaims(String token) { return Jwts .parserBuilder() .setSigningKey(getSignInKey()) .build() .parseClaimsJws(token) .getBody(); }
public String generateToken(UserDetails userDetails) { return generateToken(new HashMap<>(), userDetails); } private String generateToken(Map<String, Object> claims, UserDetails userDetails) { return buildToken(claims, userDetails, jwtExpiration); }
private String buildToken( Map<String, Object> extraClaims, UserDetails userDetails, long jwtExpiration ) { var authorities = userDetails.getAuthorities() .stream() .map(GrantedAuthority::getAuthority) .toList(); return Jwts .builder() .setClaims(extraClaims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + jwtExpiration)) .claim("authorities", authorities) .signWith(getSignInKey()) .compact(); }
public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); }
private boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); }
private Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); }
private Key getSignInKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); }}JwtFilter
Skips filtering for the login path (/api/v1/auth), extracts the JWT from the Authorization
header, validates it against UserDetailsService, and — if valid — sets the authentication on
SecurityContextHolder so downstream filters and controllers see an authenticated user:
@Service@RequiredArgsConstructorpublic class JwtFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService;
@Override protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain ) throws ServletException, IOException { if (request.getServletPath().contains("/api/v1/auth")) { filterChain.doFilter(request, response); return; } final String authHeader = request.getHeader(AUTHORIZATION); final String jwt; final String userEmail;
if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } jwt = authHeader.substring(7); userEmail = jwtService.extractUsername(jwt);
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail); if (jwtService.isTokenValid(jwt, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); SecurityContextHolder.getContext().setAuthentication(authToken); } filterChain.doFilter(request, response); } }}Registration endpoint
@RestController@RequestMapping("auth")@RequiredArgsConstructor@Tag(name = "Authentication")public class AuthenticationController { private final AuthenticationService service;
@PostMapping("/register") @ResponseStatus(HttpStatus.ACCEPTED) public ResponseEntity<?> register( @RequestBody @Valid RegistrationRequest request ) { service.register(request); return ResponseEntity.accepted().build(); }}With the request DTO:
@Getter@Setter@Builderpublic class RegistrationRequest {
@NotEmpty(message = "First name is mandatory") @NotBlank(message = "First name is mandatory") private String firstname;
@NotEmpty(message = "Last name is mandatory") @NotBlank(message = "Last name is mandatory") private String lastname;
@Email(message = "Email is not formatted properly") @NotEmpty(message = "Email is mandatory") @NotBlank(message = "Email is mandatory") private String email;
@NotEmpty(message = "Password is mandatory") @NotBlank(message = "Password is mandatory") @Size(min = 8, message = "Password should be at least 8 characters long") private String password;}Authentication service
Retrieves the default USER role, builds the user (password encoded, account locked and disabled
until verified), saves it, and kicks off email verification with a generated activation code:
@Service@RequiredArgsConstructorpublic class AuthenticationService { private final RoleRepository roleRepository; private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; private final TokenRepository tokenRepository;
public void register(RegistrationRequest request) { var userRole = roleRepository.findByName("USER") // TODO - better exception handling .orElseThrow(() -> new IllegalStateException("ROLE USER NOT INITIALIZED")); var user = User.builder() .firstname(request.getFirstname()) .lastname(request.getLastname()) .email(request.getEmail()) .password(passwordEncoder.encode(request.getPassword())) .accountLocked(false) .enabled(false) .roles(List.of(userRole)) .build(); userRepository.save(user); sendValidationEmail(user); }
private void sendValidationEmail(User user) { var newToken = generateAndSaveActivationToken(user); // send email }
private Object generateAndSaveActivationToken(User user) { // generate activation token String generatedToken = generateActivationCode(6); var token = Token.builder() .token(generatedToken) .createdAt(LocalDateTime.now()) .expiresAt(LocalDateTime.now()) .user(user) .build(); tokenRepository.save(token);
return generatedToken; }
private String generateActivationCode(int length) { String characters = "01233456789"; StringBuilder codeBuilder = new StringBuilder(); SecureRandom secureRandom = new SecureRandom();
for (int i = 0; i < length; i++) { int randomIndex = secureRandom.nextInt(characters.length()); codeBuilder.append(characters.charAt(randomIndex)); } return codeBuilder.toString(); }}The flow end to end: validate the registration request, look up the USER role, create the user
with an encoded password (disabled until verified), save it, generate and save an activation
token, then (not shown here) email the activation code — the user activates their account by
submitting it.