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:
parent
f0c84e809f
commit
fedb80f3c2
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user