mirror of
https://github.com/MewoLab/AquaDX.git
synced 2025-10-25 12:02:40 +00:00
Compare commits
12 Commits
4fb815a184
...
218d2788e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218d2788e8 | ||
|
|
0a37c2a854 | ||
|
|
7eda890473 | ||
|
|
2431bd09af | ||
|
|
7b21a38e17 | ||
|
|
bf51f48961 | ||
|
|
92868201a3 | ||
|
|
c01c40fe45 | ||
|
|
39ed8af840 | ||
|
|
82adf5c138 | ||
|
|
e0d12acf61 | ||
|
|
955743aecd |
@ -80,6 +80,7 @@
|
||||
<Router {url}>
|
||||
<Route path="/" component={Welcome} />
|
||||
<Route path="/verify" component={Welcome} /> <!-- For email verification only, backwards compatibility with AquaNet2 in the future -->
|
||||
<Route path="/reset-password" component={Welcome} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route path="/ranking" component={Ranking} />
|
||||
<Route path="/ranking/:game" component={Ranking} />
|
||||
|
||||
@ -34,21 +34,31 @@ 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.email-password-missing': 'Email and password are required',
|
||||
'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.username-missing': 'Username/email is required',
|
||||
'welcome.email-password-missing': 'Email and password are 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.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.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 has been sent to your inbox just now. Please check your inbox!',
|
||||
'welcome.verify-state-1': 'You haven\'t verified your email. You have requested too many emails, please try again later.',
|
||||
'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!',
|
||||
'welcome.reset-state-0': 'A reset email has been sent to your inbox just now. Please check your inbox!',
|
||||
'welcome.reset-state-1': 'Too many emails have been sent. Another will not be sent.',
|
||||
'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 updated! Please log back in.',
|
||||
}
|
||||
|
||||
export const EN_REF_LEADERBOARD = {
|
||||
|
||||
@ -46,21 +46,31 @@ const zhWelcome: typeof EN_REF_Welcome = {
|
||||
'back': '返回',
|
||||
'email': '邮箱',
|
||||
'password': '密码',
|
||||
'new-password': '新密码',
|
||||
'username': '用户名',
|
||||
'welcome.btn-login': '登录',
|
||||
'welcome.btn-signup': '注册',
|
||||
'welcome.email-password-missing': '邮箱和密码必须填哦',
|
||||
'welcome.btn-reset-password': '忘记密码?',
|
||||
'welcome.btn-submit-reset-password': '发送重置链接',
|
||||
'welcome.btn-submit-new-password': '修改密码',
|
||||
'welcome.email-missing': '邮箱必须填哦',
|
||||
'welcome.password-missing': '密码必须填哦',
|
||||
'welcome.username-missing': '用户名/邮箱必须填哦',
|
||||
'welcome.email-password-missing': '邮箱和密码必须填哦',
|
||||
'welcome.waiting-turnstile': '正在验证网络环境…',
|
||||
'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试',
|
||||
'welcome.turnstile-timeout': '验证网络环境超时了,请重试',
|
||||
'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱',
|
||||
'welcome.reset-password-sent': '重置邮件已发送至 ${email},请翻翻收件箱',
|
||||
'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱',
|
||||
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱',
|
||||
'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
|
||||
'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!',
|
||||
'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了',
|
||||
'welcome.verifying': '正在验证邮箱…请稍等',
|
||||
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
|
||||
'welcome.verification-failed': '验证失败:${message}。请重试',
|
||||
'welcome.password-reset-done': '您的密码已更新!请重新登录',
|
||||
}
|
||||
|
||||
const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
|
||||
|
||||
@ -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: { token: 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<AquaNetUser> => {
|
||||
|
||||
@ -20,31 +20,33 @@
|
||||
|
||||
let error = ""
|
||||
let verifyMsg = ""
|
||||
let token = ""
|
||||
|
||||
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')) {
|
||||
token = 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(token)
|
||||
.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<any> {
|
||||
submitting = true
|
||||
|
||||
@ -102,7 +104,7 @@ if (location.pathname !== '/') {
|
||||
}
|
||||
else {
|
||||
error = e.message
|
||||
submitting = false
|
||||
submitting = false // unnecessary? see line 113, same for both reset functions
|
||||
turnstileReset()
|
||||
}
|
||||
})
|
||||
@ -111,6 +113,69 @@ if (location.pathname !== '/') {
|
||||
submitting = false
|
||||
}
|
||||
|
||||
async function resetPassword(): Promise<any> {
|
||||
submitting = true;
|
||||
|
||||
if (email === "") {
|
||||
error = t("welcome.email-missing")
|
||||
return submitting = false
|
||||
}
|
||||
|
||||
if (TURNSTILE_SITE_KEY && turnstile === "") {
|
||||
// Sleep for 100ms to allow Turnstile to finish
|
||||
error = t("welcome.waiting-turnstile")
|
||||
return setTimeout(resetPassword, 100)
|
||||
}
|
||||
|
||||
// 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 => {
|
||||
if (e.message === "Reset request rejected - STATE_0") {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.reset-state-0")
|
||||
}
|
||||
else if (e.message === "Reset request rejected - STATE_1") {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.reset-state-1")
|
||||
}
|
||||
else {
|
||||
error = e.message
|
||||
submitting = false
|
||||
turnstileReset()
|
||||
}
|
||||
})
|
||||
|
||||
submitting = false
|
||||
}
|
||||
|
||||
async function changePassword(): Promise<any> {
|
||||
submitting = true
|
||||
|
||||
if (password === "") {
|
||||
error = t("welcome.password-missing")
|
||||
return submitting = false
|
||||
}
|
||||
|
||||
// Send request to server
|
||||
await USER.changePassword({ token, password })
|
||||
.then(() => {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.password-reset-done")
|
||||
})
|
||||
.catch(e => {
|
||||
error = e.message
|
||||
submitting = false
|
||||
turnstileReset()
|
||||
})
|
||||
|
||||
submitting = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<main id="home" class="no-margin">
|
||||
@ -126,11 +191,13 @@ if (location.pathname !== '/') {
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
|
||||
role="button" tabindex="0" class="clickable">
|
||||
<Icon icon="line-md:chevron-small-left" />
|
||||
<span>{t('back')}</span>
|
||||
</div>
|
||||
{#if error != t("welcome.waiting-turnstile")}
|
||||
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
|
||||
role="button" tabindex="0" class="clickable">
|
||||
<Icon icon="line-md:chevron-small-left" />
|
||||
<span>{t('back')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isSignup}
|
||||
<input type="text" placeholder={t('username')} bind:value={username}>
|
||||
{/if}
|
||||
@ -143,6 +210,9 @@ if (location.pathname !== '/') {
|
||||
{isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if state === "login" && !submitting}
|
||||
<button on:click={() => state = 'submitreset'}>{t('welcome.btn-reset-password')}</button>
|
||||
{/if}
|
||||
{#if TURNSTILE_SITE_KEY}
|
||||
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
|
||||
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
|
||||
@ -151,6 +221,34 @@ if (location.pathname !== '/') {
|
||||
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if state === "submitreset"}
|
||||
<div class="login-form" transition:slide>
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
{#if error != t("welcome.waiting-turnstile")}
|
||||
<div on:click={() => state = 'login'} on:keypress={() => state = 'login'}
|
||||
role="button" tabindex="0" class="clickable">
|
||||
<Icon icon="line-md:chevron-small-left" />
|
||||
<span>{t('back')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<input type="email" placeholder={t('email')} bind:value={email}>
|
||||
<button on:click={resetPassword}>
|
||||
{#if submitting}
|
||||
<Icon icon="line-md:loading-twotone-loop"/>
|
||||
{:else}
|
||||
{t('welcome.btn-submit-reset-password')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if TURNSTILE_SITE_KEY}
|
||||
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
|
||||
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
|
||||
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
|
||||
on:turnstile-expired={_ => window.location.reload()}
|
||||
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if state === "verify"}
|
||||
<div class="login-form" transition:slide>
|
||||
<span>{verifyMsg}</span>
|
||||
@ -158,6 +256,20 @@ if (location.pathname !== '/') {
|
||||
<button on:click={() => state = 'home'} transition:slide>{t('back')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if state === "reset"}
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
<div class="login-form" transition:slide>
|
||||
<input type="password" placeholder={t('new-password')} bind:value={password}>
|
||||
<button on:click={changePassword}>
|
||||
{#if submitting}
|
||||
<Icon icon="line-md:loading-twotone-loop"/>
|
||||
{:else}
|
||||
{t('welcome.btn-submit-new-password')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@ Located at: [icu.samnyan.aqua.net.UserRegistrar](icu/samnyan/aqua/net/UserRegist
|
||||
* token: String
|
||||
* **Returns**: User information
|
||||
|
||||
**/user/login** : Login with email/username and password. This will also check if the email is verified and send another confirmation
|
||||
**/user/login** : Login with email/username and password. This will also check if the email is verified and send another confirmation.
|
||||
|
||||
* email: String
|
||||
* password: String
|
||||
@ -74,6 +74,18 @@ Located at: [icu.samnyan.aqua.net.UserRegistrar](icu/samnyan/aqua/net/UserRegist
|
||||
* turnstile: String
|
||||
* **Returns**: Success message
|
||||
|
||||
**/user/reset-password** : Send the user a reset password email. This will also check if the email is verified or if many requests were sent recently.
|
||||
|
||||
* email: String
|
||||
* turnstile: String
|
||||
* **Returns** Success message
|
||||
|
||||
**/user/change-password** : Reset a user's password with a token sent through email to the user.
|
||||
|
||||
* token: String
|
||||
* password: String
|
||||
* **Returns** Success message
|
||||
|
||||
**/user/setting** : Validate and set a user setting field.
|
||||
|
||||
* token: String
|
||||
|
||||
@ -29,10 +29,12 @@ class UserRegistrar(
|
||||
val geoIP: GeoIP,
|
||||
val jwt: JWT,
|
||||
val confirmationRepo: EmailConfirmationRepo,
|
||||
val resetPasswordRepo: ResetPasswordRepo,
|
||||
val cardRepo: CardRepository,
|
||||
val cardService: CardService,
|
||||
val validator: AquaUserServices,
|
||||
val emailProps: EmailProperties,
|
||||
val sessionRepo: SessionTokenRepo,
|
||||
final val paths: PathProps
|
||||
) {
|
||||
val portraitPath = paths.aquaNetPortrait.path()
|
||||
@ -144,6 +146,73 @@ 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")
|
||||
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"
|
||||
|
||||
val resets = async { resetPasswordRepo.findByAquaNetUserAuId(user.auId) }
|
||||
val lastReset = resets.maxByOrNull { it.createdAt }
|
||||
|
||||
if (lastReset?.createdAt?.plusSeconds(60)?.isAfter(Instant.now()) == true) {
|
||||
400 - "Reset request rejected - STATE_0"
|
||||
}
|
||||
|
||||
// Check if we have sent more than 3 confirmation emails in the last 24 hours
|
||||
if (resets.count { it.createdAt.plusSeconds(60 * 60 * 24).isAfter(Instant.now()) } > 3) {
|
||||
400 - "Reset request rejected - STATE_1"
|
||||
}
|
||||
|
||||
// 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")
|
||||
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) }) }
|
||||
|
||||
// Remove the token from the list
|
||||
resetPasswordRepo.delete(reset)
|
||||
|
||||
// Clear all sessions
|
||||
sessionRepo.deleteAll(
|
||||
sessionRepo.findByAquaNetUserAuId(reset.aquaNetUser.auId)
|
||||
)
|
||||
|
||||
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 {
|
||||
@ -185,6 +254,12 @@ class UserRegistrar(
|
||||
|
||||
// Save the user
|
||||
userRepo.save(u)
|
||||
|
||||
// Clear all tokens if changing password
|
||||
if (key == "pwHash")
|
||||
sessionRepo.deleteAll(
|
||||
sessionRepo.findByAquaNetUserAuId(u.auId)
|
||||
)
|
||||
}
|
||||
|
||||
SUCCESS
|
||||
|
||||
@ -6,6 +6,8 @@ import ext.logger
|
||||
import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import icu.samnyan.aqua.net.db.EmailConfirmation
|
||||
import icu.samnyan.aqua.net.db.EmailConfirmationRepo
|
||||
import icu.samnyan.aqua.net.db.ResetPassword
|
||||
import icu.samnyan.aqua.net.db.ResetPasswordRepo
|
||||
import org.simplejavamail.api.mailer.Mailer
|
||||
import org.simplejavamail.email.EmailBuilder
|
||||
import org.simplejavamail.springsupport.SimpleJavaMailSpringSupport
|
||||
@ -38,10 +40,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 +85,29 @@ class EmailService(
|
||||
.buildEmail()).thenRun { log.info("Verification email sent to ${user.email}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset password email to the user
|
||||
*/
|
||||
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
|
||||
|
||||
|
||||
@ -1,77 +1,111 @@
|
||||
package icu.samnyan.aqua.net.components
|
||||
|
||||
import ext.Str
|
||||
import ext.minus
|
||||
import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import icu.samnyan.aqua.net.db.AquaNetUserRepo
|
||||
import io.jsonwebtoken.JwtParser
|
||||
import io.jsonwebtoken.Jwts
|
||||
import io.jsonwebtoken.security.Keys
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "aqua-net.jwt")
|
||||
class JWTProperties {
|
||||
var secret: Str = "Open Sesame!"
|
||||
}
|
||||
|
||||
@Service
|
||||
class JWT(
|
||||
val props: JWTProperties,
|
||||
val userRepo: AquaNetUserRepo
|
||||
) {
|
||||
val log = LoggerFactory.getLogger(JWT::class.java)!!
|
||||
lateinit var key: SecretKey
|
||||
lateinit var parser: JwtParser
|
||||
|
||||
@PostConstruct
|
||||
fun onLoad() {
|
||||
// Check secret
|
||||
if (props.secret == "Open Sesame!") {
|
||||
log.warn("USING DEFAULT JWT SECRET, PLEASE SET aqua-net.jwt IN CONFIGURATION")
|
||||
}
|
||||
|
||||
// Pad byte array to 256 bits
|
||||
var ba = props.secret.toByteArray()
|
||||
if (ba.size < 32) {
|
||||
log.warn("JWT Secret is less than 256 bits, padding with 0. PLEASE USE A STRONGER SECRET!")
|
||||
ba = ByteArray(32).also { ba.copyInto(it) }
|
||||
}
|
||||
|
||||
// Initialize key
|
||||
key = Keys.hmacShaKeyFor(ba)
|
||||
|
||||
// Create parser
|
||||
parser = Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
|
||||
log.info("JWT Service Enabled")
|
||||
}
|
||||
|
||||
|
||||
fun gen(user: AquaNetUser): Str = Jwts.builder().header()
|
||||
.keyId("aqua-net")
|
||||
.and()
|
||||
.subject(user.auId.toString())
|
||||
.issuedAt(Date())
|
||||
.signWith(key)
|
||||
.compact()
|
||||
|
||||
fun parse(token: Str): AquaNetUser? = try {
|
||||
userRepo.findByAuId(parser.parseSignedClaims(token).payload.subject.toLong())
|
||||
} catch (e: Exception) {
|
||||
log.debug("Failed to parse JWT", e)
|
||||
null
|
||||
}
|
||||
|
||||
fun auth(token: Str) = parse(token) ?: (400 - "Invalid token")
|
||||
|
||||
final inline fun <T> auth(token: Str, block: (AquaNetUser) -> T) = block(auth(token))
|
||||
package icu.samnyan.aqua.net.components
|
||||
|
||||
import ext.Str
|
||||
import ext.minus
|
||||
import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import icu.samnyan.aqua.net.db.AquaNetUserRepo
|
||||
import icu.samnyan.aqua.net.db.SessionToken
|
||||
import icu.samnyan.aqua.net.db.SessionTokenRepo
|
||||
import io.jsonwebtoken.JwtParser
|
||||
import io.jsonwebtoken.Jwts
|
||||
import io.jsonwebtoken.security.Keys
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.transaction.Transactional
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "aqua-net.jwt")
|
||||
class JWTProperties {
|
||||
var secret: Str = "Open Sesame!"
|
||||
}
|
||||
|
||||
@Service
|
||||
class JWT(
|
||||
val props: JWTProperties,
|
||||
val userRepo: AquaNetUserRepo,
|
||||
val sessionRepo: SessionTokenRepo
|
||||
) {
|
||||
val log = LoggerFactory.getLogger(JWT::class.java)!!
|
||||
lateinit var key: SecretKey
|
||||
lateinit var parser: JwtParser
|
||||
|
||||
@PostConstruct
|
||||
fun onLoad() {
|
||||
// Check secret
|
||||
if (props.secret == "Open Sesame!") {
|
||||
log.warn("USING DEFAULT JWT SECRET, PLEASE SET aqua-net.jwt IN CONFIGURATION")
|
||||
}
|
||||
|
||||
// Pad byte array to 256 bits
|
||||
var ba = props.secret.toByteArray()
|
||||
if (ba.size < 32) {
|
||||
log.warn("JWT Secret is less than 256 bits, padding with 0. PLEASE USE A STRONGER SECRET!")
|
||||
ba = ByteArray(32).also { ba.copyInto(it) }
|
||||
}
|
||||
|
||||
// Initialize key
|
||||
key = Keys.hmacShaKeyFor(ba)
|
||||
|
||||
// Create parser
|
||||
parser = Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
|
||||
log.info("JWT Service Enabled")
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun gen(user: AquaNetUser): Str {
|
||||
val activeTokens = sessionRepo.findByAquaNetUserAuId(user.auId)
|
||||
.sortedByDescending { it.expiry }.drop(4) // the cap is 5, but we append a new token after the fact
|
||||
if (activeTokens.isNotEmpty()) {
|
||||
sessionRepo.deleteAll(activeTokens)
|
||||
}
|
||||
val token = SessionToken().apply {
|
||||
aquaNetUser = user
|
||||
}
|
||||
sessionRepo.save(token)
|
||||
|
||||
return Jwts.builder().header()
|
||||
.keyId("aqua-net")
|
||||
.and()
|
||||
.subject(token.token)
|
||||
.issuedAt(Date())
|
||||
.signWith(key)
|
||||
.compact()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun parse(token: Str): AquaNetUser? {
|
||||
try {
|
||||
val uuid = parser.parseSignedClaims(token).payload.subject.toString()
|
||||
val token = sessionRepo.findByToken(uuid)
|
||||
|
||||
if (token != null) {
|
||||
val toBeRemoved = sessionRepo.findByAquaNetUserAuId(token.aquaNetUser.auId)
|
||||
.filter { it.expiry < Instant.now() }
|
||||
if (toBeRemoved.isNotEmpty())
|
||||
sessionRepo.deleteAll(toBeRemoved)
|
||||
if (token.expiry < Instant.now()) {
|
||||
sessionRepo.delete(token)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return token?.aquaNetUser
|
||||
} catch (e: Exception) {
|
||||
log.debug("Failed to parse JWT", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun auth(token: Str) = parse(token) ?: (400 - "Invalid token")
|
||||
|
||||
final inline fun <T> auth(token: Str, block: (AquaNetUser) -> T) = block(auth(token))
|
||||
}
|
||||
@ -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<ResetPassword, Long> {
|
||||
fun findByToken(token: String): ResetPassword?
|
||||
fun findByAquaNetUserAuId(auId: Long): List<ResetPassword>
|
||||
}
|
||||
31
src/main/java/icu/samnyan/aqua/net/db/AquaNetSession.kt
Normal file
31
src/main/java/icu/samnyan/aqua/net/db/AquaNetSession.kt
Normal file
@ -0,0 +1,31 @@
|
||||
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
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "aqua_net_session")
|
||||
class SessionToken(
|
||||
@Id
|
||||
@Column(nullable = false)
|
||||
var token: String = UUID.randomUUID().toString(),
|
||||
|
||||
// Token creation time
|
||||
@Column(nullable = false)
|
||||
var expiry: Instant = Instant.now().plusSeconds(3 * 86400),
|
||||
|
||||
// Linking to the AquaNetUser
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "auId", referencedColumnName = "auId")
|
||||
var aquaNetUser: AquaNetUser = AquaNetUser()
|
||||
) : Serializable
|
||||
|
||||
@Repository
|
||||
interface SessionTokenRepo : JpaRepository<SessionToken, String> {
|
||||
fun findByToken(token: String): SessionToken?
|
||||
fun findByAquaNetUserAuId(auId: Long): List<SessionToken>
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
CREATE TABLE aqua_net_user_fedy
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
au_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fedy_on_aqua_net_user FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT unq_fedy_on_aqua_net_user UNIQUE (au_id)
|
||||
);
|
||||
@ -154,4 +154,4 @@ VALUES
|
||||
(16311,4,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16312,11,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(99000,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(99001,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true);
|
||||
(99001,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true);
|
||||
@ -0,0 +1,27 @@
|
||||
INSERT INTO chusan_game_event (id, type, end_date, start_date, enable)
|
||||
VALUES
|
||||
(16600,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16601,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16602,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16603,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16604,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16605,8,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16606,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16607,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16608,16,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16609,5,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16610,4,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16611,11,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16612,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16650,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16651,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16652,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16653,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16654,8,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16655,11,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16700,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16701,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16702,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16703,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16704,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16705,5,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true);
|
||||
@ -0,0 +1,16 @@
|
||||
INSERT INTO chusan_game_event (id, type, end_date, start_date, enable)
|
||||
VALUES
|
||||
(16550, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16551, 3, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16552, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16553, 2, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16554, 8, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16555, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16556, 2, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16557, 8, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16558, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16559, 2, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16560, 8, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16561, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16562, 7, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
|
||||
(16563, 10, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true);
|
||||
22
src/main/resources/db/80/V1000_55__net_session.sql
Normal file
22
src/main/resources/db/80/V1000_55__net_session.sql
Normal file
@ -0,0 +1,22 @@
|
||||
CREATE TABLE aqua_net_session
|
||||
(
|
||||
token VARCHAR(36) NOT NULL,
|
||||
expiry datetime NOT NULL,
|
||||
au_id BIGINT NULL,
|
||||
CONSTRAINT pk_session PRIMARY KEY (token)
|
||||
);
|
||||
|
||||
ALTER TABLE aqua_net_session
|
||||
ADD CONSTRAINT FK_SESSION FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id);
|
||||
|
||||
CREATE TABLE aqua_net_email_reset_password
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT NOT NULL,
|
||||
token VARCHAR(255) NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
au_id BIGINT NULL,
|
||||
CONSTRAINT pk_email_reset_password PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
ALTER TABLE aqua_net_email_reset_password
|
||||
ADD CONSTRAINT FK_EMAIL_RESET_PASSWORD_ON_AQUA_USER FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id);
|
||||
272
src/main/resources/email/reset.html
Normal file
272
src/main/resources/email/reset.html
Normal file
@ -0,0 +1,272 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head>
|
||||
<title></title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/><!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]--><!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@100;200;300;400;500;600;700;800;900" rel="stylesheet" type="text/css"/><!--<![endif]-->
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important;
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
max-height: 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image_block img+div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width:700px) {
|
||||
|
||||
.desktop_hide table.icons-inner,
|
||||
.row-3 .column-1 .block-3.button_block .alignment a,
|
||||
.row-3 .column-1 .block-3.button_block .alignment div {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.image_block div.fullWidth {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
min-height: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0px;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.row-1 .column-1 .block-1.icons_block .alignment,
|
||||
.row-3 .column-1 .block-3.button_block .alignment {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.row-3 .column-1 .block-2.paragraph_block td.pad>div {
|
||||
text-align: left !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.row-3 .column-1 .block-1.heading_block h1 {
|
||||
text-align: center !important;
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
.row-3 .column-1 .block-1.heading_block td.pad {
|
||||
padding: 15px 0 !important;
|
||||
}
|
||||
|
||||
.row-3 .column-1 .block-4.paragraph_block td.pad>div {
|
||||
text-align: justify !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.row-3 .column-1 .block-3.button_block a,
|
||||
.row-3 .column-1 .block-3.button_block div,
|
||||
.row-3 .column-1 .block-3.button_block span {
|
||||
font-size: 14px !important;
|
||||
line-height: 28px !important;
|
||||
}
|
||||
|
||||
.row-3 .column-1 {
|
||||
padding: 0 24px 48px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color: #f8f6ff; margin: 0; padding: 0; -webkit-text-size-adjust: none; text-size-adjust: none;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f8f6ff; background-image: none; background-position: top left; background-size: auto; background-repeat: no-repeat;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; width: 680px; margin: 0 auto;" width="680">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 32px; padding-left: 48px; padding-right: 48px; padding-top: 32px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="vertical-align: middle; color: white; font-family: inherit; font-size: 24px; font-weight: 400; letter-spacing: 6px; text-align: left;">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align: middle; text-align: left;"><!--[if vml]><table align="left" cellpadding="0" cellspacing="0" role="presentation" style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; display: inline-block; margin-right: -4px; padding-left: 0px; padding-right: 0px;"><!--<![endif]-->
|
||||
<tr>
|
||||
<td style="vertical-align: middle; text-align: center; padding-top: 0px; padding-bottom: 0px; padding-left: 0px; padding-right: 15px;"><img align="center" class="icon" height="32" src="https://aquadx.net/assets/icons/android-chrome-192x192.png" style="display: block; height: auto; margin: 0 auto; border: 0;" width="32"/></td>
|
||||
<td style="font-family: 'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; font-size: 24px; font-weight: 400; color: white; vertical-align: middle; letter-spacing: 6px; text-align: left;">AquaDX</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; border-radius: 0; width: 680px; margin: 0 auto;" width="680">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0px;padding-left:0px;">
|
||||
<div align="center" class="alignment" style="line-height:10px">
|
||||
<div class="fullWidth" style="max-width: 646px;"><img alt="" src="https://aquadx.net/assets/email/border.png" style="display: block; height: auto; border: 0; width: 100%;" title="An open email illustration" width="646"/></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-3" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: white; border-radius: 0; color: #000000; width: 680px; margin: 0 auto;" width="680">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 48px; padding-left: 48px; padding-right: 48px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="heading_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="padding-bottom:12px;text-align:center;width:100%;">
|
||||
<h1 style="margin: 0; color: #292929; direction: ltr; font-family: 'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; font-size: 32px; font-weight: 700; letter-spacing: normal; line-height: 120%; text-align: left; margin-top: 0; margin-bottom: 0; mso-line-height-alt: 38.4px;"><span class="tinyMce-placeholder">Reset your password!</span></h1>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="paragraph_block block-2" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="padding-bottom:10px;padding-top:10px;">
|
||||
<div style="color:#101112;direction:ltr;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;font-weight:400;letter-spacing:0px;line-height:120%;text-align:left;mso-line-height-alt:19.2px;">
|
||||
<p style="margin: 0; margin-bottom: 16px;">Dear {{name}},</p>
|
||||
<p style="margin: 0; margin-bottom: 16px;">You recently requested to reset your AquaDX password. To reset your password, please click the link below.</p>
|
||||
<p style="margin: 0;">This link will allow you to reset your password, and it is valid for 24 hours. If you did not initiate this request, please ignore this email.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="button_block block-3" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="padding-bottom:15px;padding-top:15px;text-align:left;">
|
||||
<div align="left" class="alignment"><!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{url}}" style="height:48px;width:168px;v-text-anchor:middle;" arcsize="17%" stroke="false" fillcolor="#646cff">
|
||||
<w:anchorlock/>
|
||||
<v:textbox inset="0px,0px,0px,0px">
|
||||
<center style="color:#ffffff; font-family:'Trebuchet MS', Tahoma, sans-serif; font-size:16px">
|
||||
<![endif]--><a href="{{url}}" style="text-decoration:none;display:inline-block;color:#ffffff;background-color:#646cff;border-radius:8px;width:auto;border-top:0px solid transparent;font-weight:400;border-right:0px solid transparent;border-bottom:0px solid transparent;border-left:0px solid transparent;padding-top:8px;padding-bottom:8px;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;text-align:center;mso-border-alt:none;word-break:keep-all;" target="_blank"><span style="padding-left:16px;padding-right:16px;font-size:16px;display:inline-block;letter-spacing:normal;"><span style="word-break: break-word; line-height: 32px;">Reset password</span></span></a><!--[if mso]></center></v:textbox></v:roundrect><![endif]--></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="paragraph_block block-4" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="padding-top:16px;">
|
||||
<div style="color:#666666;direction:ltr;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:12px;font-weight:400;letter-spacing:0px;line-height:120%;text-align:left;mso-line-height-alt:14.399999999999999px;">
|
||||
<p style="margin: 0; margin-bottom: 12px;">If you're having trouble clicking the link, you can also copy and paste the URL below into your web browser:</p>
|
||||
<p style="margin: 0;">{{url}}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-4" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; width: 680px; margin: 0 auto;" width="680">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 32px; padding-left: 48px; padding-right: 48px; padding-top: 32px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
|
||||
<div class="spacer_block block-1" style="height:60px;line-height:60px;font-size:1px;"> </div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user