Skip to content
Merged
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 pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
Expand Down
24 changes: 12 additions & 12 deletions src/main/java/com/weatherviewer/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/sign-in", "/sign-up", "/sign-in-failure",
"/verify-email", "/resend-verification",
"/forgot-password", "/reset-password",
"/css/**", "/images/**", "/js/**",
"/actuator/health", "/actuator/health/**"
).permitAll()
Expand Down Expand Up @@ -89,18 +91,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.expiredUrl("/sign-in?expired")
)
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; "
+ "script-src 'self' https://cdn.jsdelivr.net; "
+ "style-src 'self' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com 'unsafe-inline'; "
+ "font-src 'self' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com data:; "
+ "img-src 'self' data:; "
+ "connect-src 'self'; "
+ "object-src 'none'; "
+ "base-uri 'self'; "
+ "form-action 'self'; "
+ "frame-ancestors 'none'"
))
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' https://cdn.jsdelivr.net; " +
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " +
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; " +
"connect-src 'self' https://cdn.jsdelivr.net; " +
"img-src 'self' data: https:;"
)
)
)
.addFilterAfter(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class);

Expand Down
69 changes: 64 additions & 5 deletions src/main/java/com/weatherviewer/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.weatherviewer.controller;

import com.weatherviewer.dto.CreateUserDto;
import com.weatherviewer.exception.InvalidTokenException;
import com.weatherviewer.model.User;
import com.weatherviewer.service.LoginService;
import com.weatherviewer.service.UserService;
import com.weatherviewer.service.VerificationService;
import com.weatherviewer.utils.SafeRedirectUtils;
import jakarta.servlet.ServletException;
import jakarta.validation.Valid;
Expand All @@ -17,6 +20,8 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.UUID;

/**
* Thymeleaf controller for the sign-in/sign-up pages and their form
* submissions. Successful sign-up auto-logs the new user in via
Expand All @@ -30,6 +35,7 @@ public class AuthController {

private final UserService userService;
private final LoginService loginService;
private final VerificationService verificationService;

/** Renders the sign-in form, preserving a sanitized post-login redirect target if one was supplied. */
@GetMapping("/sign-in")
Expand All @@ -42,8 +48,18 @@ public String signIn(@RequestParam(required = false) String redirect, Model mode

/** Landing target Spring Security redirects to after a failed login attempt; re-renders sign-in with an error. */
@GetMapping("/sign-in-failure")
public String signInFailure(RedirectAttributes redirectAttributes) {
log.info("Sign-in failed");
public String signInFailure(@RequestParam(required = false) Boolean unverified,
@RequestParam(required = false) String email,
RedirectAttributes redirectAttributes) {
log.info("Sign-in failed, unverified={}", unverified);

if (Boolean.TRUE.equals(unverified)) {
redirectAttributes.addFlashAttribute("errorMessage",
"Please verify your email before signing in.");
return "redirect:/sign-in?unverified=true"
+ (email != null && !email.isBlank() ? "&email=" + email : "");
}

redirectAttributes.addFlashAttribute("errorMessage", "Invalid email or password. Please try again.");
return "redirect:/sign-in";
}
Expand Down Expand Up @@ -76,14 +92,19 @@ public String processSignUp(@Valid @ModelAttribute("user") CreateUserDto createU
}

log.info("Processing sign-up for email={}", createUserDto.getEmail());
userService.create(createUserDto);
UUID userId = userService.create(createUserDto);

User createdUser = userService.getEntityById(userId);
verificationService.sendVerificationEmail(createdUser);

try {
loginService.login(createUserDto.getEmail(), createUserDto.getPassword());
log.info("Auto login successful for email={}", createUserDto.getEmail());
} catch (ServletException e) {
log.warn("Auto login failed after sign-up for email={}", createUserDto.getEmail(), e);
redirectAttributes.addFlashAttribute("errorMessage", "Auto login failed after sign-up");
log.info("Auto login skipped/failed after sign-up for email={} (account likely pending email verification)",
createUserDto.getEmail());
redirectAttributes.addFlashAttribute("successMessage",
"Account created! Check your email for a link to verify your account before signing in.");
return "redirect:/sign-in";
}

Expand All @@ -92,4 +113,42 @@ public String processSignUp(@Valid @ModelAttribute("user") CreateUserDto createU
return "redirect:" + SafeRedirectUtils.sanitize(redirect, "/");
}

/** Redeems an emailed email-verification link, activating the account, then sends the user to sign in. */
@GetMapping("/verify-email")
public String verifyEmail(@RequestParam String token, RedirectAttributes redirectAttributes) {
try {
verificationService.confirmEmail(token);
log.info("Email verified successfully for token");
redirectAttributes.addFlashAttribute("successMessage", "Your email has been verified. You can now sign in.");
} catch (InvalidTokenException e) {
log.warn("Email verification failed: {}", e.getMessage());
redirectAttributes.addFlashAttribute("errorMessage",
e.getMessage() + " Please request a new verification email.");
}
return "redirect:/sign-in";
}

/**
* Re-sends the verification email for an account that hasn't confirmed
* its address yet. Always shows the same confirmation message
* regardless of whether the email exists or is already verified, so
* this can't be used to enumerate registered addresses.
*/
@PostMapping("/resend-verification")
public String resendVerification(@RequestParam String email, RedirectAttributes redirectAttributes) {
log.info("Resend verification requested for email={}", email);
try {
User user = userService.getEntityByEmail(email);
if (user.getStatus() == com.weatherviewer.model.enums.UserStatus.PENDING) {
verificationService.sendVerificationEmail(user);
}
} catch (RuntimeException e) {
log.info("Resend verification requested for unknown email={}", email);
}

redirectAttributes.addFlashAttribute("successMessage",
"If that account needs verifying, we've sent a fresh link to its email address.");
return "redirect:/sign-in";
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.weatherviewer.controller;

import com.weatherviewer.dto.ForgotPasswordDto;
import com.weatherviewer.dto.ResetPasswordDto;
import com.weatherviewer.exception.InvalidTokenException;
import com.weatherviewer.service.VerificationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

/**
* Thymeleaf controller for the "forgot your password" flow: requesting a
* reset link by email, then redeeming that link to set a new password.
* <p>
* The request step always shows the same confirmation message whether or
* not the email is registered, so this flow can't be used to enumerate
* which addresses have accounts.
*/
@Controller
@RequiredArgsConstructor
@Slf4j
public class PasswordResetController {

private static final String GENERIC_REQUEST_MESSAGE =
"If an account exists for that email, we've sent a link to reset your password.";

private final VerificationService verificationService;

/** Renders the "forgot password" email-entry form. */
@GetMapping("/forgot-password")
public String forgotPassword(Model model) {
model.addAttribute("forgotPasswordDto", new ForgotPasswordDto());
return "forgot-password";
}

/** Issues a reset token for the given email (if it exists) and always shows the same confirmation. */
@PostMapping("/forgot-password")
public String processForgotPassword(@Valid @ModelAttribute("forgotPasswordDto") ForgotPasswordDto dto,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
return "forgot-password";
}

log.info("Password reset requested for email={}", dto.getEmail());
verificationService.requestPasswordReset(dto.getEmail());

redirectAttributes.addFlashAttribute("successMessage", GENERIC_REQUEST_MESSAGE);
return "redirect:/sign-in";
}

/** Renders the "choose a new password" form for a token carried in the link. */
@GetMapping("/reset-password")
public String resetPassword(@RequestParam String token, Model model) {
model.addAttribute("resetPasswordDto", new ResetPasswordDto().setToken(token));
return "reset-password";
}

/** Redeems the token and sets the new password, or re-renders the form with an error if the token/password is invalid. */
@PostMapping("/reset-password")
public String processResetPassword(@Valid @ModelAttribute("resetPasswordDto") ResetPasswordDto dto,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
if (!bindingResult.hasErrors() && !dto.getPassword().equals(dto.getRepeatPassword())) {
bindingResult.rejectValue("repeatPassword", "password.mismatch", "Passwords do not match");
}

if (bindingResult.hasErrors()) {
return "reset-password";
}

try {
verificationService.resetPassword(dto.getToken(), dto.getPassword());
} catch (InvalidTokenException e) {
log.warn("Password reset failed: {}", e.getMessage());
model.addAttribute("errorMessage", e.getMessage() + " Please request a new reset link.");
return "reset-password";
}

redirectAttributes.addFlashAttribute("successMessage", "Your password has been reset. You can now sign in.");
return "redirect:/sign-in";
}

}
23 changes: 23 additions & 0 deletions src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.weatherviewer.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

@Getter
@Setter
@Accessors(chain = true)
@Schema(description = "Payload for requesting a password reset email")
public class ForgotPasswordDto {

@Schema(description = "Email address of the account to reset", example = "jane.doe@example.com", maxLength = 150)
@Email(message = "Invalid email format")
@NotBlank(message = "Email cannot be blank")
@Size(max = 150, message = "Email cannot exceed 150 characters")
private String email;

}
37 changes: 37 additions & 0 deletions src/main/java/com/weatherviewer/dto/ResetPasswordDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.weatherviewer.dto;

import com.weatherviewer.validation.annotation.Password;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.Accessors;

@Getter
@Setter
@ToString
@Accessors(chain = true)
@Schema(description = "Payload for redeeming a password-reset link")
public class ResetPasswordDto {

@Schema(description = "Password reset token from the emailed link", accessMode = Schema.AccessMode.WRITE_ONLY)
@NotBlank(message = "Reset link is invalid")
@ToString.Exclude
private String token;

@Schema(description = "New password (up to 72 characters, bcrypt-hashed server-side)", maxLength = 72)
@Password
@NotBlank(message = "Password cannot be blank")
@Size(max = 72, message = "Password cannot exceed 72 characters")
@ToString.Exclude
private String password;

@Schema(description = "Must match `password`", maxLength = 72)
@NotBlank(message = "Repeat password cannot be blank")
@Size(max = 72, message = "Password cannot exceed 72 characters")
@ToString.Exclude
private String repeatPassword;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.weatherviewer.exception;

/**
* Thrown when an email-verification or password-reset token is missing,
* of the wrong {@link com.weatherviewer.model.enums.TokenType}, already
* used, or expired.
*/
public class InvalidTokenException extends RuntimeException {

public InvalidTokenException(String message) {
super(message);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
SearchController.class,
ForecastController.class,
ProfileController.class,
AuthController.class
AuthController.class,
PasswordResetController.class
})
@Slf4j
public class MvcExceptionHandler {
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/com/weatherviewer/mapper/UserMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ public interface UserMapper {

/**
* Builds a new {@link User} from a registration payload. New accounts
* are always created with {@code status = ACTIVE} and {@code role = USER};
* the password field is copied as-is and must already be hashed by the
* caller before persisting.
* are always created with {@code status = PENDING} (until the owner
* confirms their email address via the verification link — see
* {@link com.weatherviewer.service.VerificationService}) and
* {@code role = USER}; the password field is copied as-is and must
* already be hashed by the caller before persisting.
*/
@Mapping(target = "status", expression = "java(com.weatherviewer.model.enums.UserStatus.ACTIVE)")
@Mapping(target = "status", expression = "java(com.weatherviewer.model.enums.UserStatus.PENDING)")
@Mapping(target = "role", expression = "java(com.weatherviewer.model.enums.Role.USER)")
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Expand Down
Loading