Skip to content
thesarfo

Recipe

Spring Academy: JWT Sign-in & Sign-up

A full JWT authentication implementation — the User/Role/Token entities, SecurityFilterChain, JwtFilter, JwtService, and a registration endpoint with email verification.

views 0

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
@EnableJpaAuditing
public 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:

Role.java
@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 repository
public interface RoleRepository extends JpaRepository<Role, Integer> {
Optional<Role> findByName(String role);
}
// user repository
public 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
@Entity
public 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 repository
public interface TokenRepository extends JpaRepository<Token, Integer> {
Optional<Token> findByToken(String token);
}

Security configuration

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(securedEnabled = true) // because of role-based auth
public 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
@RequiredArgsConstructor
public 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
@RequiredArgsConstructor
public 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
@RequiredArgsConstructor
public 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
@Builder
public 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
@RequiredArgsConstructor
public 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.