create a dedicated password reset page (#2023)

This commit is contained in:
Athou
2026-01-19 20:09:35 +01:00
parent 7e50e99351
commit afe957ba59
35 changed files with 914 additions and 32 deletions

View File

@@ -0,0 +1,34 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.security.password.ValidPassword;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@Schema
public class PasswordResetConfirmationRequest implements Serializable {
@Schema(description = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
@Schema(description = "password recovery token", required = true)
@NotEmpty
private String token;
@Schema(description = "new password", required = true)
@NotEmpty
@ValidPassword
private String password;
}

View File

@@ -1,6 +1,5 @@
package com.commafeed.frontend.resource;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -21,13 +20,11 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriInfo;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.core5.net.URIBuilder;
import org.eclipse.microprofile.openapi.annotations.Operation;
@@ -38,7 +35,6 @@ import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConstants;
import com.commafeed.backend.Digests;
import com.commafeed.backend.Urls;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserRoleDAO;
import com.commafeed.backend.dao.UserSettingsDAO;
@@ -57,6 +53,7 @@ import com.commafeed.backend.service.db.DatabaseStartupService;
import com.commafeed.frontend.model.Settings;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.InitialSetupRequest;
import com.commafeed.frontend.model.request.PasswordResetConfirmationRequest;
import com.commafeed.frontend.model.request.PasswordResetRequest;
import com.commafeed.frontend.model.request.ProfileModificationRequest;
import com.commafeed.frontend.model.request.RegistrationRequest;
@@ -87,7 +84,6 @@ public class UserREST {
private final MailService mailService;
private final CommaFeedConfiguration config;
private final UriInfo uri;
private final UnitOfWork unitOfWork;
@Path("/settings")
@GET
@@ -334,45 +330,44 @@ public class UserREST {
}
}
private String buildEmailContent(User user) throws URISyntaxException, MalformedURLException {
private String buildEmailContent(User user) throws URISyntaxException {
String publicUrl = Urls.removeTrailingSlash(uri.getBaseUri().toString());
publicUrl += "/rest/user/passwordResetCallback";
return String.format(
"You asked for password recovery for account '%s', <a href='%s'>follow this link</a> to change your password. Ignore this if you didn't request a password recovery.",
user.getName(), callbackUrl(user, publicUrl));
}
private String callbackUrl(User user, String publicUrl) throws URISyntaxException, MalformedURLException {
return new URIBuilder(publicUrl).addParameter("email", user.getEmail())
.addParameter("token", user.getRecoverPasswordToken())
.build()
.toURL()
.toString();
private String callbackUrl(User user, String publicUrl) throws URISyntaxException {
URIBuilder queryBuilder = new URIBuilder();
queryBuilder.addParameter("email", user.getEmail());
queryBuilder.addParameter("token", user.getRecoverPasswordToken());
String queryString = queryBuilder.build().getRawQuery();
return publicUrl + "/#/passwordReset?" + queryString;
}
@Path("/passwordResetCallback")
@PermitAll
@GET
@POST
@Transactional
@Produces(MediaType.TEXT_HTML)
public Response passwordRecoveryCallback(@Parameter(required = true) @QueryParam("email") String email,
@Parameter(required = true) @QueryParam("token") String token) {
@Operation(summary = "confirm password reset with new password")
public Response passwordRecoveryCallback(@Valid @Parameter(required = true) PasswordResetConfirmationRequest req) {
String email = req.getEmail();
String token = req.getToken();
String password = req.getPassword();
Preconditions.checkNotNull(email);
Preconditions.checkNotNull(token);
Preconditions.checkNotNull(password);
User user = userDAO.findByEmail(email);
if (user == null) {
return Response.status(Status.UNAUTHORIZED).entity("Email not found.").build();
if (user == null || user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) {
return Response.status(Status.UNAUTHORIZED).entity("Email not found or invalid token.").build();
}
if (user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) {
return Response.status(Status.UNAUTHORIZED).entity("Invalid token.").build();
}
if (ChronoUnit.DAYS.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 2) {
return Response.status(Status.UNAUTHORIZED).entity("token expired.").build();
if (ChronoUnit.MINUTES.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 30) {
return Response.status(Status.UNAUTHORIZED).entity("Token expired.").build();
}
String passwd = RandomStringUtils.secure().nextAlphanumeric(10);
byte[] encryptedPassword = encryptionService.getEncryptedPassword(passwd, user.getSalt());
byte[] encryptedPassword = encryptionService.getEncryptedPassword(password, user.getSalt());
user.setPassword(encryptedPassword);
if (StringUtils.isNotBlank(user.getApiKey())) {
user.setApiKey(userService.generateApiKey(user));
@@ -380,10 +375,7 @@ public class UserREST {
user.setRecoverPasswordToken(null);
user.setRecoverPasswordTokenDate(null);
String message = "Your new password is: " + passwd;
message += "<br />";
message += String.format("<a href=\"%s\">Back to Homepage</a>", uri.getBaseUri());
return Response.ok(message).build();
return Response.ok().build();
}
@Path("/profile/deleteAccount")

View File

@@ -1,5 +1,7 @@
package com.commafeed.integration.rest;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import jakarta.inject.Inject;
@@ -13,6 +15,7 @@ import org.junit.jupiter.api.Test;
import com.commafeed.TestConstants;
import com.commafeed.frontend.model.Settings;
import com.commafeed.frontend.model.request.PasswordResetConfirmationRequest;
import com.commafeed.frontend.model.request.PasswordResetRequest;
import com.commafeed.integration.BaseIT;
@@ -57,8 +60,32 @@ class UserIT extends BaseIT {
Element a = Jsoup.parse(message.getHtml()).select("a").getFirst();
String link = a.attr("href");
String newPasswordResponse = RestAssured.given().urlEncodingEnabled(false).get(link).then().statusCode(200).extract().asString();
Assertions.assertTrue(newPasswordResponse.contains("Your new password is:"));
String email = null;
String token = null;
String queryString = link.substring(link.indexOf('?') + 1);
for (String param : queryString.split("&")) {
String[] keyValue = param.split("=");
if ("email".equals(keyValue[0])) {
email = URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
} else if ("token".equals(keyValue[0])) {
token = URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
}
}
Assertions.assertNotNull(email);
Assertions.assertNotNull(token);
Assertions.assertTrue(link.contains("#/passwordReset?"));
String newPassword = "MyNewPassword123!";
PasswordResetConfirmationRequest confirmReq = new PasswordResetConfirmationRequest();
confirmReq.setEmail(email);
confirmReq.setToken(token);
confirmReq.setPassword(newPassword);
RestAssured.given().body(confirmReq).contentType(ContentType.JSON).post("rest/user/passwordResetCallback").then().statusCode(200);
RestAssured.authentication = RestAssured.preemptive().basic(TestConstants.ADMIN_USERNAME, newPassword);
RestAssured.given().get("rest/user/settings").then().statusCode(200);
}
@Test