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
4 changes: 4 additions & 0 deletions app/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
<packaging>jar</packaging>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-60</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,78 @@ public class GlobalExceptionHandlerController {
private static final String BAD_REQUEST_ERROR = "BAD_REQUEST_ERROR";
private static final String TEMPORARY_UNAVAILABLE_ERROR = "TEMPORARY_UNAVAILABLE_ERROR";

/**
* Handles {@link ValidationCodeNotExpiredYetException} thrown when a previously issued
* validation/verification code is still valid and a new one cannot be generated yet.
*
* <p>Responds with HTTP 400 (Bad Request) and returns an {@link ErrorResponse} containing: - the
* exception message as the client-facing message - the request path where the error occurred
*
* @param ex the thrown {@link ValidationCodeNotExpiredYetException}
* @param handlerMethod the controller method where the exception originated
* @param request the current {@link ServletWebRequest} providing request context
* @return an {@link ErrorResponse} with the exception message and request path
*/
@ExceptionHandler(value = ValidationCodeNotExpiredYetException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ErrorResponse validationCodeNotExpiredYetException(
ValidationCodeNotExpiredYetException ex,
HandlerMethod handlerMethod,
ServletWebRequest request) {
return ErrorResponse.builder()
.message(Objects.requireNonNull(ex.getMessage()))
.path(getPath(request))
.build();
}

/**
* Handles {@link ValidationCodeExpiredException} thrown when a validation or verification code
* has expired (for example during email or token verification).
*
* <p>Responds with HTTP 400 (Bad Request) and returns an {@link ErrorResponse} containing: - the
* exception message as the client-facing message - the request path where the error occurred
*
* @param ex the thrown {@link ValidationCodeExpiredException}
* @param handlerMethod the controller method where the exception originated
* @param request the current {@link ServletWebRequest} providing request context
* @return an {@link ErrorResponse} with the exception message and request path
*/
@ExceptionHandler(value = ValidationCodeExpiredException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ErrorResponse validationCodeExpiredException(
ValidationCodeExpiredException ex, HandlerMethod handlerMethod, ServletWebRequest request) {
return ErrorResponse.builder()
.message(Objects.requireNonNull(ex.getMessage()))
.path(getPath(request))
.build();
}

/**
* Handles {@link VerificationCodeException} thrown during verification flows.
*
* <p>Responds with HTTP 400 (Bad Request) and returns an {@link ErrorResponse} containing: - a
* standardized error code from {@link VerificationCodeException#VERIFICATION_COD_IS_NOT_VALID} -
* the exception message - the request path where the error occurred
*
* @param ex the thrown {@link VerificationCodeException}
* @param handlerMethod the controller method where the exception originated
* @param request the current {@link ServletWebRequest} providing request context
* @return an {@link ErrorResponse} with error code, message and request path
*/
@ExceptionHandler(value = VerificationCodeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ErrorResponse verificationCodeException(
VerificationCodeException ex, HandlerMethod handlerMethod, ServletWebRequest request) {
return ErrorResponse.builder()
.error(VerificationCodeException.VERIFICATION_CODE_IS_NOT_VALID)
.message(Objects.requireNonNull(ex.getMessage()))
.path(getPath(request))
.build();
}

/**
* Handles {@link BuildArtifactCreationException} thrown when creating the build artifact fails.
*
Expand All @@ -62,7 +134,7 @@ public class GlobalExceptionHandlerController {
* @return an {@link ErrorResponse} with the failure message and request path
*/
@ExceptionHandler(value = BuildArtifactCreationException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ErrorResponse buildArtifactCreationException(
BuildArtifactCreationException ex, HandlerMethod handlerMethod, ServletWebRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package tools.vitruv.methodologist.exception;

/**
* Thrown to indicate that the application failed to start correctly.
*
* <p>This is an unchecked exception intended for fatal startup problems that should prevent the
* application from continuing initialization.
*
* @param message a detail message describing the startup failure
*/
public class StartupException extends RuntimeException {
/**
* Constructs a new {@code StartupException} with the specified detail message.
*
* @param message the detail message explaining the startup failure
*/
public StartupException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package tools.vitruv.methodologist.exception;

/**
* Exception indicating that a previously issued validation code (OTP) is expired.
*
* <p>This is an unchecked exception that carries a default message template available via {@link
* #messageTemplate}. Throw this when an operation fails because the stored validation code is no
* longer valid due to expiry.
*/
public class ValidationCodeExpiredException extends RuntimeException {
public static final String MESSAGE_TEMPLATE = "The validation code is expired!";

/**
* Constructs a new ValidationCodeExpiredException with the default message.
*
* <p>The default detail message is provided by {@link #messageTemplate}.
*/
public ValidationCodeExpiredException() {
super(MESSAGE_TEMPLATE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package tools.vitruv.methodologist.exception;

/**
* Thrown when a previously issued validation or verification code is still valid and a new code
* cannot be generated yet.
*
* <p>Used to enforce a cooldown period between issuing validation codes (for example email or SMS
* verification codes). Consumers can catch this exception to return an appropriate client-facing
* response (e.g. HTTP 400 with an explanatory message).
*/
public class ValidationCodeNotExpiredYetException extends RuntimeException {
public static final String MESSAGE_TEMPLATE = "The previous code is still valid!";

/** Constructs a new {@code ValidationCodeNotExpiredYetException} with the standard message. */
public ValidationCodeNotExpiredYetException() {
super(MESSAGE_TEMPLATE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package tools.vitruv.methodologist.exception;

/**
* Thrown when a provided verification code is invalid or cannot be accepted.
*
* <p>Used in verification flows (e.g., email or account verification) to indicate that the supplied
* code is incorrect, expired, or otherwise unacceptable.
*/
public class VerificationCodeException extends RuntimeException {
public static final String VERIFICATION_CODE_IS_NOT_VALID = "Verification cod is not valid!";

/** Constructs a new {@code VerificationCodeException} with the standard error message. */
public VerificationCodeException() {
super(String.format(VERIFICATION_CODE_IS_NOT_VALID));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package tools.vitruv.methodologist.general.service;

import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailSendException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

/**
* Service responsible for sending emails via a configured {@link JavaMailSender}.
*
* <p>The service reads the configured sender address and optional sender name from application
* properties and uses the injected {@link JavaMailSender} to build and send MIME messages with HTML
* content.
*/
@Service
public class MailService {

private final JavaMailSender mailSender;
private final String fromAddress;
private final String fromName;

/**
* Constructs the mail service.
*
* @param mailSender injected {@link JavaMailSender} used to create and send messages
* @param fromAddress the configured sender email address from property {@code app.mail.from}
* @param fromName optional sender display name from property {@code app.mail.fromName}; may be
* empty
*/
public MailService(
JavaMailSender mailSender,
@Value("${app.mail.from}") String fromAddress,
@Value("${app.mail.fromName:}") String fromName) {
this.mailSender = mailSender;
this.fromAddress = fromAddress;
this.fromName = fromName == null ? "" : fromName;
}

/**
* Sends an HTML email to the specified recipient.
*
* <p>The method creates a MIME message, sets the from address (with optional display name),
* recipient, subject and HTML body, and delegates sending to the injected {@link JavaMailSender}.
*
* @param to recipient email address
* @param subject email subject
* @param html HTML body of the email
* @throws RuntimeException if message creation or sending fails
*/
public void send(String to, String subject, String html) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

if (fromName.isBlank()) {
helper.setFrom(fromAddress);
} else {
helper.setFrom(new InternetAddress(fromAddress, fromName));
}

helper.setTo(to);
helper.setSubject(subject);
helper.setText(html, true);

mailSender.send(message);
} catch (Exception e) {
throw new MailSendException(e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
public class Message {
public static final String LOGIN_USER_SUCCESSFULLY = "User successfully logged in";
public static final String SIGNUP_USER_SUCCESSFULLY = "User successfully signed up";
public static final String VERIFIED_USER_SUCCESSFULLY = "User successfully verified.";
public static final String RESEND_OTP_WAS_SUCCESSFULLY = "New otp code sent to your email.";
public static final String USER_UPDATED_SUCCESSFULLY = "User successfully updated";
public static final String USER_REMOVED_SUCCESSFULLY = "User successfully removed";
public static final String VSUM_CREATED_SUCCESSFULLY = "Vsum successfully created";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package tools.vitruv.methodologist.user.controller;

import static tools.vitruv.methodologist.messages.Message.RESEND_OTP_WAS_SUCCESSFULLY;
import static tools.vitruv.methodologist.messages.Message.SIGNUP_USER_SUCCESSFULLY;
import static tools.vitruv.methodologist.messages.Message.USER_REMOVED_SUCCESSFULLY;
import static tools.vitruv.methodologist.messages.Message.USER_UPDATED_SUCCESSFULLY;
import static tools.vitruv.methodologist.messages.Message.VERIFIED_USER_SUCCESSFULLY;

import jakarta.validation.Valid;
import java.util.List;
Expand All @@ -25,6 +27,7 @@
import tools.vitruv.methodologist.user.controller.dto.request.PostAccessTokenRequest;
import tools.vitruv.methodologist.user.controller.dto.request.UserPostRequest;
import tools.vitruv.methodologist.user.controller.dto.request.UserPutRequest;
import tools.vitruv.methodologist.user.controller.dto.request.UserPutVerifyRequest;
import tools.vitruv.methodologist.user.controller.dto.response.UserResponse;
import tools.vitruv.methodologist.user.controller.dto.response.UserWebToken;
import tools.vitruv.methodologist.user.service.UserService;
Expand Down Expand Up @@ -81,6 +84,45 @@ public ResponseTemplateDto<Void> create(@Valid @RequestBody UserPostRequest user
return ResponseTemplateDto.<Void>builder().message(SIGNUP_USER_SUCCESSFULLY).build();
}

/**
* Verifies a one-time password (OTP) for the authenticated caller.
*
* <p>Endpoint requires the caller to have the `user` role. On success it returns a {@link
* ResponseTemplateDto} with no data and a success message ({@code VERIFIED_USER_SUCCESSFULLY}).
*
* @param authentication the {@link KeycloakAuthentication} containing the caller's parsed token
* and email
* @param userPutVerifyRequest the request payload containing OTP/verification data
* @return a {@link ResponseTemplateDto} with a success message and no payload
*/
@PutMapping("/v1/users/verify-otp")
@PreAuthorize("hasRole('user')")
public ResponseTemplateDto<Void> verifyOtp(
KeycloakAuthentication authentication,
@Valid @RequestBody UserPutVerifyRequest userPutVerifyRequest) {
String callerEmail = authentication.getParsedToken().getEmail();
userService.verifyOtp(callerEmail, userPutVerifyRequest);
return ResponseTemplateDto.<Void>builder().message(VERIFIED_USER_SUCCESSFULLY).build();
}

/**
* Sends a new one-time password (OTP) to the authenticated caller's email.
*
* <p>Accessible only to callers with the user role. On success returns a {@link
* ResponseTemplateDto} with no data and a success message ({@code RESEND_OTP_WAS_SUCCESSFULLY}).
*
* @param authentication the {@link KeycloakAuthentication} containing the caller's parsed token
* and email
* @return a {@link ResponseTemplateDto} with no payload and a success message
*/
@GetMapping("/v1/users/resend-otp")
@PreAuthorize("hasRole('user')")
public ResponseTemplateDto<Void> resendOtp(KeycloakAuthentication authentication) {
String callerEmail = authentication.getParsedToken().getEmail();
userService.resendOtp(callerEmail);
return ResponseTemplateDto.<Void>builder().message(RESEND_OTP_WAS_SUCCESSFULLY).build();
}

/**
* Retrieves the authenticated user's information. This endpoint requires the user to have the
* 'user' role.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package tools.vitruv.methodologist.user.controller.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
* Request payload used to verify a user's account (for example during email verification).
*
* <p>Holds the verification input code supplied by the client. The value must be present and not
* blank; validation annotations enforce these constraints.
*/
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserPutVerifyRequest {
@NotNull @NotBlank String inputCode;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ public class UserResponse {
private String email;
private String firstName;
private String lastName;
private Boolean verified;
}
14 changes: 10 additions & 4 deletions app/src/main/java/tools/vitruv/methodologist/user/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,21 @@ public class User {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Email private String email;
@NotNull @Email private String email;

@NotNull
@Enumerated(EnumType.STRING)
private RoleType roleType;

private String username;
private String firstName;
private String lastName;
@NotNull private String username;

@NotNull private String firstName;

@NotNull private String lastName;
private String otpSecret;
private Instant otpExpiresAt;

@NotNull @Builder.Default private Boolean verified = false;

@CreationTimestamp private Instant createdAt;
private Instant removedAt;
Expand Down
Loading