diff --git a/AquaNet/src/App.svelte b/AquaNet/src/App.svelte index 8483a0b4..64268330 100644 --- a/AquaNet/src/App.svelte +++ b/AquaNet/src/App.svelte @@ -80,6 +80,7 @@ + diff --git a/AquaNet/src/libs/i18n/en_ref.ts b/AquaNet/src/libs/i18n/en_ref.ts index 0f523b21..7fb75e88 100644 --- a/AquaNet/src/libs/i18n/en_ref.ts +++ b/AquaNet/src/libs/i18n/en_ref.ts @@ -34,21 +34,29 @@ export const EN_REF_Welcome = { 'back': 'Back', 'email': 'Email', 'password': 'Password', + 'new-password': 'New password', 'username': 'Username', 'welcome.btn-login': 'Log in', 'welcome.btn-signup': 'Sign up', + 'welcome.btn-reset-password': 'Forgot password?', + 'welcome.btn-submit-reset-password': 'Send reset link', + 'welcome.btn-submit-new-password': 'Change password', + 'welcome.email-missing': 'Email is required', + 'welcome.password-missing': 'Password is required', 'welcome.email-password-missing': 'Email and password are required', 'welcome.username-missing': 'Username/email is required', 'welcome.waiting-turnstile': 'Waiting for Turnstile to verify your network environment...', 'welcome.turnstile-error': 'Error verifying your network environment. Please turn off your VPN and try again.', 'welcome.turnstile-timeout': 'Network verification timed out. Please try again.', 'welcome.verification-sent': 'A verification email has been sent to ${email}. Please check your inbox!', + 'welcome.reset-password-sent': 'A password reset email has been sent to ${email}. Please check your inbox!', 'welcome.verify-state-0': 'You haven\'t verified your email. A verification email had been sent to your inbox less than a minute ago. Please check your inbox!', 'welcome.verify-state-1': 'You haven\'t verified your email. We\'ve already sent 3 emails over the last 24 hours so we\'ll not send another one. Please check your inbox!', 'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!', 'welcome.verifying': 'Verifying your email... please wait.', 'welcome.verified': 'Your email has been verified! You can now log in now.', 'welcome.verification-failed': 'Verification failed: ${message}. Please try again.', + 'welcome.password-reset-done': 'Your password has been changed! You can log in now.', } export const EN_REF_LEADERBOARD = { diff --git a/AquaNet/src/libs/sdk.ts b/AquaNet/src/libs/sdk.ts index 3904313e..bb0b65d8 100644 --- a/AquaNet/src/libs/sdk.ts +++ b/AquaNet/src/libs/sdk.ts @@ -163,12 +163,22 @@ async function login(user: { email: string, password: string, turnstile: string localStorage.setItem('token', data.token) } +async function resetPassword(user: { email: string, turnstile: string }) { + return await post('api/v2/user/reset-password', user) +} + +async function changePassword(user: { code: string, password: string }) { + return await post('/api/v2/user/change-password', user) +} + const isLoggedIn = () => !!localStorage.getItem('token') const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/') export const USER = { register, login, + resetPassword, + changePassword, confirmEmail: (token: string) => post('/api/v2/user/confirm-email', { token }), me: (): Promise => { diff --git a/AquaNet/src/pages/Welcome.svelte b/AquaNet/src/pages/Welcome.svelte index 6a1b0118..4fa30b50 100644 --- a/AquaNet/src/pages/Welcome.svelte +++ b/AquaNet/src/pages/Welcome.svelte @@ -20,31 +20,33 @@ let error = "" let verifyMsg = "" + let code = "" if (USER.isLoggedIn()) { window.location.href = "/home" } -if (location.pathname !== '/') { - location.href = `/${params.get('code') ? `?code=${params.get('code')}` : ""}` - } else - if (params.get('code')) { - + if (params.get('code')) { + code = params.get('code')! + if (location.pathname === '/verify') { state = 'verify' verifyMsg = t("welcome.verifying") submitting = true // Send request to server - USER.confirmEmail(params.get('code')!) + USER.confirmEmail(code) .then(() => { verifyMsg = t('welcome.verified') submitting = false - // Clear the query param - window.history.replaceState({}, document.title, window.location.pathname) - }) - .catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message })) + // Clear the query param + window.history.replaceState({}, document.title, window.location.pathname) + }) + .catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message })) } - + else if (location.pathname === '/reset-password') { + state = 'reset' + } + } async function submit(): Promise { submitting = true @@ -111,6 +113,52 @@ if (location.pathname !== '/') { submitting = false } + async function resetPassword(): Promise { + submitting = true; + + if (email === "") { + error = t("welcome.email-missing") + return submitting = false + } + + // Send request to server + await USER.resetPassword({ email, turnstile }) + .then(() => { + // Show email sent message, reusing email verify page + state = 'verify' + verifyMsg = t("welcome.reset-password-sent", { email }) + }) + .catch(e => { + error = e.message + submitting = false + turnstileReset() + }) + + submitting = false; + } + + async function changePassword(): Promise { + submitting = true + + if (password === "") { + error = t("welcome.password-missing") + return submitting = false + } + + // Send request to server + await USER.changePassword({ code, password }) + .then(() => { + verifyMsg = t("welcome.password-reset-done") + }) + .catch(e => { + error = e.message + submitting = false + turnstileReset() + }) + + submitting = false + } +
@@ -143,6 +191,9 @@ if (location.pathname !== '/') { {isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')} {/if} + {#if !submitting} + + {/if} {#if TURNSTILE_SITE_KEY} console.log(turnstile = e.detail.token)} @@ -151,6 +202,32 @@ if (location.pathname !== '/') { on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} /> {/if} + {:else if state === "submitreset"} + {:else if state === "verify"} + {:else if state === "reset"} + {/if} diff --git a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt index a1e6c519..3e887c61 100644 --- a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt +++ b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt @@ -29,6 +29,7 @@ class UserRegistrar( val geoIP: GeoIP, val jwt: JWT, val confirmationRepo: EmailConfirmationRepo, + val resetPasswordRepo: ResetPasswordRepo, val cardRepo: CardRepository, val cardService: CardService, val validator: AquaUserServices, @@ -144,6 +145,53 @@ class UserRegistrar( return mapOf("token" to token) } + @API("/reset-password") + @Doc("Reset password with a token sent through email to the user, if it exists.", "Success message") // wtf is the second param in this annotation? + suspend fun resetPassword( + @RP email: Str, @RP turnstile: Str, + request: HttpServletRequest + ) : Any { + + // Check captcha + val ip = geoIP.getIP(request) + log.info("Net: /user/reset-password from $ip : $email") + if (!turnstileService.validate(turnstile, ip)) 400 - "Invalid captcha" + + // Check if user exists, treat as email / username + val user = async { userRepo.findByEmailIgnoreCase(email) ?: userRepo.findByUsernameIgnoreCase(email) } + ?: return SUCCESS // obviously dont tell them if the email exists or not + + // Check if email is verified + if (!user.emailConfirmed && emailProps.enable) 400 - "Email not verified" // maybe similar logic to login here + + // Send a password reset email + emailService.sendPasswordReset(user) + + return SUCCESS + } + + @API("/change-password") + @Doc("Change a user's password given a reset code", "Success message") // again have no idea what it is + suspend fun changePassword( + @RP token: Str, @RP password: Str, + request: HttpServletRequest + ) : Any { + + // Find the reset token + val reset = async { resetPasswordRepo.findByToken(token) } + + // Check if the token is valid + if (reset == null) 400 - "Invalid token" + + // Check if the token is expired + if (reset.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired" + + // Change the password + async { userRepo.save(reset.aquaNetUser.apply { pwHash = validator.checkPwHash(password) }) } // how... + + return SUCCESS + } + @API("/confirm-email") @Doc("Confirm email address with a token sent through email to the user.", "Success message") suspend fun confirmEmail(@RP token: Str): Any { diff --git a/src/main/java/icu/samnyan/aqua/net/components/Email.kt b/src/main/java/icu/samnyan/aqua/net/components/Email.kt index 21f62cfa..73db2d97 100644 --- a/src/main/java/icu/samnyan/aqua/net/components/Email.kt +++ b/src/main/java/icu/samnyan/aqua/net/components/Email.kt @@ -5,6 +5,7 @@ import ext.Str import ext.logger import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.EmailConfirmation +import icu.samnyan.aqua.net.db.PasswordReset import icu.samnyan.aqua.net.db.EmailConfirmationRepo import org.simplejavamail.api.mailer.Mailer import org.simplejavamail.email.EmailBuilder @@ -38,10 +39,13 @@ class EmailService( val mailer: Mailer, val props: EmailProperties, val confirmationRepo: EmailConfirmationRepo, + val resetPasswordRepo: ResetPasswordRepo, ) { val log = logger() val confirmTemplate: Str = this::class.java.getResource("/email/confirm.html")?.readText() - ?: throw Exception("Email Template Not Found") + ?: throw Exception("Email Confirm Template Not Found") + val resetTemplate: Str = this::class.java.getResource("/email/reset.html")?.readText() + ?: throw Exception("Password Reset Template Not Found") @Async @EventListener(ApplicationStartedEvent::class) @@ -80,6 +84,26 @@ class EmailService( .buildEmail()).thenRun { log.info("Verification email sent to ${user.email}") } } + fun sendPasswordReset (user: AquaNetUser) { + if (!props.enable) return + + // Generate token (UUID4) + val token = UUID.randomUUID().toString() + val reset = ResetPassword(token = token, aquaNetUser = user, createdAt = Date().toInstant()) + resetPasswordRepo.save(reset) + + // Send email + log.info("Sending reset password email to ${user.email}") + mailer.sendMail(EmailBuilder.startingBlank() + .from(props.senderName, props.senderAddr) + .to(user.computedName, user.email) + .withSubject("Reset Your Password for AquaNet") + .withHTMLText(resetTemplate + .replace("{{name}}", user.computedName) + .replace("{{url}}", "https://${props.webHost}/reset-password?code=$token")) + .buildEmail()).thenRun { log.info("Reset password email sent to ${user.email}") } + } + fun testEmail(addr: Str, name: Str) { if (!props.enable) return diff --git a/src/main/java/icu/samnyan/aqua/net/db/AquaEmailResetPassword.kt b/src/main/java/icu/samnyan/aqua/net/db/AquaEmailResetPassword.kt new file mode 100644 index 00000000..98a0281c --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/net/db/AquaEmailResetPassword.kt @@ -0,0 +1,33 @@ +package icu.samnyan.aqua.net.db + +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.io.Serializable +import java.time.Instant + +@Entity +@Table(name = "aqua_net_email_reset_password") +class ResetPassword( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0, + + @Column(nullable = false) + var token: String = "", + + // Token creation time + @Column(nullable = false) + var createdAt: Instant = Instant.now(), + + // Linking to the AquaNetUser + @ManyToOne + @JoinColumn(name = "auId", referencedColumnName = "auId") + var aquaNetUser: AquaNetUser = AquaNetUser() +) : Serializable + +@Repository +interface ResetPasswordRepo : JpaRepository { + fun findByToken(token: String): ResetPassword? + fun findByAquaNetUserAuId(auId: Long): List +} \ No newline at end of file diff --git a/src/main/resources/email/reset.html b/src/main/resources/email/reset.html new file mode 100644 index 00000000..caf7fc44 --- /dev/null +++ b/src/main/resources/email/reset.html @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file