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
- Enable "2-Step Verification"
- Create an "App Password" in Google settings
- 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
| Test | Description |
|---|---|
| Registration | Email sent, user not active |
| Verification | Valid token activates account |
| Token expired | Error if token expired |
| Login | Fails if not verified |
| Forgot password | Email sent |
| Reset password | Password changed |
| Change role | Admin can change role |
Bonus
| Feature | Points |
|---|---|
| Rate limiting on auth | +3 |
| Login history | +3 |
| Lockout after X attempts | +5 |
| 2FA (TOTP) | +10 |
| OAuth2 (Google) | +10 |