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
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:
co-authored by
Claude Opus 4.6
parent
726479a57f
commit
6b65e0130f
@@ -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)
|
||||
|
||||
Vendored
+6
-6
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user