103 lines
3.9 KiB
Go
103 lines
3.9 KiB
Go
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
|
|
})
|
|
}
|