Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@
<artifactId>spring-security-crypto</artifactId>
</dependency>

<!-- Email Sending -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

</dependencies>
<build>
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/com/beerapp/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
66 changes: 66 additions & 0 deletions src/main/java/com/beerapp/domain/VerificationToken.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<VerificationToken, Long> {
Optional<VerificationToken> findByToken(UUID token);
}
3 changes: 3 additions & 0 deletions src/main/java/com/beerapp/exceptions/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
40 changes: 40 additions & 0 deletions src/main/java/com/beerapp/service/EmailService.java
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: email HTML


@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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add enable async on application

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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

url from application.yml

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");
}
}
}
43 changes: 41 additions & 2 deletions src/main/java/com/beerapp/service/UserService.java
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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) {
Expand All @@ -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);
}
}
20 changes: 14 additions & 6 deletions src/main/java/com/beerapp/web/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -42,4 +43,11 @@ public ResponseEntity<AuthResponse> login(@RequestBody LogInRequest request) {
String token = jwtUtil.generateToken((UserDetails) auth.getPrincipal());
return ResponseEntity.ok(new AuthResponse(token));
}

// receive verification endpoint
@GetMapping("/verify")
public ResponseEntity<String> verifyAccount(@RequestParam("token") UUID token) throws NotFoundException, BadRequestException {
userService.verifyUser(token);
return ResponseEntity.noContent().build();
}
}
18 changes: 17 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,4 +71,7 @@ management:
include: health

# Security
jwt.secret: "dkS2n9tBvmn3XsZ5qW8RmEpLcTY9vPxDfajGhKwM"
jwt.secret: "dkS2n9tBvmn3XsZ5qW8RmEpLcTY9vPxDfajGhKwM"

beer:
token-expiration: 15 # in minutes
12 changes: 12 additions & 0 deletions src/main/resources/migration/V4__users_add_verification.sql
Original file line number Diff line number Diff line change
@@ -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`)
);