diff --git a/docker/ss-atlas/internal/handlers/authelia.go b/docker/ss-atlas/internal/handlers/authelia.go index 432fd72..2272ae3 100644 --- a/docker/ss-atlas/internal/handlers/authelia.go +++ b/docker/ss-atlas/internal/handlers/authelia.go @@ -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 { diff --git a/docker/ss-atlas/internal/handlers/resend_ratelimit.go b/docker/ss-atlas/internal/handlers/resend_ratelimit.go new file mode 100644 index 0000000..904abe7 --- /dev/null +++ b/docker/ss-atlas/internal/handlers/resend_ratelimit.go @@ -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 diff --git a/docker/ss-atlas/internal/handlers/subscription.go b/docker/ss-atlas/internal/handlers/subscription.go index cc44bb4..87c3138 100644 --- a/docker/ss-atlas/internal/handlers/subscription.go +++ b/docker/ss-atlas/internal/handlers/subscription.go @@ -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) } diff --git a/docker/ss-atlas/templates/pages/activate.html b/docker/ss-atlas/templates/pages/activate.html index c2c4004..b38ca0b 100644 --- a/docker/ss-atlas/templates/pages/activate.html +++ b/docker/ss-atlas/templates/pages/activate.html @@ -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 @@
You need to sign in with your new password before activating your stack. If you haven't set your password yet, do that first.
Sign In +Didn't get the setup email?
+ + +Once you've signed in, you can activate your workspace from the dashboard.
+ {{if .Username}} +Didn't get the email?
+ + +