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 ¶
- Constants
- Variables
- func BasicAuth(auth ldapauth.Authenticator, opts ...Option) func(http.Handler) http.Handler
- func Credentials(r *http.Request, maxBody int64) (username, password string, err error)
- func IdentityFrom(ctx context.Context) (ldapauth.Identity, bool)
- func LoginHandler(auth ldapauth.Authenticator, ...) http.HandlerFunc
- func Middleware(auth ldapauth.Authenticator, policy *Policy) func(http.Handler) http.Handler
- func MustIdentity(ctx context.Context) ldapauth.Identity
- func PrincipalFrom(ctx context.Context) (*ldapauth.Principal, bool)
- func Status(err error) int
- func WithIdentity(ctx context.Context, identity ldapauth.Identity) context.Context
- type Option
- func RequireAllGroups(groups ...string) Option
- func RequireAnyGroup(groups ...string) Option
- func WithErrorHandler(h func(w http.ResponseWriter, r *http.Request, err error)) Option
- func WithMaxBodyBytes(n int64) Option
- func WithMinimumDuration(d time.Duration) Option
- func WithRealm(realm string) Option
- func WithSkip(skip func(*http.Request) bool) Option
- type Policy
- func (p *Policy) Authenticate(ctx context.Context, auth ldapauth.Authenticator, username, password string) (ldapauth.Identity, error)
- func (p *Policy) Challenge() string
- func (p *Policy) MaxBodyBytes() int64
- func (p *Policy) Realm() string
- func (p *Policy) Reject(w http.ResponseWriter, r *http.Request, err error)
- func (p *Policy) ShouldSkip(r *http.Request) bool
Examples ¶
Constants ¶
const DefaultMaxBodyBytes = 64 << 10
DefaultMaxBodyBytes bounds the login request body. Credentials are small; a body larger than this is not a login.
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.
const DefaultRealm = "Restricted"
DefaultRealm is the realm sent in the WWW-Authenticate header when none is configured.
Variables ¶
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") )
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 ¶
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 ¶
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 ¶
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 ¶
Middleware is BasicAuth with the policy already resolved.
func MustIdentity ¶
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 ¶
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 ¶
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 ¶
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 ¶
An Option configures a Policy.
func RequireAllGroups ¶
RequireAllGroups rejects a principal that is not in every named group. Calling it more than once appends.
func RequireAnyGroup ¶
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 ¶
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 ¶
WithMaxBodyBytes bounds how much of a login request body is read. The default is DefaultMaxBodyBytes.
func WithMinimumDuration ¶
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.
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 ¶
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) MaxBodyBytes ¶
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) ShouldSkip ¶
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.