From a36c9344384e02e4ee0d2daeeafaa717ec1acc52 Mon Sep 17 00:00:00 2001 From: ZapDos7 Date: Tue, 17 Jun 2025 14:28:48 +0300 Subject: [PATCH] adding verification logic with email --- README.md | 2 +- pom.xml | 5 ++ src/main/java/com/beerapp/domain/User.java | 13 ++++ .../com/beerapp/domain/VerificationToken.java | 66 +++++++++++++++++++ .../VerificationTokenRepository.java | 13 ++++ .../com/beerapp/exceptions/ErrorCode.java | 3 + .../com/beerapp/service/EmailService.java | 40 +++++++++++ .../java/com/beerapp/service/UserService.java | 43 +++++++++++- .../web/controller/AuthController.java | 20 ++++-- src/main/resources/application.yml | 18 ++++- ...e_beers.sql => V1__create_table_beers.sql} | 0 ...e_users.sql => V2__create_table_users.sql} | 0 ...tings.sql => V3__create_table_ratings.sql} | 0 .../migration/V4__users_add_verification.sql | 12 ++++ 14 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/beerapp/domain/VerificationToken.java create mode 100644 src/main/java/com/beerapp/domain/repository/VerificationTokenRepository.java create mode 100644 src/main/java/com/beerapp/service/EmailService.java rename src/main/resources/migration/{V0.0.1__create_table_beers.sql => V1__create_table_beers.sql} (100%) rename src/main/resources/migration/{V0.0.2__create_table_users.sql => V2__create_table_users.sql} (100%) rename src/main/resources/migration/{V0.0.3__create_table_ratings.sql => V3__create_table_ratings.sql} (100%) create mode 100644 src/main/resources/migration/V4__users_add_verification.sql diff --git a/README.md b/README.md index 7ad2ca9..9dbe8d0 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ A simple Spring Boot app which displays a list of beers, information about them * Used JUnit & Mockito for testing ## To Dos -- [ ] Introduce docker - [ ] Send verification emails to users upon sign up / add verification logic +- [ ] Introduce docker - [ ] Implement FE - [ ] Implement recommendation system diff --git a/pom.xml b/pom.xml index f79d4cf..7d1fd01 100644 --- a/pom.xml +++ b/pom.xml @@ -121,6 +121,11 @@ spring-security-crypto + + + org.springframework.boot + spring-boot-starter-mail + diff --git a/src/main/java/com/beerapp/domain/User.java b/src/main/java/com/beerapp/domain/User.java index b0933f6..3e6e15d 100644 --- a/src/main/java/com/beerapp/domain/User.java +++ b/src/main/java/com/beerapp/domain/User.java @@ -29,6 +29,10 @@ public class User implements UserDetails { @Column(name = "sign_up_date") @CreatedDate private LocalDateTime signUpDate; + @Column(name = "verified") + private boolean verified = false; + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL) + private VerificationToken verificationToken; public User() { } @@ -37,6 +41,7 @@ public User(String email, boolean isAdmin) { this.email = email; this.role = isAdmin ? Role.ADMIN : Role.USER; this.signUpDate = LocalDateTime.now(); + this.verified = false; } public Long getId() { @@ -80,6 +85,14 @@ public void setSignUpDate(LocalDateTime signUpDate) { this.signUpDate = signUpDate; } + public boolean isVerified() { + return verified; + } + + public void setVerified(boolean verified) { + this.verified = verified; + } + // --- Implemented from UserDetails --- @Override public String getUsername() { diff --git a/src/main/java/com/beerapp/domain/VerificationToken.java b/src/main/java/com/beerapp/domain/VerificationToken.java new file mode 100644 index 0000000..a41e269 --- /dev/null +++ b/src/main/java/com/beerapp/domain/VerificationToken.java @@ -0,0 +1,66 @@ +package com.beerapp.domain; + +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "token") +public class VerificationToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", unique = true, nullable = false) + private Long id; + + @Column(name = "token", columnDefinition = "BINARY(16)", nullable = false) + private UUID token; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false, name = "user_id") + private User user; + + @Column(name = "expiry_date") + private Instant expiryDate; + + public VerificationToken() { + } + + public VerificationToken(UUID token, User user, Instant expiryDate) { + this.token = token; + this.user = user; + this.expiryDate = expiryDate; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public UUID getToken() { + return token; + } + + public void setToken(UUID token) { + this.token = token; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public Instant getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(Instant expiryDate) { + this.expiryDate = expiryDate; + } +} diff --git a/src/main/java/com/beerapp/domain/repository/VerificationTokenRepository.java b/src/main/java/com/beerapp/domain/repository/VerificationTokenRepository.java new file mode 100644 index 0000000..f18caa8 --- /dev/null +++ b/src/main/java/com/beerapp/domain/repository/VerificationTokenRepository.java @@ -0,0 +1,13 @@ +package com.beerapp.domain.repository; + +import com.beerapp.domain.VerificationToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface VerificationTokenRepository extends JpaRepository { + Optional findByToken(UUID token); +} diff --git a/src/main/java/com/beerapp/exceptions/ErrorCode.java b/src/main/java/com/beerapp/exceptions/ErrorCode.java index 6ebe883..8dc923f 100644 --- a/src/main/java/com/beerapp/exceptions/ErrorCode.java +++ b/src/main/java/com/beerapp/exceptions/ErrorCode.java @@ -5,4 +5,7 @@ public class ErrorCode { public static final String RATING_NOT_FOUND = "rating.not.found"; public static final String USER_NOT_FOUND = "user.not.found"; public static final String UNSUPPORTED_COUNTRY = "country.unsupported"; + public static final String AUTH_TOKEN_NOT_FOUND = "token.notFound"; + public static final String AUTH_TOKEN_INVALID = "token.invalid"; + public static final String AUTH_TOKEN_EXPIRED = "token.expired"; } diff --git a/src/main/java/com/beerapp/service/EmailService.java b/src/main/java/com/beerapp/service/EmailService.java new file mode 100644 index 0000000..77fcfc2 --- /dev/null +++ b/src/main/java/com/beerapp/service/EmailService.java @@ -0,0 +1,40 @@ +package com.beerapp.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.MailException; +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 { + + @Value("${spring.mail.username}") + private String fromAddress; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final JavaMailSender javaMailSender; + + public EmailService(JavaMailSender javaMailSender) { + this.javaMailSender = javaMailSender; + } + + @Async + public void sendVerificationEmail(String toAddress, String token) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(toAddress); + message.setSubject("Email Verification"); + String confirmationUrl = "http://yourapp.com/api/auth/verify?token=" + token; + String body = "Please click on the link below to verify your email:\n" + confirmationUrl; + message.setText(body); + try { + javaMailSender.send(message); + } catch (MailException e) { + logger.error("malakia"); + } + } +} diff --git a/src/main/java/com/beerapp/service/UserService.java b/src/main/java/com/beerapp/service/UserService.java index ae78917..5ba38eb 100644 --- a/src/main/java/com/beerapp/service/UserService.java +++ b/src/main/java/com/beerapp/service/UserService.java @@ -1,29 +1,43 @@ package com.beerapp.service; import com.beerapp.domain.User; +import com.beerapp.domain.VerificationToken; import com.beerapp.domain.enums.Role; import com.beerapp.domain.repository.UserRepository; +import com.beerapp.domain.repository.VerificationTokenRepository; +import com.beerapp.exceptions.BadRequestException; +import com.beerapp.exceptions.ErrorCode; +import com.beerapp.exceptions.NotFoundException; import com.beerapp.web.request.SignUpRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.UUID; @Service public class UserService implements UserDetailsService { + @Value("${beer.token-expiration}") + private int tokenExpirationDuration; + private final Logger logger = LoggerFactory.getLogger(getClass()); private final UserRepository userRepository; + private final VerificationTokenRepository verificationTokenRepository; private final PasswordEncoder passwordEncoder; - public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + public UserService(UserRepository userRepository, VerificationTokenRepository verificationTokenRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; + this.verificationTokenRepository = verificationTokenRepository; this.passwordEncoder = passwordEncoder; } @@ -44,7 +58,13 @@ public User createUser(SignUpRequest request) { logger.trace("Creating new user"); User user = new User(request.getEmail(), request.getIsAdmin()); user.setPassword(passwordEncoder.encode(request.getPassword())); - return userRepository.save(user); + userRepository.save(user); + // verification token + UUID token = UUID.randomUUID(); + VerificationToken verificationToken = new VerificationToken(token, user, Instant.now().plus(tokenExpirationDuration, ChronoUnit.MINUTES)); + verificationTokenRepository.save(verificationToken); + // return + return user; } public void deleteById(Long userId, boolean userRequest) { @@ -55,4 +75,23 @@ public void deleteById(Long userId, boolean userRequest) { } userRepository.deleteById(userId); } + + public void verifyUser(UUID token) throws BadRequestException { + var verificationToken = verificationTokenRepository.findByToken(token).orElseThrow(() -> { + logger.error("Could not find token with value {}", token); + return new BadRequestException(ErrorCode.AUTH_TOKEN_NOT_FOUND); + }); + if (verificationToken == null) { + throw new BadRequestException(ErrorCode.AUTH_TOKEN_INVALID); + } + if (verificationToken.getExpiryDate().isBefore(Instant.now())) { + throw new BadRequestException(ErrorCode.AUTH_TOKEN_EXPIRED); + } + User user = verificationToken.getUser(); + user.setVerified(true); + userRepository.save(user); + + // cleanup token + verificationTokenRepository.delete(verificationToken); + } } diff --git a/src/main/java/com/beerapp/web/controller/AuthController.java b/src/main/java/com/beerapp/web/controller/AuthController.java index 93fd365..3776e90 100644 --- a/src/main/java/com/beerapp/web/controller/AuthController.java +++ b/src/main/java/com/beerapp/web/controller/AuthController.java @@ -1,20 +1,21 @@ package com.beerapp.web.controller; -import com.beerapp.domain.User; import com.beerapp.config.JwtUtil; +import com.beerapp.domain.User; +import com.beerapp.exceptions.BadRequestException; +import com.beerapp.exceptions.NotFoundException; import com.beerapp.service.UserService; -import com.beerapp.web.utils.AuthResponse; import com.beerapp.web.request.LogInRequest; import com.beerapp.web.request.SignUpRequest; +import com.beerapp.web.utils.AuthResponse; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; @RestController @RequestMapping("/auth") @@ -42,4 +43,11 @@ public ResponseEntity login(@RequestBody LogInRequest request) { String token = jwtUtil.generateToken((UserDetails) auth.getPrincipal()); return ResponseEntity.ok(new AuthResponse(token)); } + + // receive verification endpoint + @GetMapping("/verify") + public ResponseEntity verifyAccount(@RequestParam("token") UUID token) throws NotFoundException, BadRequestException { + userService.verifyUser(token); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 131d00d..ed77a01 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,6 +37,19 @@ spring: redis: host: localhost port: 6379 + # Emails + mail: + host: 'smtp.gmail.com' + port: 587 + username: beer@gmail.com + password: some-password + properties: + mail: + debug: true + transport.protocol: smtp + smtp: + auth: true + starttls.enable: true server: port: 8080 @@ -58,4 +71,7 @@ management: include: health # Security -jwt.secret: "dkS2n9tBvmn3XsZ5qW8RmEpLcTY9vPxDfajGhKwM" \ No newline at end of file +jwt.secret: "dkS2n9tBvmn3XsZ5qW8RmEpLcTY9vPxDfajGhKwM" + +beer: + token-expiration: 15 # in minutes \ No newline at end of file diff --git a/src/main/resources/migration/V0.0.1__create_table_beers.sql b/src/main/resources/migration/V1__create_table_beers.sql similarity index 100% rename from src/main/resources/migration/V0.0.1__create_table_beers.sql rename to src/main/resources/migration/V1__create_table_beers.sql diff --git a/src/main/resources/migration/V0.0.2__create_table_users.sql b/src/main/resources/migration/V2__create_table_users.sql similarity index 100% rename from src/main/resources/migration/V0.0.2__create_table_users.sql rename to src/main/resources/migration/V2__create_table_users.sql diff --git a/src/main/resources/migration/V0.0.3__create_table_ratings.sql b/src/main/resources/migration/V3__create_table_ratings.sql similarity index 100% rename from src/main/resources/migration/V0.0.3__create_table_ratings.sql rename to src/main/resources/migration/V3__create_table_ratings.sql diff --git a/src/main/resources/migration/V4__users_add_verification.sql b/src/main/resources/migration/V4__users_add_verification.sql new file mode 100644 index 0000000..e27c13f --- /dev/null +++ b/src/main/resources/migration/V4__users_add_verification.sql @@ -0,0 +1,12 @@ +-- Add verification boolean +ALTER TABLE users +ADD verified TINYINT(1) DEFAULT '0'; + +-- Add tokens table +CREATE TABLE IF NOT EXISTS `token` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `token` BINARY(16) NOT NULL, + `user_id` BIGINT NOT NULL, + `expiry_date` DATETIME, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +); \ No newline at end of file