forked from Nixius/authelia
1
0
Fork 0

Add password reset resend button with 1-min rate limit

- Rate limiter per username (resend_ratelimit.go)
- handleResendReset accepts username or email, returns JSON
- Success page: Resend button with 60s cooldown from first send
- Activate page (NeedLogin): email input + Resend with cooldown

Made-with: Cursor
This commit is contained in:
Leopere 2026-03-04 17:18:51 -05:00
parent 4ac4de9df2
commit 926ddc0356
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
5 changed files with 250 additions and 5 deletions

View File

@ -6,23 +6,51 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
)
func (a *App) handleResendReset(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
if username == "" {
http.Error(w, "username required", http.StatusBadRequest)
if email := r.FormValue("email"); email != "" {
username = sanitizeUsername(email)
}
}
if username == "" {
respondResendError(w, http.StatusBadRequest, "email or username required", 0)
return
}
if ok, retryAfter := resendRateLimiter.allow(username); !ok {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
respondResendError(w, http.StatusTooManyRequests,
"please wait before requesting another email", retryAfter)
return
}
if err := a.triggerPasswordReset(username); err != nil {
log.Printf("resend-reset: failed for %s: %v", username, err)
http.Error(w, "failed to send email", http.StatusInternalServerError)
respondResendError(w, http.StatusInternalServerError, "failed to send email", 0)
return
}
resendRateLimiter.record(username)
log.Printf("resend-reset: password reset email sent for %s", username)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Password setup email sent. Check your inbox."))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": "Password setup email sent. Check your inbox.",
})
}
func respondResendError(w http.ResponseWriter, code int, msg string, retryAfter int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
body := map[string]any{"ok": false, "error": msg}
if retryAfter > 0 {
body["retry_after_seconds"] = retryAfter
}
json.NewEncoder(w).Encode(body)
}
func (a *App) triggerPasswordReset(username string) error {

View File

@ -0,0 +1,38 @@
package handlers
import (
"sync"
"time"
)
const resendCooldown = time.Minute
type resendLimiter struct {
mu sync.RWMutex
last map[string]time.Time
}
func (rl *resendLimiter) allow(username string) (ok bool, retryAfter int) {
rl.mu.Lock()
defer rl.mu.Unlock()
if rl.last == nil {
rl.last = make(map[string]time.Time)
}
last := rl.last[username]
elapsed := time.Since(last)
if elapsed < resendCooldown {
return false, int((resendCooldown - elapsed).Seconds())
}
return true, 0
}
func (rl *resendLimiter) record(username string) {
rl.mu.Lock()
defer rl.mu.Unlock()
if rl.last == nil {
rl.last = make(map[string]time.Time)
}
rl.last[username] = time.Now()
}
var resendRateLimiter resendLimiter

View File

@ -154,8 +154,13 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
// New or lapsed: send password email, show success page.
if err := a.triggerPasswordReset(result.Username); err != nil {
log.Printf("authelia reset trigger failed for %s: %v", username, err)
} else {
resendRateLimiter.record(result.Username)
}
if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{"AppURL": a.cfg.AppURL}); err != nil {
if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{
"AppURL": a.cfg.AppURL,
"Username": result.Username,
}); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}

View File

@ -55,6 +55,24 @@
transition: background 0.2s;
}
.btn:hover { background: var(--accent-hover); }
.btn-outline {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
padding: 0.5rem 1rem;
font-size: 0.9rem;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-outline:hover:not(:disabled) { opacity: 0.9; }
.btn-outline:disabled { opacity: 0.5; cursor: not-allowed; }
.resend { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.resend p { margin-bottom: 0.5rem; font-size: 0.9rem; }
.resend input { margin-right: 0.5rem; padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); width: 12rem; }
.resend .msg { font-size: 0.85rem; margin-top: 0.5rem; }
.resend .msg.success { color: var(--green); }
.resend .msg.error { color: #ef4444; }
.icon {
font-size: 3rem;
margin-bottom: 1rem;
@ -73,7 +91,74 @@
<h2>Sign In First</h2>
<p>You need to sign in with your new password before activating your stack. If you haven't set your password yet, do that first.</p>
<a href="{{.AutheliaURL}}" class="btn">Sign In</a>
<div class="resend">
<p>Didn't get the setup email?</p>
<form id="resend-form">
<input type="email" name="email" placeholder="Your email" required>
<button type="submit" class="btn-outline" id="resend-btn">Resend</button>
</form>
<p class="msg" id="resend-msg"></p>
</div>
</div>
<script>
(function() {
var form = document.getElementById('resend-form');
if (!form) return;
var btn = document.getElementById('resend-btn');
var msg = document.getElementById('resend-msg');
var cooldown = 0, interval;
function setCooldown(sec) {
cooldown = sec;
btn.disabled = true;
btn.textContent = 'Resend (' + sec + 's)';
if (interval) clearInterval(interval);
interval = setInterval(function() {
cooldown--;
if (cooldown <= 0) {
clearInterval(interval);
btn.disabled = false;
btn.textContent = 'Resend';
return;
}
btn.textContent = 'Resend (' + cooldown + 's)';
}, 1000);
}
form.addEventListener('submit', function(e) {
e.preventDefault();
btn.disabled = true;
msg.textContent = '';
fetch('/resend-reset', {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function(r) {
return r.json().then(function(data) {
if (data.ok) {
msg.textContent = data.message;
msg.className = 'msg success';
setCooldown(60);
} else if (data.retry_after_seconds) {
msg.textContent = data.error + ' Try again in ' + data.retry_after_seconds + 's.';
msg.className = 'msg error';
setCooldown(data.retry_after_seconds);
} else {
msg.textContent = data.error;
msg.className = 'msg error';
btn.disabled = false;
btn.textContent = 'Resend';
}
});
}).catch(function() {
msg.textContent = 'Something went wrong. Please try again.';
msg.className = 'msg error';
btn.disabled = false;
btn.textContent = 'Resend';
});
});
})();
</script>
{{else if .Ready}}
<div class="subtitle subtitle-green">Welcome, {{.User}}</div>
<div class="card">

View File

@ -39,6 +39,23 @@
.card ul { color: var(--muted); line-height: 1.7; margin: 1rem 0; padding-left: 1.25rem; }
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
.footer a { color: var(--accent); text-decoration: none; }
.resend { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.resend p { margin-bottom: 0.5rem; font-size: 0.9rem; }
.resend .btn-outline {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
padding: 0.5rem 1rem;
font-size: 0.9rem;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.2s;
}
.resend .btn-outline:hover:not(:disabled) { opacity: 0.9; }
.resend .btn-outline:disabled { opacity: 0.5; cursor: not-allowed; }
.resend .msg { font-size: 0.85rem; margin-top: 0.5rem; }
.resend .msg.success { color: var(--green, #22c55e); }
.resend .msg.error { color: #ef4444; }
</style>
{{template "analytics"}}
</head>
@ -54,10 +71,82 @@
<li>Enable two-factor authentication or a passkey</li>
</ul>
<p>Once you've signed in, you can activate your workspace from the dashboard.</p>
{{if .Username}}
<div class="resend">
<p>Didn't get the email?</p>
<form id="resend-form">
<input type="hidden" name="username" value="{{.Username}}">
<button type="submit" class="btn-outline" id="resend-btn" disabled>Resend (60s)</button>
</form>
<p class="msg" id="resend-msg"></p>
</div>
{{end}}
</div>
<div class="footer">
<a href="{{.AppURL}}/dashboard">Go to Dashboard</a>
</div>
</div>
{{if .Username}}
<script>
(function() {
var form = document.getElementById('resend-form');
var btn = document.getElementById('resend-btn');
var msg = document.getElementById('resend-msg');
var cooldown = 60, interval;
function setCooldown(sec) {
cooldown = sec;
btn.disabled = true;
btn.textContent = 'Resend (' + sec + 's)';
if (interval) clearInterval(interval);
interval = setInterval(function() {
cooldown--;
if (cooldown <= 0) {
clearInterval(interval);
btn.disabled = false;
btn.textContent = 'Resend';
return;
}
btn.textContent = 'Resend (' + cooldown + 's)';
}, 1000);
}
setCooldown(60);
form.addEventListener('submit', function(e) {
e.preventDefault();
btn.disabled = true;
msg.textContent = '';
fetch('/resend-reset', {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function(r) {
return r.json().then(function(data) {
if (data.ok) {
msg.textContent = data.message;
msg.className = 'msg success';
setCooldown(60);
} else if (data.retry_after_seconds) {
msg.textContent = data.error + ' Try again in ' + data.retry_after_seconds + 's.';
msg.className = 'msg error';
setCooldown(data.retry_after_seconds);
} else {
msg.textContent = data.error;
msg.className = 'msg error';
btn.disabled = false;
btn.textContent = 'Resend';
}
});
}).catch(function() {
msg.textContent = 'Something went wrong. Please try again.';
msg.className = 'msg error';
btn.disabled = false;
btn.textContent = 'Resend';
});
});
})();
</script>
{{end}}
</body>
</html>