diff --git a/src/main/java/com/alttd/forms/Main.java b/src/main/java/com/alttd/forms/Main.java index aa296ac..c06358b 100644 --- a/src/main/java/com/alttd/forms/Main.java +++ b/src/main/java/com/alttd/forms/Main.java @@ -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() { diff --git a/src/main/java/com/alttd/forms/controlers/FormRequestHandler.java b/src/main/java/com/alttd/forms/controlers/FormRequestHandler.java new file mode 100644 index 0000000..7c1bd0b --- /dev/null +++ b/src/main/java/com/alttd/forms/controlers/FormRequestHandler.java @@ -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> handleRequestWithVerifyMail(Form form, String ip) { + CompletableFuture 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"); + }); + } +} diff --git a/src/main/java/com/alttd/forms/controlers/apply/StaffAppController.java b/src/main/java/com/alttd/forms/controlers/apply/StaffAppController.java index ee20cdd..b326bfd 100644 --- a/src/main/java/com/alttd/forms/controlers/apply/StaffAppController.java +++ b/src/main/java/com/alttd/forms/controlers/apply/StaffAppController.java @@ -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> submitForm(@Valid @RequestBody StaffAppFormData formData) { + public CompletableFuture> submitForm(@Valid @RequestBody StaffAppFormData formData, HttpServletRequest request) { logger.debug("submitForm"); logger.trace(formData.toString()); - CompletableFuture 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()); } } \ No newline at end of file diff --git a/src/main/java/com/alttd/forms/controlers/apply/StaffAppFormData.java b/src/main/java/com/alttd/forms/controlers/apply/StaffAppFormData.java index bb21d69..5c4baa3 100644 --- a/src/main/java/com/alttd/forms/controlers/apply/StaffAppFormData.java +++ b/src/main/java/com/alttd/forms/controlers/apply/StaffAppFormData.java @@ -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"}; diff --git a/src/main/java/com/alttd/forms/controlers/contact/ContactController.java b/src/main/java/com/alttd/forms/controlers/contact/ContactController.java index 715a4d0..e2efc99 100644 --- a/src/main/java/com/alttd/forms/controlers/contact/ContactController.java +++ b/src/main/java/com/alttd/forms/controlers/contact/ContactController.java @@ -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> submitForm(@Valid @RequestBody ContactFormData formData) { + public CompletableFuture> submitForm(@Valid @RequestBody ContactFormData formData, HttpServletRequest request) { logger.debug("submitForm"); logger.trace(formData.toString()); - CompletableFuture 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()); } } diff --git a/src/main/java/com/alttd/forms/controlers/contact/ContactFormData.java b/src/main/java/com/alttd/forms/controlers/contact/ContactFormData.java index 01fdb46..c33b8f6 100644 --- a/src/main/java/com/alttd/forms/controlers/contact/ContactFormData.java +++ b/src/main/java/com/alttd/forms/controlers/contact/ContactFormData.java @@ -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"}; diff --git a/src/main/java/com/alttd/forms/form/Form.java b/src/main/java/com/alttd/forms/form/Form.java index af7f439..7f4cd7c 100644 --- a/src/main/java/com/alttd/forms/form/Form.java +++ b/src/main/java/com/alttd/forms/form/Form.java @@ -18,20 +18,25 @@ public abstract class Form { public String toHtml(String[] fields, String[] values) { StringBuilder htmlOutput = new StringBuilder(); + //language=HTML htmlOutput.append(""); for (int i = 0; i < fields.length; i++) { - htmlOutput.append(""); - htmlOutput.append(""); - htmlOutput.append(""); - htmlOutput.append(""); + htmlOutput.append( + String.format( + //language=HTML + """ + + + + + """, fields[i], values[i])); } - + //language=HTML htmlOutput.append("
"); - htmlOutput.append(fields[i]); - htmlOutput.append(""); - htmlOutput.append(values[i]); - htmlOutput.append("
+ %s + + %s +
"); - return htmlOutput.toString(); } @@ -41,4 +46,6 @@ public abstract class Form { public abstract Optional getDiscordBotUrl(); public abstract String getReceiver(); + + public abstract String getSender(); } diff --git a/src/main/java/com/alttd/forms/mail/rate_limitter/FormRateLimit.java b/src/main/java/com/alttd/forms/mail/rate_limitter/FormRateLimit.java new file mode 100644 index 0000000..bb8e5d3 --- /dev/null +++ b/src/main/java/com/alttd/forms/mail/rate_limitter/FormRateLimit.java @@ -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 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> 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; + } + +} diff --git a/src/main/java/com/alttd/forms/mail/rate_limitter/RateLimitQuery.java b/src/main/java/com/alttd/forms/mail/rate_limitter/RateLimitQuery.java index af3baf5..6ea4ed1 100644 --- a/src/main/java/com/alttd/forms/mail/rate_limitter/RateLimitQuery.java +++ b/src/main/java/com/alttd/forms/mail/rate_limitter/RateLimitQuery.java @@ -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); diff --git a/src/main/java/com/alttd/forms/mail/verification/VerificationResult.java b/src/main/java/com/alttd/forms/mail/verification/VerificationResult.java index 8d7d295..a618ee3 100644 --- a/src/main/java/com/alttd/forms/mail/verification/VerificationResult.java +++ b/src/main/java/com/alttd/forms/mail/verification/VerificationResult.java @@ -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; } diff --git a/src/main/java/com/alttd/forms/mail/verification/Verify.java b/src/main/java/com/alttd/forms/mail/verification/Verify.java index baf35a5..0e97155 100644 --- a/src/main/java/com/alttd/forms/mail/verification/Verify.java +++ b/src/main/java/com/alttd/forms/mail/verification/Verify.java @@ -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 verifyEmail(String address, int code) { + public static CompletableFuture 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 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 + """ + + Email Verification + +

Please verify your email by entering the following code on the page you made the form in:

+

%s

+

+

This is the form you're submitting:

+ """, code); + body += form.toHtml(); + message.setContent(body, "text/html"); + logger.trace("Set code: {}", code); + return message; + } + }