fix: login shows correct error + brute-force protection
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s

- Wrong password now shows "Invalid username or password" (was "Session expired")
- Client API differentiates 401 on login page vs elsewhere
- Added per-IP login rate limiter: 3 failures → blocked 1 minute
- 429 status code returned with remaining seconds in message
- Rate limit cleared on successful login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-27 06:28:19 +02:00
co-authored by Claude Opus 4.6
parent 726479a57f
commit 6b65e0130f
3 changed files with 96 additions and 9 deletions
+78 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/ory/fosite"
@@ -36,6 +37,63 @@ type Handlers struct {
config Config
agentLister AgentLister
logger *slog.Logger
loginLimiter *loginRateLimiter
}
// loginRateLimiter tracks failed login attempts per IP.
type loginRateLimiter struct {
mu sync.Mutex
attempts map[string]*loginAttempt
}
type loginAttempt struct {
failures int
blockedAt time.Time
}
const (
maxLoginFailures = 3
loginBlockTime = 1 * time.Minute
)
func newLoginRateLimiter() *loginRateLimiter {
return &loginRateLimiter{attempts: make(map[string]*loginAttempt)}
}
func (l *loginRateLimiter) isBlocked(ip string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
a, ok := l.attempts[ip]
if !ok {
return false, 0
}
if a.failures >= maxLoginFailures && time.Since(a.blockedAt) < loginBlockTime {
remaining := loginBlockTime - time.Since(a.blockedAt)
return true, remaining
}
if time.Since(a.blockedAt) >= loginBlockTime {
delete(l.attempts, ip)
return false, 0
}
return false, 0
}
func (l *loginRateLimiter) recordFailure(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
a, ok := l.attempts[ip]
if !ok {
a = &loginAttempt{}
l.attempts[ip] = a
}
a.failures++
a.blockedAt = time.Now()
}
func (l *loginRateLimiter) clearFailures(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, ip)
}
// NewHandlers creates a new set of auth HTTP handlers.
@@ -53,6 +111,7 @@ func NewHandlers(
provider: provider,
config: config,
logger: slog.Default().With("component", "auth"),
loginLimiter: newLoginRateLimiter(),
}
}
@@ -116,6 +175,20 @@ func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Brute-force protection: block IP after 3 failed attempts for 1 minute
ip := remoteIP(r)
if blocked, remaining := h.loginLimiter.isBlocked(ip); blocked {
secs := int(remaining.Seconds()) + 1
LogAuthEvent(r.Context(), h.logger, AuthEvent{
Type: EventLoginFailure,
Username: "(rate-limited)",
RemoteIP: ip,
})
writeError(w, http.StatusTooManyRequests, "rate_limited",
fmt.Sprintf("Too many login attempts. Try again in %d seconds.", secs))
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -128,15 +201,19 @@ func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) {
user, err := h.userStore.VerifyPassword(r.Context(), req.Username, req.Password)
if err != nil {
h.loginLimiter.recordFailure(ip)
LogAuthEvent(r.Context(), h.logger, AuthEvent{
Type: EventLoginFailure,
Username: req.Username,
RemoteIP: remoteIP(r),
RemoteIP: ip,
})
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Invalid username or password")
return
}
// Successful login — clear rate limit
h.loginLimiter.clearFailures(ip)
session, err := h.sessionStore.CreateSession(r.Context(), user.ID, h.config.SessionLifetime)
if err != nil {
h.logger.Error("create session failed", "error", err)
+6 -6
View File
@@ -11,30 +11,30 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="/_app/immutable/entry/start.DkfAG9pH.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Ll39S8uO.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.C7Nk8VcW.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BX6BYLlQ.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BK7DUW2U.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CslSvznw.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C_dJMdcr.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Du3f5uIc.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B3RSY5nb.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.koRx5eH6.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.BMHPwGGu.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_v7zte8 = {
__sveltekit_wis49l = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.DkfAG9pH.js"),
import("/_app/immutable/entry/app.koRx5eH6.js")
import("/_app/immutable/entry/start.C7Nk8VcW.js"),
import("/_app/immutable/entry/app.BMHPwGGu.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+12 -2
View File
@@ -25,13 +25,23 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
const res = await fetch(path, opts);
if (res.status === 401) {
// Redirect to login on auth failure
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
// On login page, pass through the actual error message
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) {
const err = await res.json().catch(() => ({ error: 'unauthorized', message: 'Invalid username or password' }));
throw new ApiError(401, err.error || 'unauthorized', err.message || 'Invalid username or password');
}
// Elsewhere, redirect to login
if (typeof window !== 'undefined') {
window.location.href = `/login?return=${encodeURIComponent(window.location.pathname)}`;
}
throw new ApiError(401, 'unauthorized', 'Session expired');
}
if (res.status === 429) {
const err = await res.json().catch(() => ({ message: 'Too many attempts. Please wait and try again.' }));
throw new ApiError(429, 'rate_limited', err.message || 'Too many attempts. Please wait and try again.');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'unknown', message: res.statusText }));
throw new ApiError(res.status, err.error || 'unknown', err.message || err.error_description || res.statusText);