httpauth

package
v1.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package httpauth turns an ldapauth.Authenticator into HTTP authentication: middleware for net/http, a login handler, and the policy core that the Gin, Echo, and Fiber modules under contrib/ are built on.

It lives in the root module because it needs nothing outside the standard library, so nothing about it costs the root module its one-dependency promise. The framework modules are separate because they do not: importing this package never puts Gin in your build graph.

mux := http.NewServeMux()
mux.Handle("/private", httpauth.BasicAuth(auth)(privateHandler))

Everything a caller might want to vary — the realm, which groups are required, what a rejection looks like on the wire, how long an answer must take — is an Option.

What it does not do

It issues no session and no token. LoginHandler verifies the credentials and hands you the identity; what that becomes is your application's decision.

That is not an omission. How long a session lives, where it is stored, what else it carries, how it is revoked, and what happens when the directory says the user is gone are all decisions an application has already made or is about to, and a library that made them for you would be a library you had to work around. Every session library in Go takes a user identifier and some claims; that is what you have been handed.

Status codes

400  the request was not a login: no username, an empty password
401  wrong or absent credentials, with a WWW-Authenticate challenge
403  the credentials were right and the groups were not
503  the directory could not answer

The distinction between 401 and 403 is the one that matters to a client: 401 says try other credentials, 403 says do not bother. Status is that mapping, exported, so an adapter for another framework does not have to reconstruct it from the sentinels.

Bodies are empty by default. A rejection that explains itself is a rejection that helps somebody guess, and the error text can name the account. WithErrorHandler replaces that when your API has a shape of its own.

Timing

A search that matches nothing returns sooner than a search that matches and then binds, and that difference is measurable over a network — an account enumeration oracle that no amount of collapsing error messages will close. WithMinimumDuration pads every answer to a floor, which moves the difference below the noise of the network in front of it. It is not a constant-time guarantee, and nothing built on a directory round trip can be.

Index

Examples

Constants

View Source
const DefaultMaxBodyBytes = 64 << 10

DefaultMaxBodyBytes bounds the login request body. Credentials are small; a body larger than this is not a login.

View Source
const DefaultMinimumDuration = 100 * time.Millisecond

DefaultMinimumDuration is how long an authentication is padded to when nothing says otherwise.

It is not zero, and that is a deliberate change from the first release. Collapsing the *errors* — a wrong password and an unknown user return the same one — closes the obvious enumeration oracle and leaves the timing one wide open: under search-then-bind an unknown user costs one round trip and a wrong password costs two, and the difference is measurable across a network. With a cache in front, a hit is microseconds and a miss is milliseconds, which leaks whether a credential was recently used successfully.

Every other decision of this kind in this package is made for the caller and left available to override. This one was the exception, and making it the default is the fix. Pass WithMinimumDuration(0) to turn it off, and set it above your directory's typical login latency for it to mean anything.

View Source
const DefaultRealm = "Restricted"

DefaultRealm is the realm sent in the WWW-Authenticate header when none is configured.

Variables

View Source
var (
	// ErrNoCredentials reports a request that carried no credentials at
	// all: no Authorization header, or a scheme that is not Basic.
	ErrNoCredentials = errors.New("httpauth: request carried no credentials")

	// ErrMalformedRequest reports a login request whose body could not be
	// read as credentials.
	ErrMalformedRequest = errors.New("httpauth: request is not a login")
)
View Source
var ErrForbidden = errors.New("httpauth: identity is not in a required group")

ErrForbidden reports that the credentials were valid but the principal is not in the groups the policy requires. It is separate from ldapauth.ErrInvalidCredentials because the two deserve different answers: a 401 invites the client to try again with other credentials, and a 403 tells it not to bother.

Functions

func BasicAuth

func BasicAuth(auth ldapauth.Authenticator, opts ...Option) func(http.Handler) http.Handler

BasicAuth returns middleware that authenticates every request with HTTP Basic credentials and puts the resulting identity in the request context.

mux.Handle("/private", httpauth.BasicAuth(auth,
    httpauth.WithRealm("corp"),
    httpauth.RequireAnyGroup("developers", "ops"),
)(handler))

The signature is func(http.Handler) http.Handler, which is what chi, gorilla/mux, and every other net/http router expect, so there is nothing framework-specific about it.

An option that does not validate panics, because a middleware chain is built at startup and there is nowhere to return an error to. Use NewPolicy and Middleware if you would rather handle it.

Example

The middleware has the func(http.Handler) http.Handler shape that chi, gorilla/mux, and a bare ServeMux all expect.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/httpauth"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleAuth builds an authenticator against an in-memory directory, so
// that the examples run with no LDAP server anywhere.
func exampleAuth() *ldapauth.Client {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").With("cn", "developers"),
	)

	auth, err := ldapauth.New(
		ldapauth.WithURL("ldap://in-memory"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
		ldapauth.WithMemberOfGroups(),
	)
	if err != nil {
		panic(err)
	}

	return auth
}

func main() {
	auth := exampleAuth()
	defer auth.Close()

	private := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		identity := httpauth.MustIdentity(r.Context())

		fmt.Fprintf(w, "hello %s", identity.GetUsername())
	})

	handler := httpauth.BasicAuth(auth,
		httpauth.WithRealm("corp"),
		httpauth.RequireAnyGroup("developers"),
		httpauth.WithMinimumDuration(10*time.Millisecond),
	)(private)

	request := httptest.NewRequest(http.MethodGet, "/private", nil)
	request.SetBasicAuth("alice", "s3cret")

	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, request)

	fmt.Println(recorder.Code, recorder.Body)
}
Output:
200 hello alice
Example (Rejection)

A request with no credentials gets a challenge, and one with the wrong credentials gets a bare 401: a rejection that explains itself is a rejection that helps somebody guess.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/httpauth"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleAuth builds an authenticator against an in-memory directory, so
// that the examples run with no LDAP server anywhere.
func exampleAuth() *ldapauth.Client {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").With("cn", "developers"),
	)

	auth, err := ldapauth.New(
		ldapauth.WithURL("ldap://in-memory"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
		ldapauth.WithMemberOfGroups(),
	)
	if err != nil {
		panic(err)
	}

	return auth
}

func main() {
	auth := exampleAuth()
	defer auth.Close()

	handler := httpauth.BasicAuth(auth, httpauth.WithRealm("corp"))(http.NotFoundHandler())

	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/private", nil))

	fmt.Println(recorder.Code)
	fmt.Println(recorder.Header().Get("WWW-Authenticate"))
	fmt.Printf("body: %q\n", recorder.Body.String())
}
Output:
401
Basic realm="corp", charset="UTF-8"
body: ""

func Credentials

func Credentials(r *http.Request, maxBody int64) (username, password string, err error)

Credentials reads a username and password out of a request: from a JSON body, a form body, or an Authorization header, whichever is there. The body is limited to maxBody bytes.

It is exported because an adapter for another framework needs the same parsing, and because getting it wrong — reading an unbounded body, say — is a denial of service rather than a bug.

func IdentityFrom

func IdentityFrom(ctx context.Context) (ldapauth.Identity, bool)

IdentityFrom returns the identity the middleware authenticated, if the request went through it.

func LoginHandler

func LoginHandler(
	auth ldapauth.Authenticator,
	onSuccess func(w http.ResponseWriter, r *http.Request, identity ldapauth.Identity),
	opts ...Option,
) http.HandlerFunc

LoginHandler returns a handler that reads credentials from the request body and hands the authenticated principal to onSuccess.

It accepts a JSON object, an HTML form, or Basic credentials in the Authorization header, whichever the request carries:

{"username": "alice", "password": "..."}
username=alice&password=...

What happens next is onSuccess's business. This package does not issue a session cookie or a token, because the shape of a session is an application decision — how long it lives, where it is stored, what else it carries — and a library that picked for you would be a library you had to work around.

http.Handle("POST /login", httpauth.LoginHandler(auth,
    func(w http.ResponseWriter, r *http.Request, id ldapauth.Identity) {
        session.Start(w, id.GetDN(), id.GetGroups())
    },
))
Example

LoginHandler reads credentials from a JSON body, a form body, or a Basic header, and hands you the identity. What happens next is your application's decision: this package issues no session and no token.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/httpauth"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleAuth builds an authenticator against an in-memory directory, so
// that the examples run with no LDAP server anywhere.
func exampleAuth() *ldapauth.Client {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").With("cn", "developers"),
	)

	auth, err := ldapauth.New(
		ldapauth.WithURL("ldap://in-memory"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
		ldapauth.WithMemberOfGroups(),
	)
	if err != nil {
		panic(err)
	}

	return auth
}

func main() {
	auth := exampleAuth()
	defer auth.Close()

	handler := httpauth.LoginHandler(auth,
		func(w http.ResponseWriter, r *http.Request, identity ldapauth.Identity) {
			// Where a real application would start a session.
			w.Header().Set("Content-Type", "application/json")

			_ = json.NewEncoder(w).Encode(map[string]any{
				"dn":     identity.GetDN(),
				"groups": identity.GetGroups(),
			})
		},
	)

	body := `{"username":"alice","password":"s3cret"}`
	request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
	request.Header.Set("Content-Type", "application/json")

	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, request)

	fmt.Print(recorder.Body.String())
}
Output:
{"dn":"uid=alice,ou=people,dc=example,dc=com","groups":["developers"]}

func Middleware

func Middleware(auth ldapauth.Authenticator, policy *Policy) func(http.Handler) http.Handler

Middleware is BasicAuth with the policy already resolved.

func MustIdentity

func MustIdentity(ctx context.Context) ldapauth.Identity

MustIdentity returns the authenticated identity and panics if there is none. Use it in a handler that is only ever mounted behind BasicAuth, where a missing identity is a routing mistake rather than a runtime condition.

func PrincipalFrom

func PrincipalFrom(ctx context.Context) (*ldapauth.Principal, bool)

PrincipalFrom returns the identity as the concrete ldapauth.Principal, for the common case where the mapper was not replaced and reading fields is nicer than calling accessors. It reports false both when there is no identity and when the identity is some other implementation.

func Status

func Status(err error) int

Status maps an authentication error to the status code that answers it.

It is exported because an adapter for another framework needs the same mapping, and because an application writing its own error handler should not have to reconstruct it from the sentinels.

401  the credentials were wrong, or absent
403  the credentials were right and the groups were not
400  the request was not a login: no username, no password
429  this account has been refused too often too recently
503  the directory could not answer

func WithIdentity

func WithIdentity(ctx context.Context, identity ldapauth.Identity) context.Context

WithIdentity returns a context carrying the identity, for adapters and for tests that need to exercise a handler without a directory behind it.

Example

A handler behind the middleware needs no directory to test: put an identity in the context directly.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/httpauth"
)

func main() {
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		identity := httpauth.MustIdentity(r.Context())

		fmt.Println(identity.GetUsername(), identity.InGroup("developers"))
	})

	request := httptest.NewRequest(http.MethodGet, "/private", nil)

	request = request.WithContext(httpauth.WithIdentity(request.Context(), &ldapauth.Principal{
		Username: "alice",
		Groups:   []string{"developers"},
	}))

	handler.ServeHTTP(httptest.NewRecorder(), request)
}
Output:
alice true

Types

type Option

type Option func(*Policy) error

An Option configures a Policy.

func RequireAllGroups

func RequireAllGroups(groups ...string) Option

RequireAllGroups rejects a principal that is not in every named group. Calling it more than once appends.

func RequireAnyGroup

func RequireAnyGroup(groups ...string) Option

RequireAnyGroup rejects a principal that is in none of the named groups. Calling it more than once appends. With no groups named, nothing is required.

Group membership has to be configured on the authenticator as well — ldapauth.WithMemberOfGroups or ldapauth.WithGroupSearch — or the identity arrives with no groups and every requirement fails.

Example

Valid credentials that are not in a required group are a 403 rather than a 401. The distinction matters to a client: 401 says try other credentials, 403 says do not bother.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/httpauth"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleAuth builds an authenticator against an in-memory directory, so
// that the examples run with no LDAP server anywhere.
func exampleAuth() *ldapauth.Client {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").With("cn", "developers"),
	)

	auth, err := ldapauth.New(
		ldapauth.WithURL("ldap://in-memory"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
		ldapauth.WithMemberOfGroups(),
	)
	if err != nil {
		panic(err)
	}

	return auth
}

func main() {
	auth := exampleAuth()
	defer auth.Close()

	handler := httpauth.BasicAuth(auth, httpauth.RequireAnyGroup("ops"))(http.NotFoundHandler())

	request := httptest.NewRequest(http.MethodGet, "/private", nil)
	request.SetBasicAuth("alice", "s3cret")

	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, request)

	fmt.Println(recorder.Code)
}
Output:
403

func WithErrorHandler

func WithErrorHandler(h func(w http.ResponseWriter, r *http.Request, err error)) Option

WithErrorHandler replaces what a rejection looks like on the wire. The default sends 401 with a WWW-Authenticate challenge for bad credentials, 403 for a failed group requirement, and 503 for anything else, each with an empty body.

A handler must write a response. It must not write the principal, and it should not write the error: the error text can name the account, and under some directories the reason it was refused.

Example

Replacing the rejection with your own shape. Write a response, and do not write the error: its text can name the account.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/httpauth"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleAuth builds an authenticator against an in-memory directory, so
// that the examples run with no LDAP server anywhere.
func exampleAuth() *ldapauth.Client {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").With("cn", "developers"),
	)

	auth, err := ldapauth.New(
		ldapauth.WithURL("ldap://in-memory"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
		ldapauth.WithMemberOfGroups(),
	)
	if err != nil {
		panic(err)
	}

	return auth
}

func main() {
	auth := exampleAuth()
	defer auth.Close()

	handler := httpauth.BasicAuth(auth,
		httpauth.WithErrorHandler(func(w http.ResponseWriter, _ *http.Request, err error) {
			status := httpauth.Status(err)

			w.Header().Set("Content-Type", "application/problem+json")
			w.WriteHeader(status)

			_ = json.NewEncoder(w).Encode(map[string]any{
				"title":  http.StatusText(status),
				"status": status,
			})
		}),
	)(http.NotFoundHandler())

	request := httptest.NewRequest(http.MethodGet, "/private", nil)
	request.SetBasicAuth("alice", "wrong")

	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, request)

	fmt.Print(recorder.Body.String())
}
Output:
{"status":401,"title":"Unauthorized"}

func WithMaxBodyBytes

func WithMaxBodyBytes(n int64) Option

WithMaxBodyBytes bounds how much of a login request body is read. The default is DefaultMaxBodyBytes.

func WithMinimumDuration

func WithMinimumDuration(d time.Duration) Option

WithMinimumDuration pads every authentication to at least d, successes and failures alike.

It blunts the timing difference between a username that exists and one that does not: a search that matches nothing returns sooner than a search that matches and then binds, and that difference is measurable over a network. Padding is not a constant-time guarantee — nothing built on a directory round trip can be — but it moves the signal below the noise of the network in front of it.

Pick a value above your directory's typical login latency; DefaultMinimumDuration, which is what you get without this option, is 100ms. Zero turns padding off, which is worth doing only when something in front of this is already flattening response times.

func WithRealm

func WithRealm(realm string) Option

WithRealm sets the realm offered in the WWW-Authenticate header.

func WithSkip

func WithSkip(skip func(*http.Request) bool) Option

WithSkip leaves requests alone when the predicate returns true, for health endpoints and anything else mounted behind the same middleware.

type Policy

type Policy struct {
	// contains filtered or unexported fields
}

A Policy is the framework-independent half of HTTP authentication: it turns a username and password into a principal, applies the group requirement, and pads the response time. The middleware in this package and the adapters under contrib/ are thin wrappers around one.

Build it once and share it; it is safe for concurrent use.

func NewPolicy

func NewPolicy(opts ...Option) (*Policy, error)

NewPolicy resolves options into a Policy. The middleware constructors call it for you; call it directly when building an adapter for a framework this repository does not ship.

func (*Policy) Authenticate

func (p *Policy) Authenticate(
	ctx context.Context,
	auth ldapauth.Authenticator,
	username, password string,
) (ldapauth.Identity, error)

Authenticate verifies credentials and applies the group requirement, padding the call to the configured minimum duration whatever the outcome.

It is what every middleware in this package and under contrib/ calls, and it is exported so that an adapter for another framework is a matter of moving values in and out of that framework's context.

func (*Policy) Challenge

func (p *Policy) Challenge() string

Challenge is the value of the WWW-Authenticate header this policy sends.

func (*Policy) MaxBodyBytes

func (p *Policy) MaxBodyBytes() int64

MaxBodyBytes returns the configured login body limit.

It is exported because the framework adapters have to apply it themselves, and a limit only the net/http middleware honoured would make "one policy, shared by every adapter" untrue in the one place it is load-bearing.

func (*Policy) Realm

func (p *Policy) Realm() string

Realm returns the configured realm.

func (*Policy) Reject

func (p *Policy) Reject(w http.ResponseWriter, r *http.Request, err error)

Reject writes the response for a failed authentication.

func (*Policy) ShouldSkip

func (p *Policy) ShouldSkip(r *http.Request) bool

ShouldSkip reports whether the policy leaves this request alone.

Exported for the same reason as MaxBodyBytes. It reports false when no skip predicate is configured, and for a request an adapter cannot represent as an *http.Request — the Fiber one, which is why that adapter's documentation says so.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL