Skip to main content

Project D: Authentication System with Email Verification

Description​

Develop a complete RESTful API for user management with email verification, role management, and password recovery.


Required Features​

1. Registration with Email Verification​

POST /api/auth/register     - Registration (sends verification email)
GET /api/auth/verify - Validate account via email token
POST /api/auth/resend - Resend verification email

2. Login and JWT​

POST /api/auth/login        - Login (valid account required)
POST /api/auth/refresh - Refresh token
POST /api/auth/logout - Logout

3. Password Recovery​

POST /api/auth/forgot-password  - Request reset (sends email)
POST /api/auth/reset-password - Change password via token

4. User Management​

GET    /api/users/me            - My profile
PUT /api/users/me - Edit my profile
PUT /api/users/me/password - Change my password
GET /api/admin/users - List users (ADMIN)
PUT /api/admin/users/{id}/role - Change role (ADMIN)
DELETE /api/admin/users/{id} - Delete user (ADMIN)

Database Schema​

-- Table users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
role VARCHAR(20) DEFAULT 'USER',
enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);

-- Table verification_tokens (email verification)
CREATE TABLE verification_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
user_id BIGINT REFERENCES users(id),
expiry_date TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Table password_reset_tokens
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
user_id BIGINT REFERENCES users(id),
expiry_date TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

JPA Entities​

User Entity​

package com.example.auth.model;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class User {

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

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

@Column(nullable = false)
private String password;

private String firstName;
private String lastName;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private Role role = Role.USER;

@Builder.Default
private boolean enabled = false; // false until email verification

private LocalDateTime createdAt;
private LocalDateTime updatedAt;

@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}

@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}

public enum Role {
USER, ADMIN
}
}

VerificationToken Entity​

package com.example.auth.model;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "verification_tokens")
@Getter @Setter
@NoArgsConstructor
public class VerificationToken {

private static final int EXPIRATION_HOURS = 24;

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

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

@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;

private LocalDateTime expiryDate;

@Builder.Default
private boolean used = false;

private LocalDateTime createdAt;

public VerificationToken(User user) {
this.user = user;
this.token = UUID.randomUUID().toString();
this.expiryDate = LocalDateTime.now().plusHours(EXPIRATION_HOURS);
this.createdAt = LocalDateTime.now();
}

public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}

PasswordResetToken Entity​

package com.example.auth.model;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "password_reset_tokens")
@Getter @Setter
@NoArgsConstructor
public class PasswordResetToken {

private static final int EXPIRATION_HOURS = 1; // 1 hour for reset

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

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

@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;

private LocalDateTime expiryDate;

@Builder.Default
private boolean used = false;

private LocalDateTime createdAt;

public PasswordResetToken(User user) {
this.user = user;
this.token = UUID.randomUUID().toString();
this.expiryDate = LocalDateTime.now().plusHours(EXPIRATION_HOURS);
this.createdAt = LocalDateTime.now();
}

public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}

DTOs​

Request DTOs​

// RegisterRequest.java
package com.example.auth.dto.request;

import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class RegisterRequest {

@NotBlank(message = "Email required")
@Email(message = "Invalid email")
private String email;

@NotBlank(message = "Password required")
@Size(min = 8, message = "Minimum 8 characters")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "Must contain uppercase, lowercase, and number"
)
private String password;

@NotBlank(message = "First name required")
private String firstName;

@NotBlank(message = "Last name required")
private String lastName;
}
// LoginRequest.java
@Data
public class LoginRequest {

@NotBlank(message = "Email required")
@Email
private String email;

@NotBlank(message = "Password required")
private String password;
}
// ForgotPasswordRequest.java
@Data
public class ForgotPasswordRequest {

@NotBlank(message = "Email required")
@Email
private String email;
}
// ResetPasswordRequest.java
@Data
public class ResetPasswordRequest {

@NotBlank(message = "Token required")
private String token;

@NotBlank(message = "New password required")
@Size(min = 8)
private String newPassword;
}
// ChangeRoleRequest.java
@Data
public class ChangeRoleRequest {

@NotNull
private User.Role role;
}

Response DTOs​

// AuthResponse.java
@Data
@Builder
public class AuthResponse {
private String token;
private String type;
private Long expiresIn;
private UserDto user;
}
// UserDto.java
@Data
@Builder
public class UserDto {
private Long id;
private String email;
private String firstName;
private String lastName;
private String role;
private boolean enabled;
private LocalDateTime createdAt;
}
// MessageResponse.java
@Data
@AllArgsConstructor
public class MessageResponse {
private String message;
}

Services​

EmailService​

package com.example.auth.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class EmailService {

private static final Logger log = LoggerFactory.getLogger(EmailService.class);

private final JavaMailSender mailSender;

@Value("${app.base-url}")
private String baseUrl;

@Value("${spring.mail.username}")
private String fromEmail;

public EmailService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}

@Async
public void sendVerificationEmail(String to, String token) {
String verifyUrl = baseUrl + "/api/auth/verify?token=" + token;

SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("Verify your account");
message.setText(
"Welcome!\n\n" +
"Click the following link to verify your account:\n\n" +
verifyUrl + "\n\n" +
"This link expires in 24 hours.\n\n" +
"If you did not create an account, ignore this email."
);

try {
mailSender.send(message);
log.info("Verification email sent to: {}", to);
} catch (Exception e) {
log.error("Error sending email to {}: {}", to, e.getMessage());
}
}

@Async
public void sendPasswordResetEmail(String to, String token) {
String resetUrl = baseUrl + "/reset-password?token=" + token;

SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("Reset your password");
message.setText(
"You have requested a password reset.\n\n" +
"Click the following link to choose a new password:\n\n" +
resetUrl + "\n\n" +
"This link expires in 1 hour.\n\n" +
"If you did not make this request, ignore this email."
);

try {
mailSender.send(message);
log.info("Reset email sent to: {}", to);
} catch (Exception e) {
log.error("Error sending reset email to {}: {}", to, e.getMessage());
}
}
}

AuthService​

package com.example.auth.service;

import com.example.auth.dto.request.*;
import com.example.auth.dto.response.*;
import com.example.auth.exception.*;
import com.example.auth.model.*;
import com.example.auth.repository.*;
import com.example.auth.security.JwtService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuthService {

private static final Logger log = LoggerFactory.getLogger(AuthService.class);

private final UserRepository userRepository;
private final VerificationTokenRepository verificationTokenRepository;
private final PasswordResetTokenRepository passwordResetTokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final EmailService emailService;

// Constructor injection...

@Transactional
public MessageResponse register(RegisterRequest request) {
// Check if email exists
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException("Email already in use");
}

// Create user (not active)
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.firstName(request.getFirstName())
.lastName(request.getLastName())
.role(User.Role.USER)
.enabled(false)
.build();

userRepository.save(user);

// Create verification token
VerificationToken verificationToken = new VerificationToken(user);
verificationTokenRepository.save(verificationToken);

// Send email
emailService.sendVerificationEmail(user.getEmail(), verificationToken.getToken());

log.info("User registered: {}", user.getEmail());

return new MessageResponse(
"Registration successful! Check your email to activate your account."
);
}

@Transactional
public MessageResponse verifyEmail(String token) {
VerificationToken verificationToken = verificationTokenRepository
.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("Invalid token"));

if (verificationToken.isUsed()) {
throw new InvalidTokenException("Token already used");
}

if (verificationToken.isExpired()) {
throw new InvalidTokenException("Token expired");
}

// Activate user
User user = verificationToken.getUser();
user.setEnabled(true);
userRepository.save(user);

// Mark token as used
verificationToken.setUsed(true);
verificationTokenRepository.save(verificationToken);

log.info("Account activated: {}", user.getEmail());

return new MessageResponse("Account successfully activated! You can now log in.");
}

@Transactional
public MessageResponse resendVerificationEmail(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException("User not found"));

if (user.isEnabled()) {
throw new BadRequestException("Account already active");
}

// Invalidate old tokens
verificationTokenRepository.deleteByUser(user);

// Create new token
VerificationToken newToken = new VerificationToken(user);
verificationTokenRepository.save(newToken);

// Send email
emailService.sendVerificationEmail(user.getEmail(), newToken.getToken());

return new MessageResponse("Verification email resent");
}

public AuthResponse login(LoginRequest request) {
// Check if account is active
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new BadCredentialsException("Incorrect email or password"));

if (!user.isEnabled()) {
throw new AccountNotVerifiedException("Account not verified. Check your email.");
}

// Authenticate
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);

// Generate JWT
String token = jwtService.generateToken(user);

log.info("Login successful: {}", user.getEmail());

return AuthResponse.builder()
.token(token)
.type("Bearer")
.expiresIn(jwtService.getExpiration())
.user(mapToUserDto(user))
.build();
}

@Transactional
public MessageResponse forgotPassword(ForgotPasswordRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElse(null);

// Always return the same message (security)
if (user != null && user.isEnabled()) {
// Invalidate old tokens
passwordResetTokenRepository.deleteByUser(user);

// Create new token
PasswordResetToken resetToken = new PasswordResetToken(user);
passwordResetTokenRepository.save(resetToken);

// Send email
emailService.sendPasswordResetEmail(user.getEmail(), resetToken.getToken());
}

return new MessageResponse(
"If the email exists, a reset link has been sent."
);
}

@Transactional
public MessageResponse resetPassword(ResetPasswordRequest request) {
PasswordResetToken resetToken = passwordResetTokenRepository
.findByToken(request.getToken())
.orElseThrow(() -> new InvalidTokenException("Invalid token"));

if (resetToken.isUsed()) {
throw new InvalidTokenException("Token already used");
}

if (resetToken.isExpired()) {
throw new InvalidTokenException("Token expired");
}

// Change password
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);

// Mark token as used
resetToken.setUsed(true);
passwordResetTokenRepository.save(resetToken);

log.info("Password reset: {}", user.getEmail());

return new MessageResponse("Password successfully reset");
}

private UserDto mapToUserDto(User user) {
return UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.role(user.getRole().name())
.enabled(user.isEnabled())
.createdAt(user.getCreatedAt())
.build();
}
}

UserService (Admin)​

package com.example.auth.service;

import com.example.auth.dto.request.ChangeRoleRequest;
import com.example.auth.dto.response.UserDto;
import com.example.auth.model.User;
import com.example.auth.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

private final UserRepository userRepository;

public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}

public Page<UserDto> getAllUsers(Pageable pageable) {
return userRepository.findAll(pageable)
.map(this::mapToDto);
}

@Transactional
public UserDto changeRole(Long userId, ChangeRoleRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));

user.setRole(request.getRole());
User updated = userRepository.save(user);

return mapToDto(updated);
}

@Transactional
public void deleteUser(Long userId) {
if (!userRepository.existsById(userId)) {
throw new RuntimeException("User not found");
}
userRepository.deleteById(userId);
}

private UserDto mapToDto(User user) {
return UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.role(user.getRole().name())
.enabled(user.isEnabled())
.createdAt(user.getCreatedAt())
.build();
}
}

Controllers​

AuthController​

package com.example.auth.controller;

import com.example.auth.dto.request.*;
import com.example.auth.dto.response.*;
import com.example.auth.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

private final AuthService authService;

public AuthController(AuthService authService) {
this.authService = authService;
}

@PostMapping("/register")
public ResponseEntity<MessageResponse> register(
@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}

@GetMapping("/verify")
public ResponseEntity<MessageResponse> verifyEmail(@RequestParam String token) {
return ResponseEntity.ok(authService.verifyEmail(token));
}

@PostMapping("/resend")
public ResponseEntity<MessageResponse> resendVerification(
@RequestParam String email) {
return ResponseEntity.ok(authService.resendVerificationEmail(email));
}

@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}

@PostMapping("/forgot-password")
public ResponseEntity<MessageResponse> forgotPassword(
@Valid @RequestBody ForgotPasswordRequest request) {
return ResponseEntity.ok(authService.forgotPassword(request));
}

@PostMapping("/reset-password")
public ResponseEntity<MessageResponse> resetPassword(
@Valid @RequestBody ResetPasswordRequest request) {
return ResponseEntity.ok(authService.resetPassword(request));
}
}

AdminController​

package com.example.auth.controller;

import com.example.auth.dto.request.ChangeRoleRequest;
import com.example.auth.dto.response.UserDto;
import com.example.auth.service.UserService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {

private final UserService userService;

public AdminController(UserService userService) {
this.userService = userService;
}

@GetMapping("/users")
public ResponseEntity<Page<UserDto>> getAllUsers(Pageable pageable) {
return ResponseEntity.ok(userService.getAllUsers(pageable));
}

@PutMapping("/users/{id}/role")
public ResponseEntity<UserDto> changeUserRole(
@PathVariable Long id,
@Valid @RequestBody ChangeRoleRequest request) {
return ResponseEntity.ok(userService.changeRole(id, request));
}

@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}

Email Configuration​

application.yml​

spring:
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true

app:
base-url: http://localhost:8080
jwt:
secret: ${JWT_SECRET}
expiration: 86400000 # 24 hours

For Gmail​

  1. Enable "2-Step Verification"
  2. Create an "App Password" in Google settings
  3. Use this password in MAIL_PASSWORD

Alternative: MailHog (Dev)​

# Start MailHog (intercepts emails)
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog

# Configuration application-dev.yml
spring:
mail:
host: localhost
port: 1025
# No need for username/password

# View emails: http://localhost:8025

Complete Flow​

1. Registration​

POST /api/auth/register
{
"email": "john@example.com",
"password": "Password123",
"firstName": "John",
"lastName": "Doe"
}

Response: { "message": "Registration successful! Check your email..." }

2. Email Verification​

GET /api/auth/verify?token=abc123-def456-...

Response: { "message": "Account successfully activated!" }

3. Login​

POST /api/auth/login
{
"email": "john@example.com",
"password": "Password123"
}

Response: {
"token": "eyJhbGciOiJIUzI1NiIs...",
"type": "Bearer",
"expiresIn": 86400000,
"user": {
"id": 1,
"email": "john@example.com",
"role": "USER"
}
}

4. Forgot Password​

POST /api/auth/forgot-password
{
"email": "john@example.com"
}

Response: { "message": "If the email exists, a link..." }

5. Reset Password​

POST /api/auth/reset-password
{
"token": "xyz789-...",
"newPassword": "NewPassword456"
}

Response: { "message": "Password successfully reset" }

6. Change Role (Admin)​

PUT /api/admin/users/1/role
Authorization: Bearer <admin-token>
{
"role": "ADMIN"
}

Response: {
"id": 1,
"email": "john@example.com",
"role": "ADMIN"
}

Required Tests​

TestDescription
RegistrationEmail sent, user not active
VerificationValid token activates account
Token expiredError if token expired
LoginFails if not verified
Forgot passwordEmail sent
Reset passwordPassword changed
Change roleAdmin can change role

Bonus​

FeaturePoints
Rate limiting on auth+3
Login history+3
Lockout after X attempts+5
2FA (TOTP)+10
OAuth2 (Google)+10

Back to Final Project