ldap

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

README

github.com/ubgo/auth/plugin/ldap

Sign-in with a username and password checked against an LDAP directory. The guide is docs/plugins/ldap.md.

Install

go get github.com/ubgo/auth/plugin/ldap

A separate module: it depends on github.com/go-ldap/ldap/v3, so an app without a directory never compiles it.

What it does

  • POST /auth/ldap/sign-in {username, password} checks the pair with a Directory and signs the user in; the plugin stores no password.
  • The default Directory, NewClient(Config), uses go-ldap: service-account bind, search with an escaped filter, bind as the user. TLS is required (ldaps://, or ldap:// with StartTLS); AllowInsecure exists for a local test server only.
  • The first sign-in links the entry to the user holding its verified email, or creates a user. Links live in auth_ldap_account, keyed by Subject, so a renamed entry reaches the same user.
  • An unknown user and a wrong password are the same error after the same work (a decoy bind); an empty password never reaches the directory.
  • Limits: 20 sign-ins per address per minute, 5 per username per 15 minutes, below a typical directory lockout threshold.

Options

WithDirectory(d), MapAttributes(AttributeMap), MapProfile(fn), NoSignUp(), UntrustedEmail(). Every option refuses a nil argument by name.

Errors

ldap.credentials_invalid (401), ldap.directory_unavailable (503), ldap.account_exists (409), ldap.sign_up_disabled (403), ldap.profile_refused (403), ldap.sign_in_conflict (409). ErrNoDirectory is returned by auth.New.

Tests

Unit tests in this module against a fake connection and Directory; the flows in examples/ldap-sign-in; task test:ldap (opt-in) against a throwaway lldap container.

Documentation

Overview

Package ldap signs users in with a username and password checked against an LDAP directory (OpenLDAP, Active Directory, FreeIPA, lldap, 389-DS).

The directory is the source of truth for the password: the plugin stores none. It reaches the directory only through the Directory interface; the default, Client, uses github.com/go-ldap/ldap with TLS required. On a first sign-in the directory account is linked to a user: the user who already holds the entry's verified email address, or a new user created from the entry's attributes. Links live in auth_ldap_account, keyed by a stable id from the entry, so a renamed DN or a changed address still reaches the same user.

An unknown username and a wrong password answer the same error, after the same work: the Directory contract requires it and Client keeps it with a decoy bind.

Index

Constants

View Source
const (
	SchemeLDAP  = "ldap"
	SchemeLDAPS = "ldaps"
)

URL schemes the Client dials.

View Source
const (
	// MaxUsernameLength bounds the username before it reaches the
	// directory; real login names are far shorter, and a long one is only
	// work for the directory's filter parser.
	MaxUsernameLength = 256
	// MaxPasswordLength bounds the password sent in the bind. Directories
	// cap theirs lower; 1024 bytes stops a request from shipping megabytes
	// to the directory per attempt.
	MaxPasswordLength = 1024
)

Limits.

View Source
const (
	// DefaultEmailAttribute holds the address in inetOrgPerson and AD.
	DefaultEmailAttribute = "mail"
	// DefaultNameAttribute is the common name, present on every person.
	DefaultNameAttribute = "cn"
)

Default entry attributes the profile is read from.

View Source
const (
	TableNameAccount  = "auth_ldap_account"
	TableAliasAccount = "lda"
	ColID             = "id"
	ColUserID         = "user_id"
	// ColSubject is the entry's stable id (AttributeMap.Subject, the DN by
	// default), unique: one directory account belongs to one user.
	ColSubject   = "subject"
	ColDN        = "dn"
	ColCreatedAt = "created_at"
	ColLastUsed  = "last_used_at"
)

The plugin's table: one row per directory account, linked to one user.

View Source
const DefaultTimeout = 10 * time.Second

DefaultTimeout bounds each directory call (dial, bind, search). A sign-in that waits longer has failed for the person anyway, and an unbounded call would let a hung server pile up requests.

View Source
const DefaultUserFilter = "(&(objectClass=person)(uid=" + UsernamePlaceholder + "))"

DefaultUserFilter finds a person by uid, the attribute OpenLDAP, 389-DS, FreeIPA and lldap use for the login name. Active Directory uses "(&(objectClass=user)(sAMAccountName={username}))".

View Source
const EndpointSignIn auth.EndpointID = "ldap.sign-in"

EndpointSignIn is the sign-in operation's id, a stable wire name.

View Source
const PluginName = "ldap"

PluginName is the plugin's permanent identity: its route prefix and its owner name in the migration ledger.

View Source
const UsernamePlaceholder = "{username}"

UsernamePlaceholder is replaced in Config.UserFilter by the username, escaped for a filter (RFC 4515), so a username such as "*" or "a)(uid=*" matches only itself.

Variables

View Source
var (
	ErrConfigURL = errors.New("ldap: Config.URL must be ldap://host[:port] or ldaps://host[:port]")
	// ErrInsecure refuses a plain ldap:// URL without StartTLS: the password
	// would cross the network in clear text. AllowInsecure accepts it, for a
	// test server on the same machine.
	ErrInsecure = errors.New("ldap: ldap:// without StartTLS sends passwords in clear text; use ldaps://, set StartTLS, or set AllowInsecure for a local test server")
	// ErrConfigTLS is StartTLS on an ldaps:// URL, which is already TLS.
	ErrConfigTLS    = errors.New("ldap: StartTLS is for ldap:// URLs; ldaps:// is TLS from the start")
	ErrConfigBaseDN = errors.New("ldap: Config.BaseDN is required")
	ErrConfigFilter = errors.New("ldap: Config.UserFilter must contain " + UsernamePlaceholder)
	// ErrConfigBindPassword is a BindDN without a password: an
	// unauthenticated bind, which most servers accept and which then
	// searches as nobody.
	ErrConfigBindPassword = errors.New("ldap: Config.BindDN needs BindPassword; leave both empty to search anonymously")
)

Errors NewClient returns for a configuration that could never sign anyone in safely.

View Source
var (
	TableAccount     = sqlb.Table{Name: TableNameAccount, Alias: TableAliasAccount}
	AccountID        = sqlb.Col[string](TableAccount, ColID)
	AccountUserID    = sqlb.Col[string](TableAccount, ColUserID)
	AccountSubject   = sqlb.Col[string](TableAccount, ColSubject)
	AccountDN        = sqlb.Col[string](TableAccount, ColDN)
	AccountCreatedAt = sqlb.Col[time.Time](TableAccount, ColCreatedAt)
	AccountLastUsed  = sqlb.Col[time.Time](TableAccount, ColLastUsed)
)

Typed columns, for an app that reads the table (reads are open; only the plugin writes it).

View Source
var (
	// ErrCredentialsInvalid is an unknown username, a wrong password, a
	// locked account or an entry the filter leaves out, all alike.
	ErrCredentialsInvalid = auth.NewError("ldap.credentials_invalid", http.StatusUnauthorized, "The username or password is incorrect.")
	// ErrDirectoryUnavailable is a directory that could not decide: down,
	// unreachable, or refusing the service account. The cause is logged.
	ErrDirectoryUnavailable = auth.NewError("ldap.directory_unavailable", http.StatusServiceUnavailable, "Sign-in is unavailable right now. Please try again later.")
	// ErrAccountExists is a directory account whose email belongs to an
	// existing user when the directory's emails are not trusted
	// (UntrustedEmail): linking would hand that user to whoever controls the
	// directory entry.
	ErrAccountExists = auth.NewError("ldap.account_exists", http.StatusConflict, "An account with this email already exists. Sign in to it first.")
	// ErrSignUpDisabled is a first sign-in under NoSignUp with no user to
	// link to.
	ErrSignUpDisabled = auth.NewError("ldap.sign_up_disabled", http.StatusForbidden, "This directory account has no account here.")
	// ErrProfileRefused is an entry MapProfile refused, or one without the
	// stable id the link needs. The cause is logged.
	ErrProfileRefused = auth.NewError("ldap.profile_refused", http.StatusForbidden, "This account may not sign in here.")
	// ErrSignInConflict is two first sign-ins of one directory account at
	// once; the second is asked to try again, when the first has linked it.
	ErrSignInConflict = auth.NewError("ldap.sign_in_conflict", http.StatusConflict, "Another sign-in for this account is in progress. Please try again.")
)

Errors the sign-in answers.

View Source
var DefaultAttributes = []string{"*", "entryUUID"}

DefaultAttributes are read from the user's entry: every user attribute, and entryUUID, the stable id OpenLDAP and lldap keep as an operational attribute, which "*" leaves out.

View Source
var ErrAmbiguousUser = errors.New("ldap: the username matched more than one entry; make the user filter specific")

ErrAmbiguousUser is what a Directory returns when the username matched more than one entry: a filter that is not specific enough, which is a configuration mistake. The plugin logs it and answers ErrCredentialsInvalid, so the person sees the same answer as for a wrong password and the operator sees the cause.

View Source
var ErrInvalidCredentials = errors.New("ldap: unknown user or wrong password")

ErrInvalidCredentials is what a Directory returns when the user is unknown OR the password is wrong. One error for both, by contract: the plugin answers it as ErrCredentialsInvalid, and a Directory that told the two apart (in its error or in how long it took) would tell a stranger which usernames exist. A locked or disabled account is this error too.

View Source
var ErrNoDirectory = errors.New("ldap: no directory; pass ldap.WithDirectory(ldap.NewClient(...))")

ErrNoDirectory is auth.New's error for a plugin built without WithDirectory: it could never check a password.

Functions

This section is empty.

Types

type AttributeMap

type AttributeMap struct {
	// Subject is the stable id: empty means the DN, which changes when an
	// entry is renamed or moved; set "entryUUID" (OpenLDAP, lldap),
	// "objectGUID" (Active Directory) or "ipaUniqueID" (FreeIPA) to survive
	// that. A binary value is hex-encoded.
	Subject string
	// Email is DefaultEmailAttribute by default.
	Email string
	// Name is DefaultNameAttribute by default; "displayName" is common too.
	Name string
	// Image has no default; "jpegPhoto" is binary and not a URL, so map it
	// with MapProfile if at all.
	Image string
}

AttributeMap names the attributes a Profile is read from. A field left empty keeps its default.

type Client

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

Client is the default Directory, over github.com/go-ldap/ldap. Each Authenticate opens a connection, upgrades it to TLS, binds the service account, searches for the user, binds as the user, and closes: no pool and no state between sign-ins, so a password change or a disabled account takes effect at the next sign-in. An app that signs in many users a second can put its own pooled Directory in its place.

func NewClient

func NewClient(cfg Config) (*Client, error)

NewClient checks cfg and returns the Client. TLS is required unless AllowInsecure: an ldaps:// URL, or ldap:// with StartTLS.

func (*Client) Authenticate

func (c *Client) Authenticate(ctx context.Context, username, password string) (Entry, error)

Authenticate checks the password with a bind as the user's entry and returns the entry. See Directory for the contract it keeps: an unknown user binds a decoy DN under BaseDN with the password, so it costs the same search and bind as a wrong password and answers the same ErrInvalidCredentials.

type Config

type Config struct {
	// URL is the server: ldaps://ldap.example.com (port 636) or
	// ldap://ldap.example.com (port 389) with StartTLS.
	URL string
	// StartTLS upgrades an ldap:// connection to TLS before anything is
	// sent. Required for ldap:// unless AllowInsecure.
	StartTLS bool
	// TLS is the TLS configuration for ldaps:// and StartTLS. Nil means the
	// system roots, the URL's host as the server name, and TLS 1.2 or later.
	// Set RootCAs here for a directory with a private CA; never
	// InsecureSkipVerify outside a test.
	TLS *tls.Config
	// AllowInsecure accepts ldap:// without StartTLS. Only for a test server
	// on the same machine: the password crosses the wire in clear text.
	AllowInsecure bool
	// BindDN and BindPassword are the service account that searches for the
	// user. Empty searches anonymously, which many directories refuse.
	BindDN       string
	BindPassword string
	// BaseDN is where the user search starts, such as
	// "ou=people,dc=example,dc=com". Required.
	BaseDN string
	// UserFilter finds the user; UsernamePlaceholder is replaced by the
	// escaped username. Empty means DefaultUserFilter. Put access rules here
	// too: "(&(uid={username})(memberOf=cn=staff,ou=groups,dc=example,dc=com))"
	// lets only staff sign in, and everyone else gets the same answer as a
	// wrong password.
	UserFilter string
	// Attributes are read from the user's entry. Nil means
	// DefaultAttributes.
	Attributes []string
	// Timeout bounds each call; zero means DefaultTimeout.
	Timeout time.Duration
}

Config configures the Client.

type Directory

type Directory interface {
	Authenticate(ctx context.Context, username, password string) (Entry, error)
}

Directory checks a username and password against a directory and returns the user's entry. It is the plugin's only view of LDAP, so an app can put anything behind it: the go-ldap Client this module ships, a pooled client of its own, Active Directory through another library, or a fake in tests.

The contract an implementation must keep, because sign-in security depends on it:

  • an unknown user and a wrong password both return ErrInvalidCredentials (or an error wrapping it), and take the same work: the Client binds a decoy DN for an unknown user so both cost one search and one bind;
  • an empty password is ErrInvalidCredentials, never an attempt: LDAP treats a simple bind with an empty password as an anonymous bind, which succeeds (RFC 4513 section 5.1.2);
  • any other error is a failure to decide (the server is down, the service account's password expired) and is returned as it is;
  • the returned Entry is the authenticated user's, read after the password was proven.

type DirectoryFunc

type DirectoryFunc func(ctx context.Context, username, password string) (Entry, error)

DirectoryFunc adapts a function to Directory.

func (DirectoryFunc) Authenticate

func (f DirectoryFunc) Authenticate(ctx context.Context, username, password string) (Entry, error)

Authenticate calls f.

type Entry

type Entry struct {
	DN         string
	Attributes map[string][]string
}

Entry is a directory entry: its distinguished name and its attributes, each with every value the directory holds. Attribute names are as the directory spells them; Get matches them without regard to case, as LDAP does.

func (Entry) Get

func (e Entry) Get(name string) string

Get returns the first value of the attribute named, matched without regard to case, or "" when the entry has none. Most profile attributes (mail, cn) are single-valued in practice; read Attributes for the others.

type Option

type Option func(*Plugin)

Option configures the plugin; New applies them in order.

func MapAttributes

func MapAttributes(m AttributeMap) Option

MapAttributes replaces the attribute names a Profile is read from; empty fields keep their defaults.

func MapProfile

func MapProfile(fn func(Entry) (Profile, error)) Option

MapProfile replaces the attribute mapping with a function: compute a subject, join a given name and a surname, refuse an entry by returning an error (ErrProfileRefused is answered, the error is logged; an *auth.Error is answered as it is). It runs after the password was proven.

func NoSignUp

func NoSignUp() Option

NoSignUp creates no users: a directory account signs in only when it is linked already, or its email matches a user's verified one.

func UntrustedEmail

func UntrustedEmail() Option

UntrustedEmail treats the directory's email addresses as unverified: they never link to an existing user (ErrAccountExists instead), and a new user's address is stored unverified. By default the directory is the organization's own record and its addresses are trusted, which is what lets a person who signed up with a password and later signs in through the directory keep one account.

func WithDirectory

func WithDirectory(d Directory) Option

WithDirectory is the directory passwords are checked against: a Client from NewClient, or any Directory. Required.

type Plugin

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

Plugin is the LDAP sign-in method. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin. WithDirectory is required.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init keeps the core for the table name and the clock.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists the sign-in.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds sign-in per IP and, lower, per username, so a guesser cannot trip the directory's own lockout for a real person.

func (*Plugin) SignIn

func (p *Plugin) SignIn(ctx context.Context, username, password string) (SignInResult, error)

SignIn checks a username and password against the directory and signs the user in, the typed form of POST /ldap/sign-in.

func (*Plugin) SignInCredentials

func (p *Plugin) SignInCredentials(ctx context.Context, userID, exceptID string) (int, error)

SignInCredentials counts userID's linked directory accounts, leaving out exceptID, for the core's last-sign-in-method check.

func (*Plugin) SignsIn

func (p *Plugin) SignsIn() bool

SignsIn reports that a directory account is a way in.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares auth_ldap_account, so auth.New refuses another owner.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates auth_ldap_account: subject is unique, user_id indexed. MySQL declares the index inside CREATE TABLE, having no CREATE INDEX IF NOT EXISTS.

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables declares that account rows belong to the user in user_id, so DeleteUser removes them.

func (*Plugin) Validate

func (p *Plugin) Validate(*auth.Config) error

Validate refuses a plugin without a directory.

type Profile

type Profile struct {
	// Subject is the entry's stable id, which links it to a user. Required.
	Subject string
	// Email is the user's address; empty when the entry has none.
	Email string
	// Name and Image are a new user's profile.
	Name  string
	Image string
}

Profile is what a sign-in takes from a directory entry.

type SignInRequest

type SignInRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

SignInRequest is the login form.

func (SignInRequest) RateIdentifier

func (r SignInRequest) RateIdentifier() string

RateIdentifier counts attempts per username, folded as most directories compare login names, so case changes do not reset the limit.

type SignInResult

type SignInResult struct {
	User auth.UserView `json:"user"`
	// Created is true when this sign-in created the user.
	Created bool `json:"created"`
	// Linked is true when this sign-in linked the directory account to an
	// existing user by email.
	Linked               bool       `json:"linked"`
	SecondFactorRequired bool       `json:"secondFactorRequired"`
	Token                auth.Token `json:"-"`
}

SignInResult is the output of sign-in. Token is for Go callers and bearer transports, never encoded into JSON.

Jump to

Keyboard shortcuts

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