Files
nadir-agent/internal/auth/login.go
T

103 lines
3.9 KiB
Go
Raw Normal View History

2026-06-22 16:06:57 +02:00
package auth
import (
"context"
"net/http"
2026-06-22 20:03:27 +02:00
"regexp"
2026-06-22 16:06:57 +02:00
"time"
"nadir/internal/auditlog"
"github.com/danielgtaylor/huma/v2"
)
2026-06-22 20:03:27 +02:00
// loginNameRe is the useradd default NAME_REGEX. Validating at this trust
// boundary keeps a flag-like name (e.g. "-c", "--help") from reaching `su` in
// showing up verbatim in audit logs / throttle keys.
2026-06-22 20:03:27 +02:00
var loginNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}\$?$`)
2026-06-22 16:06:57 +02:00
// authenticator verifies a username/password (PAM in production). It's a field
// of the login handler rather than a package global so tests can inject a stub
// without mutating shared state.
type authenticator func(username, password string) error
type LoginInput struct {
Body struct {
Username string `json:"username" doc:"System username"`
Password string `json:"password" doc:"System password"`
}
}
type LoginOutput struct {
SetCookie http.Cookie `header:"Set-Cookie"`
Body struct {
Status string `json:"status" example:"logged in"`
}
}
// RegisterLogin wires the login operation into the Huma API. It has no
// permission metadata, so the RBAC middleware lets it through unauthenticated.
//
// secure sets the Secure attribute on the session cookie. Keep it true in
// production (the cookie is then only sent over HTTPS); set it false for local
// development over plain HTTP, where a Secure cookie would never be sent back.
func RegisterLogin(api huma.API, sessions *SessionStore, auditor *auditlog.Store, secure bool) {
// loginThrottle blunts brute force: 5 failures for a username+source IP
// trigger a one-minute cooldown. Persisted in SQLite so cooldowns survive
// process restarts.
registerLogin(api, sessions, auditor, secure, Authenticate, sessions.NewPersistentFailLimiter(5, time.Minute))
2026-06-22 16:06:57 +02:00
}
func registerLogin(api huma.API, sessions *SessionStore, auditor *auditlog.Store, secure bool, authenticate authenticator, throttle *failLimiter) {
huma.Register(api, huma.Operation{
OperationID: "login",
Method: "POST",
Path: "/api/login",
Summary: "Authenticate and start a session",
Description: "Verifies the username and password against PAM (the " +
"dedicated nadir service) and, on success, sets an HttpOnly " +
"session cookie used to authorize all other endpoints.",
Tags: []string{"Authentication"},
Errors: []int{401, 429},
}, func(ctx context.Context, in *LoginInput) (*LoginOutput, error) {
2026-06-22 20:03:27 +02:00
// Reject malformed usernames at the trust boundary so PAM, su, and the
// audit log never see flag-like or shell-metacharacter input.
if !loginNameRe.MatchString(in.Body.Username) {
return nil, huma.Error401Unauthorized("invalid credentials")
}
2026-06-22 16:06:57 +02:00
// Throttle brute force: too many recent failures for this account/source
// put it in a short cooldown before the password is even checked.
throttleKey := in.Body.Username + "|" + ClientIP(ctx)
if throttle.blocked(throttleKey) {
return nil, huma.Error429TooManyRequests("too many failed login attempts; wait a minute")
}
// Record both outcomes: failed logins are the brute-force signal, and the
// username is captured even on failure (which account is being targeted).
if err := authenticate(in.Body.Username, in.Body.Password); err != nil {
throttle.fail(throttleKey)
auditor.Record(in.Body.Username, "POST", "/api/login", "auth", http.StatusUnauthorized)
return nil, huma.Error401Unauthorized("invalid credentials")
}
throttle.reset(throttleKey)
auditor.Record(in.Body.Username, "POST", "/api/login", "auth", http.StatusOK)
sessionID, err := sessions.Create(in.Body.Username)
if err != nil {
return nil, huma.Error500InternalServerError("could not create session", err)
}
out := &LoginOutput{
SetCookie: http.Cookie{
Name: "nadir_session_id",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400,
},
2026-06-22 16:06:57 +02:00
}
out.Body.Status = "logged in"
return out, nil
})
}