Skip to content

Admin portal auth#78

Open
victor-cuevas wants to merge 2 commits intofeat/admin-portalfrom
feat/admin-auth
Open

Admin portal auth#78
victor-cuevas wants to merge 2 commits intofeat/admin-portalfrom
feat/admin-auth

Conversation

@victor-cuevas
Copy link
Copy Markdown
Contributor

No description provided.

@victor-cuevas victor-cuevas requested a review from qarlosh April 9, 2026 10:01
@victor-cuevas victor-cuevas changed the title Admi portal auth Admin portal auth Apr 9, 2026
Bcrypt password hashing, session cookies (24h TTL / 2h idle), CSRF
double-submit cookie, IP-based login rate limiting, and CLI subcommands
for user creation and password reset.
…gement

Wire the Go auth backend into the Vue frontend: login page, password
change settings, navigation guards, CSRF token injection, and 401
session-expiry interceptor.
Comment thread admin/auth/ratelimit.go
}

// Record logs a failed login attempt for the given IP.
func (rl *RateLimiter) Record(ip string) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded memory growth. prune(ip) is only called from Allow() (line 36) and only for that one IP. Record() here never prunes, and Reset() only clears on successful login — which attackers won't hit. An IP that records a failure and never returns (distributed brute force, scanners, NAT churn) keeps its entry in attempts forever. No size cap either.

Suggest a Sweep() that walks the whole map, plus a background goroutine started from runServer alongside cleanupExpiredSessions:

func (rl *RateLimiter) Sweep() {
    rl.mu.Lock()
    defer rl.mu.Unlock()
    cutoff := rl.now().Add(-rl.window)
    for ip, attempts := range rl.attempts {
        i := 0
        for i < len(attempts) && attempts[i].Before(cutoff) { i++ }
        if i == len(attempts) {
            delete(rl.attempts, ip)
        } else if i > 0 {
            rl.attempts[ip] = attempts[i:]
        }
    }
}

Comment thread admin/api/auth.go
})
}

func clientIP(r *http.Request) string {
Copy link
Copy Markdown
Contributor

@sergiopalacio sergiopalacio Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate-limit key is the raw TCP peer address. Today clientIP returns r.RemoteAddr, which behind a reverse proxy / LB is the proxy's IP on every request — 5 failed logins from any attacker lock out every user until the window expires.

Two ways to fix, depending on deployment assumptions:

Option A — always direct-to-internet. Document the assumption and explicitly reject X-Forwarded-For (so an attacker can't spoof the header to escape the per-IP counter):

// Admin portal is deployed direct-to-internet; X-Forwarded-For from clients
// is attacker-controlled and must be ignored for rate-limiting.
func clientIP(r *http.Request) string {
    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        return r.RemoteAddr
    }
    return host
}

Option B — can sit behind a trusted proxy. Add a config knob (server.trusted_proxies: ["10.0.0.0/8", ...]) and only trust X-Forwarded-For when the direct peer is on that list. Walk the header right-to-left, first untrusted IP wins:

func clientIP(r *http.Request, trusted []netip.Prefix) string {
    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        host = r.RemoteAddr
    }
    peer, err := netip.ParseAddr(host)
    if err != nil || !inTrusted(peer, trusted) {
        return host // untrusted peer — ignore XFF
    }
    parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
    for i := len(parts) - 1; i >= 0; i-- {
        ip, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
        if err == nil && !inTrusted(ip, trusted) {
            return ip.String()
        }
    }
    return host
}

func inTrusted(ip netip.Addr, prefixes []netip.Prefix) bool {
    for _, p := range prefixes {
        if p.Contains(ip) {
            return true
        }
    }
    return false
}

Option A is simpler; Option B is required if the portal ever ends up behind an ingress.

Comment thread admin/config/config.go
type ServerConfig struct {
Addr string `yaml:"addr"`
Addr string `yaml:"addr"`
SecureCookies bool `yaml:"secure_cookies"`
Copy link
Copy Markdown
Contributor

@sergiopalacio sergiopalacio Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default is insecure. SecureCookies zero-value is false, so any deployment that forgets to set the flag ships auth cookies over plaintext. Prefer defaulting to true and making explicit opt-out the dev path or auto-detect based on Server.Addr being localhost.

Comment thread admin/store/user.go
type Session struct {
ID int64
UserID int64
Token string
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field name is misleading — this holds the SHA-256 hash, not the raw token. GetSessionByToken (line 127) scans the token column — which is written as hashToken(token) on line 114 — into sess.Token. Meanwhile LoginResult.SessionToken in auth/auth.go:63 holds the raw token. A future maintainer mixing up sess.Token and loginResult.SessionToken could trivially create an auth bypass. Suggest renaming to TokenHash. The test at user_test.go even works around the confusion with a comment: "Token is stored as a SHA-256 hash, so it won't match the raw value."

Comment thread admin/api/auth.go
MaxAge: int(h.sessionMaxAge.Seconds()),
HttpOnly: false,
Secure: h.secureCookies,
SameSite: http.SameSiteStrictMode,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SameSite mode is inconsistent with the session cookie. Session cookie uses SameSiteLaxMode (line 176), CSRF cookie uses SameSiteStrictMode here. With Lax on the session, a top-level navigation carries the session but not the CSRF cookie. For unsafe methods both are required (fine), but a GET /api/me on such a navigation succeeds with no CSRF attestation at all. Pick one — both Strict is the safer default; both Lax is acceptable if you rely on the CSRF header check for all state changes. Same mismatch exists in clearCookies (:205, :214).

Comment thread admin/api/auth_test.go
func TestLogin_Success_Returns200WithCookies(t *testing.T) {
t.Parallel()
mux, svc := newTestAuthMux(t)
svc.CreateUser(context.Background(), "admin", testPassword)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

svc.CreateUser error is discarded in 8 places in this file (:35, :86, :115, :144, :172, :193, :214, :251). If the test DB fails to create the user, the test proceeds with confusing downstream failures instead of a clear t.Fatalf. admin/auth/auth_test.go already has a createTestUser helper that does this properly — use it here, or inline if err := svc.CreateUser(...); err != nil { t.Fatalf(...) }.

Comment thread admin/api/auth.go
}

ip := clientIP(r)
result, err := h.auth.Login(r.Context(), ip, req.Username, req.Password)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No account-level lockout. Rate limiting is only per-IP (see RateLimiter in auth/ratelimit.go). An attacker with N distinct IPs still gets N × 5 guesses/minute against a single username. For an admin portal with presumably few users, a per-username failure counter (with a longer lockout window) is a reasonable additional layer. Not blocking, but worth tracking.

Comment thread admin/auth/auth.go
// to prevent timing-based username enumeration.
//
//nolint:errcheck // bcrypt.GenerateFromPassword with DefaultCost never fails
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-password-for-timing"), bcrypt.DefaultCost)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bcrypt.GenerateFromPassword(DefaultCost) runs at package init — every binary invocation, including the short-lived create-user and reset-password CLI commands, pays ~50–100ms of bcrypt startup even when it never hits the user-not-found branch. Defer it:

var dummyHash = sync.OnceValue(func() []byte {
    h, _ := bcrypt.GenerateFromPassword([]byte("dummy-password-for-timing"), bcrypt.DefaultCost)
    return h
})

And use dummyHash() at the call site on line 135.


func readPassword(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd())) // #nosec G115 -- stdin fd is always 0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Password bytes are never zeroed. term.ReadPassword returns a []byte; converting it with string(password) copies the bytes into an immutable string that lives until GC, and the original slice is also never cleared. For a short-lived CLI this is a minor hygiene issue, but the project already lists memguard as a dep in the core module for exactly this purpose. At minimum, for i := range password { password[i] = 0 } after the bcrypt hash is computed would reduce the exposure window.

Comment thread admin/ui/src/utils/api.js
const auth = useAuthStore();
if (auth.ready) {
auth.user = null;
window.location.href = '/login';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard navigation instead of SPA routing. window.location.href = '/login' drops SPA state (scroll position, any in-flight router transitions, the redirect query param that LoginView.vue relies on to bounce back after login). Use the router instead:

const { default: router } = await import('../router/index.js');
router.push({ name: 'login', query: { redirect: window.location.pathname } });

Preserves the post-login redirect UX that the current LoginView already supports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants