forked from Nixius/authelia
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:
parent
4ac4de9df2
commit
926ddc0356
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue