package auth import ( "context" "net/http" "regexp" "time" "nadir/internal/auditlog" "github.com/danielgtaylor/huma/v2" ) // 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. var loginNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}\$?$`) // 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)) } 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) { // 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") } // 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, }, } out.Body.Status = "logged in" return out, nil }) }