Skip to content

Commit afeca9b

Browse files
authored
Add backend email verification (#226)
* feat(auth): add InvalidActivationTokenException handling Introduce `InvalidActivationTokenException` to provide a custom exception for handling invalid or expired activation tokens. Extend the `ErrorCode` enum with `INVALID_ACTIVATION_TOKEN` to support this exception with a unique error code. * feat(auth): add method to find activation token by value Introduce `findByToken` method in `UserActivationTokenRepository` to enable retrieval of activation tokens by their string value. This enhances query capabilities for token-based operations. * feat(auth): add email verification via activation token Introduce `verifyEmail` method in `UserActivationServiceImpl` to handle email verification using activation tokens. Validate tokens for existence and expiration, update user status to `ACTIVATED`, and remove used tokens. Add logging for successful activations and handle edge cases such as already activated accounts. * feat(auth): add email verification endpoint in controller Introduce `verifyEmail` GET endpoint in `UserActivationController` to handle user account activation via an email-provided activation token. Annotate with OpenAPI documentation, validate input tokens, and handle exceptions for invalid or already activated accounts. * feat(security): allow public access to user activation verification endpoint Add `/api/v1/user-activation/verify` to permitted paths in `SecurityConfig`. This enables unauthenticated users to verify their accounts via activation tokens. * style(auth): remove unnecessary blank line in UserActivationServiceImpl Remove redundant blank line in `activateUser` method to improve code readability and adhere to consistent formatting.
1 parent 1c2b03a commit afeca9b

7 files changed

Lines changed: 104 additions & 1 deletion

File tree

src/main/java/com/itasocialacademy/oitassist/auth/controller/UserActivationController.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
package com.itasocialacademy.oitassist.auth.controller;
22

33
import com.itasocialacademy.oitassist.auth.dto.request.ResendVerificationMailRequest;
4+
import com.itasocialacademy.oitassist.auth.exceptions.InvalidActivationTokenException;
45
import com.itasocialacademy.oitassist.user.exceptions.ActivationTokenSendingTimeoutException;
56
import com.itasocialacademy.oitassist.auth.service.interfaces.UserActivationService;
67
import com.itasocialacademy.oitassist.core.web.ErrorResponse;
78
import com.itasocialacademy.oitassist.auth.exceptions.UserAlreadyActivatedException;
89
import com.itasocialacademy.oitassist.user.exceptions.UserNotFoundException;
910
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.Parameter;
1012
import io.swagger.v3.oas.annotations.media.Content;
1113
import io.swagger.v3.oas.annotations.media.Schema;
1214
import io.swagger.v3.oas.annotations.responses.ApiResponse;
1315
import io.swagger.v3.oas.annotations.responses.ApiResponses;
1416
import io.swagger.v3.oas.annotations.tags.Tag;
1517
import jakarta.validation.Valid;
18+
import jakarta.validation.constraints.NotBlank;
1619
import lombok.RequiredArgsConstructor;
1720
import org.springframework.http.HttpStatus;
21+
import org.springframework.web.bind.annotation.GetMapping;
1822
import org.springframework.web.bind.annotation.PostMapping;
1923
import org.springframework.web.bind.annotation.RequestBody;
2024
import org.springframework.web.bind.annotation.RequestMapping;
25+
import org.springframework.web.bind.annotation.RequestParam;
2126
import org.springframework.web.bind.annotation.ResponseStatus;
2227
import org.springframework.web.bind.annotation.RestController;
2328

@@ -33,6 +38,41 @@
3338
public class UserActivationController {
3439
private final UserActivationService userActivationService;
3540

41+
/**
42+
* Activates a user account by activation token.
43+
*
44+
* @param token activation token sent by email
45+
* @throws InvalidActivationTokenException if the token is invalid or expired
46+
* @throws UserAlreadyActivatedException if the account is already activated
47+
*/
48+
@GetMapping("/verify")
49+
@ResponseStatus(HttpStatus.OK)
50+
@Operation(
51+
summary = "Verify user email",
52+
description = "Activates a user account using the activation token from the email link.")
53+
@ApiResponses(value = {
54+
@ApiResponse(
55+
responseCode = "200",
56+
description = "User account successfully activated"),
57+
@ApiResponse(
58+
responseCode = "400",
59+
description = "Activation token is invalid or expired",
60+
content = @Content(
61+
mediaType = "application/json",
62+
schema = @Schema(implementation = ErrorResponse.class))),
63+
@ApiResponse(
64+
responseCode = "409",
65+
description = "User account is already activated",
66+
content = @Content(
67+
mediaType = "application/json",
68+
schema = @Schema(implementation = ErrorResponse.class)))
69+
})
70+
public void verifyEmail(
71+
@Parameter(description = "Activation token from email link",
72+
required = true) @RequestParam @NotBlank String token) {
73+
userActivationService.verifyEmail(token);
74+
}
75+
3676
/**
3777
* Resends an activation email to a user whose account is not yet activated.
3878
*
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.itasocialacademy.oitassist.auth.dao.repository;
22

33
import com.itasocialacademy.oitassist.user.dao.model.UserActivationToken;
4+
import java.util.Optional;
45
import org.springframework.data.jpa.repository.JpaRepository;
56
import org.springframework.stereotype.Repository;
67

78
@Repository
89
public interface UserActivationTokenRepository extends JpaRepository<UserActivationToken, Long> {
10+
Optional<UserActivationToken> findByToken(String token);
911
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.itasocialacademy.oitassist.auth.exceptions;
2+
3+
import com.itasocialacademy.oitassist.core.enums.ErrorCode;
4+
import com.itasocialacademy.oitassist.core.exceptions.BusinessException;
5+
6+
public class InvalidActivationTokenException extends BusinessException {
7+
public InvalidActivationTokenException() {
8+
super("Activation token is invalid or expired", ErrorCode.INVALID_ACTIVATION_TOKEN);
9+
}
10+
}

src/main/java/com/itasocialacademy/oitassist/auth/service/UserActivationServiceImpl.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.itasocialacademy.oitassist.auth.service;
22

3+
import com.itasocialacademy.oitassist.auth.dao.repository.UserActivationTokenRepository;
34
import com.itasocialacademy.oitassist.auth.dto.event.ActivationAccountEvent;
5+
import com.itasocialacademy.oitassist.auth.exceptions.InvalidActivationTokenException;
46
import com.itasocialacademy.oitassist.auth.exceptions.UserAlreadyActivatedException;
57
import com.itasocialacademy.oitassist.auth.service.interfaces.UserActivationService;
68
import com.itasocialacademy.oitassist.user.dao.enums.UserStatus;
@@ -9,17 +11,51 @@
911
import com.itasocialacademy.oitassist.user.dao.repository.UserRepository;
1012
import com.itasocialacademy.oitassist.user.exceptions.UserNotFoundException;
1113
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
1215
import org.springframework.context.ApplicationEventPublisher;
1316
import org.springframework.stereotype.Service;
1417
import org.springframework.transaction.annotation.Transactional;
1518
import java.time.Duration;
1619

20+
@Slf4j
1721
@Service
1822
@RequiredArgsConstructor
1923
public class UserActivationServiceImpl implements UserActivationService {
2024
private final UserRepository userRepository;
25+
private final UserActivationTokenRepository tokenRepository;
2126
private final ApplicationEventPublisher eventPublisher;
2227

28+
/**
29+
* {@inheritDoc}
30+
*
31+
* <p>
32+
* Looks up the token, validates it is not expired, sets the user status to
33+
* {@code ACTIVATED}, and deletes the token so it cannot be reused.
34+
* </p>
35+
*/
36+
@Override
37+
@Transactional
38+
public void verifyEmail(String token) {
39+
UserActivationToken activationToken = tokenRepository.findByToken(token)
40+
.orElseThrow(InvalidActivationTokenException::new);
41+
42+
if (activationToken.isExpired()) {
43+
throw new InvalidActivationTokenException();
44+
}
45+
46+
User user = activationToken.getUser();
47+
48+
if (user.getUserStatus() == UserStatus.ACTIVATED) {
49+
throw new UserAlreadyActivatedException();
50+
}
51+
52+
user.setUserStatus(UserStatus.ACTIVATED);
53+
user.setUserActivationToken(null);
54+
userRepository.save(user);
55+
56+
log.info("User account activated for email={}", user.getEmail());
57+
}
58+
2359
/**
2460
* {@inheritDoc}
2561
*

src/main/java/com/itasocialacademy/oitassist/auth/service/interfaces/UserActivationService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.itasocialacademy.oitassist.auth.service.interfaces;
22

3+
import com.itasocialacademy.oitassist.auth.exceptions.InvalidActivationTokenException;
34
import com.itasocialacademy.oitassist.auth.exceptions.UserAlreadyActivatedException;
45
import com.itasocialacademy.oitassist.user.exceptions.ActivationTokenSendingTimeoutException;
56
import com.itasocialacademy.oitassist.user.exceptions.UserNotFoundException;
@@ -41,4 +42,16 @@ public interface UserActivationService {
4142
* activated
4243
*/
4344
void initializeActivation(String email, String firstName);
45+
46+
/**
47+
* Verifies a user's email address using the activation token from the email
48+
* link. If the token is valid and not expired, the user's status is updated to
49+
* {@code ACTIVATED} and the token is removed.
50+
*
51+
* @param token the activation token sent to the user's email
52+
* @throws InvalidActivationTokenException if the token does not exist or has
53+
* expired
54+
* @throws UserAlreadyActivatedException if the account is already activated
55+
*/
56+
void verifyEmail(String token);
4457
}

src/main/java/com/itasocialacademy/oitassist/core/enums/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public enum ErrorCode {
2626
INVALID_FILE_PATH(ErrorCategory.TECHNICAL),
2727
COMMON_VALIDATION_FAILED(ErrorCategory.VALIDATION),
2828
FILE_VALIDATION_FAILED(ErrorCategory.VALIDATION),
29+
INVALID_ACTIVATION_TOKEN(ErrorCategory.VALIDATION),
2930

3031
TOKEN_EXPIRE(ErrorCategory.AUTHENTICATION),
3132
UNSUPPORTED_TOKEN(ErrorCategory.AUTHENTICATION),

src/main/java/com/itasocialacademy/oitassist/security/config/SecurityConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ public SecurityFilterChain configure(HttpSecurity http) {
6666
"/api/v1/security/refresh")
6767
.permitAll()
6868
.requestMatchers(HttpMethod.GET,
69-
"/api/v1/news/**")
69+
"/api/v1/news/**",
70+
"/api/v1/user-activation/verify")
7071
.permitAll()
7172
.requestMatchers(
7273
"/actuator/health/**",

0 commit comments

Comments
 (0)