Refactor form request handling and add rate limiting.

Consolidate email verification logic into FormRequestHandler to simplify code maintenance. Implement a new rate limiting feature to restrict form submissions based on IP and email address, improving server security and performance.
This commit is contained in:
Teriuihi 2024-08-10 01:52:34 +02:00
parent f0c84e809f
commit fedb80f3c2
11 changed files with 229 additions and 80 deletions

View File

@ -1,6 +1,7 @@
package com.alttd.forms;
import com.alttd.forms.database.DatabaseConnection;
import com.alttd.forms.mail.rate_limitter.FormRateLimit;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
@ -16,6 +17,7 @@ public class Main {
path = args[0];
SpringApplication.run(Main.class, args);
DatabaseConnection.initialize(args[0]);
FormRateLimit.initialize(args[0]);
}
public static String getConfigPath() {

View File

@ -0,0 +1,41 @@
package com.alttd.forms.controlers;
import com.alttd.forms.form.Form;
import com.alttd.forms.form.StoreFormQuery;
import com.alttd.forms.mail.verification.VerificationResult;
import com.alttd.forms.mail.verification.Verify;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.concurrent.CompletableFuture;
public class FormRequestHandler {
private static final Logger logger = LoggerFactory.getLogger(FormRequestHandler.class);
public static CompletableFuture<ResponseEntity<String>> handleRequestWithVerifyMail(Form form, String ip) {
CompletableFuture<Integer> storeFormForVerificationCode = new StoreFormQuery().storeFormForVerificationCode(form.getSender(), form);
return storeFormForVerificationCode.thenCompose(code -> Verify.verifyEmail(ip, form.getSender(), code, form).thenApply(verificationResult -> {
if (verificationResult == VerificationResult.VERIFICATION_SENT) {
//TODO if this is ok tell the user they have x min to verify if they fail to do so they have to remake the form
logger.trace("Staff application form stored and requested verification from user");
return ResponseEntity.ok("User Data received and email verification sent.");
} else if (verificationResult == VerificationResult.RATE_LIMIT_EXCEEDED) {
logger.trace("User hit the rate limit email: {} ip: {}", form.getSender(), ip);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("You have sent too many forms today, " +
"please wait up to an hour before trying again. " +
"You can resubmit your form by hitting submit again later. " +
"Continued use of the form will extent the time you have to wait.");
} else {
logger.trace("Failed to send verification email {}", verificationResult.name());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to send verification email. Reason: " + verificationResult.name());
}
})).exceptionally(throwable -> {
logger.error("Failed to store form", throwable);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to store your form");
});
}
}

View File

@ -1,12 +1,10 @@
package com.alttd.forms.controlers.apply;
import com.alttd.forms.form.StoreFormQuery;
import com.alttd.forms.mail.verification.VerificationResult;
import com.alttd.forms.mail.verification.Verify;
import com.alttd.forms.controlers.FormRequestHandler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -19,24 +17,10 @@ public class StaffAppController {
private static final Logger logger = LoggerFactory.getLogger(StaffAppController.class);
@PostMapping("/staffApplication")
public CompletableFuture<ResponseEntity<String>> submitForm(@Valid @RequestBody StaffAppFormData formData) {
public CompletableFuture<ResponseEntity<String>> submitForm(@Valid @RequestBody StaffAppFormData formData, HttpServletRequest request) {
logger.debug("submitForm");
logger.trace(formData.toString());
CompletableFuture<Integer> storeFormForVerificationCode = new StoreFormQuery().storeFormForVerificationCode(formData.email, formData);
return storeFormForVerificationCode.thenCompose(code -> Verify.verifyEmail(formData.email, code).thenApply(verificationResult -> {
if (verificationResult == VerificationResult.VERIFICATION_SENT) {
//TODO if this is ok tell the user they have x min to verify if they fail to do so they have to remake the form
logger.trace("Staff application form stored and requested verification from user");
return ResponseEntity.ok("User Data received and email verification sent.");
} else {
logger.trace("Failed to send verification email");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to send verification email. Reason: " + verificationResult.name());
}
})).exceptionally(throwable -> {
logger.error("Failed to store form", throwable);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to store your form");
});
return FormRequestHandler.handleRequestWithVerifyMail(formData, request.getRemoteAddr());
}
}

View File

@ -119,6 +119,11 @@ public class StaffAppFormData extends Form {
return "apply@alttd.com";
}
@Override
public String getSender() {
return email;
}
@Override
public String toHtml() {
String[] fields = {"Username", "Email", "Discord", "PC requirements", "Age", "Pronoun", "Join date", "Avg time", "Available days", "Available time", "Staff experience", "Plugin experience", "Why staff", "Expectations mod", "Other"};

View File

@ -1,11 +1,9 @@
package com.alttd.forms.controlers.contact;
import com.alttd.forms.form.StoreFormQuery;
import com.alttd.forms.mail.verification.VerificationResult;
import com.alttd.forms.mail.verification.Verify;
import com.alttd.forms.controlers.FormRequestHandler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -18,25 +16,11 @@ public class ContactController {
private static final Logger logger = LoggerFactory.getLogger(ContactController.class);
@PostMapping("/submitContactForm")
public CompletableFuture<ResponseEntity<String>> submitForm(@Valid @RequestBody ContactFormData formData) {
public CompletableFuture<ResponseEntity<String>> submitForm(@Valid @RequestBody ContactFormData formData, HttpServletRequest request) {
logger.debug("submitForm");
logger.trace(formData.toString());
CompletableFuture<Integer> storeFormForVerificationCode = new StoreFormQuery().storeFormForVerificationCode(formData.email, formData);
return storeFormForVerificationCode.thenCompose(code -> Verify.verifyEmail(formData.email, code).thenApply(verificationResult -> {
if (verificationResult == VerificationResult.VERIFICATION_SENT) {
//TODO if this is ok tell the user they have x min to verify if they fail to do so they have to remake the form
logger.trace("Contact form stored and requested verification from user");
return ResponseEntity.ok("User Data received and email verification sent.");
} else {
logger.trace("Failed to send verification email");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to send verification email. Reason: " + verificationResult.name());
}
})).exceptionally(throwable -> {
logger.error("Failed to store form", throwable);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to store your form");
});
return FormRequestHandler.handleRequestWithVerifyMail(formData, request.getRemoteAddr());
}
}

View File

@ -48,6 +48,11 @@ public class ContactFormData extends Form {
return "support@alttd.com";
}
@Override
public String getSender() {
return email;
}
@Override
public String toHtml() {
String[] fields = {"Username", "Email", "Question"};

View File

@ -18,20 +18,25 @@ public abstract class Form {
public String toHtml(String[] fields, String[] values) {
StringBuilder htmlOutput = new StringBuilder();
//language=HTML
htmlOutput.append("<table style='border-collapse: collapse; width: 100%;'>");
for (int i = 0; i < fields.length; i++) {
htmlOutput.append("<tr style='border: 1px solid #ddd;'>");
htmlOutput.append("<td style='border: 1px solid #ddd; padding: 10px; font-weight: bold;'>");
htmlOutput.append(fields[i]);
htmlOutput.append("</td>");
htmlOutput.append("<td style='border: 1px solid #ddd; padding: 10px;'>");
htmlOutput.append(values[i]);
htmlOutput.append("</td>");
htmlOutput.append("</tr>");
htmlOutput.append(
String.format(
//language=HTML
"""
<tr style='border: 1px solid #ddd;'>
<td style='border: 1px solid #ddd; padding: 10px; font-weight: bold;'>
%s
</td>
<td style='border: 1px solid #ddd; padding: 10px;'>
%s
</td>
</tr>
""", fields[i], values[i]));
}
//language=HTML
htmlOutput.append("</table>");
return htmlOutput.toString();
}
@ -41,4 +46,6 @@ public abstract class Form {
public abstract Optional<String> getDiscordBotUrl();
public abstract String getReceiver();
public abstract String getSender();
}

View File

@ -0,0 +1,87 @@
package com.alttd.forms.mail.rate_limitter;
import com.alttd.forms.database.DatabaseConnection;
import com.alttd.forms.properties.PropertiesLoader;
import com.alttd.forms.properties.PropertiesWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
public class FormRateLimit {
private static final Logger logger = LoggerFactory.getLogger(FormRateLimit.class);
private static FormRateLimit instance;
private Properties properties;
private FormRateLimit(String path) {
FormRateLimit.instance = this;
loadProperties(path);
}
public static FormRateLimit getInstance() {
return FormRateLimit.instance;
}
public static void initialize(String path) {
if (instance != null)
return;
FormRateLimit.instance = new FormRateLimit(path);
}
private void loadProperties(String path) {
String fileName = "form.properties";
Optional<Properties> optionalProperties = PropertiesLoader.loadProperties(path, fileName);
if (optionalProperties.isPresent()) {
properties = optionalProperties.get();
return;
}
properties = new Properties();
properties.setProperty("rate-limit-ip-count", "10");
properties.setProperty("rate-limit-mail-count", "10");
properties.setProperty("rate-limit-ip-minutes", "60");
properties.setProperty("rate-limit-mail-minutes", "60");
PropertiesWriter.writeProperties(properties, path, fileName);
}
public CompletableFuture<Optional<Boolean>> isRateLimited(String ip, String mail) {
return CompletableFuture.supplyAsync(
() -> {
RateLimitQuery rateLimitQuery = new RateLimitQuery(DatabaseConnection.getConnection());
try {
if (isIpRateLimited(rateLimitQuery, ip) || isMailRateLimited(rateLimitQuery, mail))
return Optional.of(true);
} catch (SQLException e) {
logger.error("Failed rate limit query for ip: {}, mail: {}", ip, mail, e);
return Optional.empty();
} catch (NumberFormatException e) {
logger.error("Failed loading numbers from properties", e);
return Optional.empty();
}
return Optional.of(false);
});
}
private boolean isIpRateLimited(RateLimitQuery rateLimitQuery, String ip) throws SQLException, NumberFormatException {
int minutes = Integer.parseInt(properties.getProperty("rate-limit-ip-minutes"));
Instant after = Instant.now().minus(Duration.ofMinutes(minutes));
int ipHits = rateLimitQuery.getIpHits(ip, after);
int ipRateLimit = Integer.parseInt(properties.getProperty("rate-limit-ip-count"));
return ipHits >= ipRateLimit;
}
private boolean isMailRateLimited(RateLimitQuery rateLimitQuery, String mail) throws SQLException, NumberFormatException {
int minutes = Integer.parseInt(properties.getProperty("rate-limit-mail-minutes"));
Instant after = Instant.now().minus(Duration.ofMinutes(minutes));
int ipHits = rateLimitQuery.getMailHits(mail, after);
int ipRateLimit = Integer.parseInt(properties.getProperty("rate-limit-mail-count"));
return ipHits >= ipRateLimit;
}
}

View File

@ -9,8 +9,13 @@ import java.time.Instant;
public class RateLimitQuery {
private static final Logger logger = LoggerFactory.getLogger(RateLimitQuery.class);
private final Connection connection;
public int getIpHits(Connection connection, String ip, Instant after) throws SQLException {
protected RateLimitQuery(Connection connection) {
this.connection = connection;
}
protected int getIpHits(String ip, Instant after) throws SQLException {
String sql = "SELECT COUNT(*) AS hits FROM rate_limit WHERE ip = ? AND time > ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, ip);
@ -26,7 +31,7 @@ public class RateLimitQuery {
}
}
public int getMailHits(Connection connection, String mail, Instant after) throws SQLException {
protected int getMailHits(String mail, Instant after) throws SQLException {
String sql = "SELECT COUNT(*) AS hits FROM rate_limit WHERE mail = ? AND time > ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, mail);
@ -42,7 +47,7 @@ public class RateLimitQuery {
}
}
public boolean insertRateLimitEntry(Connection connection, RateLimitEntryDTO entry) throws SQLException {
protected boolean insertRateLimitEntry(RateLimitEntryDTO entry) throws SQLException {
String sql = "INSERT INTO rate_limit (time, ip, mail) VALUES (?, ?, ?)";
try {
PreparedStatement stmt = connection.prepareStatement(sql);

View File

@ -1,6 +1,6 @@
package com.alttd.forms.mail.verification;
public enum VerificationResult {
NO_MAIL_ACCOUNT, FAILED_TO_SEND, VERIFICATION_SENT,
NO_MAIL_ACCOUNT, FAILED_TO_SEND, VERIFICATION_SENT, RATE_LIMIT_EXCEEDED, FAILED_TO_RETRIEVE_RATE_LIMIT_DATA;
}

View File

@ -1,6 +1,8 @@
package com.alttd.forms.mail.verification;
import com.alttd.forms.form.Form;
import com.alttd.forms.mail.MailSettings;
import com.alttd.forms.mail.rate_limitter.FormRateLimit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -15,7 +17,7 @@ public class Verify {
private static final Logger logger = LoggerFactory.getLogger(Verify.class);
public static CompletableFuture<VerificationResult> verifyEmail(String address, int code) {
public static CompletableFuture<VerificationResult> verifyEmail(String ip, String address, int code, Form form) {
logger.debug("verifyEmail");
Properties mailProperties = MailSettings.getMailProperties();
logger.debug("mailProperties: {}", mailProperties);
@ -30,35 +32,62 @@ public class Verify {
//TODO rate limit sending mail from IP and to specific e-mail addresses (max 1 per minute and max 10 per day)
//TODO include a link to all emails that people can click to block us from sending mail to them so no one can use us to spam ppl
return CompletableFuture.supplyAsync(() -> sendMail(ip, address, code, session, passwordAuthentication, form));
}
private static VerificationResult sendMail(String ip, String address, int code, Session session, PasswordAuthentication passwordAuthentication, Form form) {
Optional<Boolean> isRateLimited = FormRateLimit.getInstance().isRateLimited(ip, address).join();
if (isRateLimited.isEmpty()) {
return VerificationResult.FAILED_TO_RETRIEVE_RATE_LIMIT_DATA;
} else if (isRateLimited.get()) {
return VerificationResult.RATE_LIMIT_EXCEEDED;
}
Message message;
try {
logger.trace("Creating mail");
Message message = new MimeMessage(session);
message.setFrom(new InternetAddress(passwordAuthentication.getUserName()));
logger.trace("Set from to {}", passwordAuthentication.getUserName());
message.setRecipients(
Message.RecipientType.TO,
InternetAddress.parse(address)
);
logger.trace("Set recipients");
message.setSubject("Altitude Email Verification");
message.setText("Please verify your email by entering the following code on the page you made the form in\n" + code); //TODO pretty html stuff
logger.trace("Set code: {}", code);
//TODO include the form they filled in (also in pretty html stuff)
return CompletableFuture.supplyAsync(() -> {
try {
logger.trace("Sending mail");
Transport.send(message);
logger.trace("Sending mail succeeded");
return VerificationResult.VERIFICATION_SENT;
} catch (MessagingException e) {
logger.error("Failed to send mail", e);
return VerificationResult.FAILED_TO_SEND;
}
});
message = getMail(address, code, session, passwordAuthentication, form);
} catch (MessagingException e) {
logger.error("Failed to create MimeMessage", e);
return CompletableFuture.completedFuture(VerificationResult.FAILED_TO_SEND);
return VerificationResult.FAILED_TO_SEND;
}
try {
logger.trace("Sending mail");
Transport.send(message);
logger.trace("Sending mail succeeded");
return VerificationResult.VERIFICATION_SENT;
} catch (MessagingException e) {
logger.error("Failed to send mail", e);
return VerificationResult.FAILED_TO_SEND;
}
}
private static Message getMail(String address, int code, Session session, PasswordAuthentication passwordAuthentication, Form form) throws MessagingException {
logger.trace("Creating mail");
Message message = new MimeMessage(session);
message.setFrom(new InternetAddress(passwordAuthentication.getUserName()));
logger.trace("Set from to {}", passwordAuthentication.getUserName());
message.setRecipients(
Message.RecipientType.TO,
InternetAddress.parse(address)
);
logger.trace("Set recipients");
message.setSubject("Altitude Email Verification");
String body = String.format(
//language=HTML
"""
<title>
Email Verification
</title>
<p>Please verify your email by entering the following code on the page you made the form in:</p>
<p>%s</p>
<p></p>
<p>This is the form you're submitting:</p>
""", code);
body += form.toHtml();
message.setContent(body, "text/html");
logger.trace("Set code: {}", code);
return message;
}
}