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
- Variables
- type AttributeMap
- type Client
- type Config
- type Directory
- type DirectoryFunc
- type Entry
- type Option
- type Plugin
- func (p *Plugin) Init(a *auth.Auth) error
- func (p *Plugin) Name() string
- func (p *Plugin) Operations() []auth.Operation
- func (p *Plugin) OptionErr() error
- func (p *Plugin) RateLimits() []auth.RateRule
- func (p *Plugin) SignIn(ctx context.Context, username, password string) (SignInResult, error)
- func (p *Plugin) SignInCredentials(ctx context.Context, userID, exceptID string) (int, error)
- func (p *Plugin) SignsIn() bool
- func (p *Plugin) Tables() []sqlb.Table
- func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)
- func (p *Plugin) UserTables() []auth.UserTable
- func (p *Plugin) Validate(*auth.Config) error
- type Profile
- type SignInRequest
- type SignInResult
Constants ¶
const ( SchemeLDAP = "ldap" SchemeLDAPS = "ldaps" )
URL schemes the Client dials.
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.
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.
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.
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.
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}))".
const EndpointSignIn auth.EndpointID = "ldap.sign-in"
EndpointSignIn is the sign-in operation's id, a stable wire name.
const PluginName = "ldap"
PluginName is the plugin's permanent identity: its route prefix and its owner name in the migration ledger.
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 ¶
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.
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).
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.") // 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.
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.
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.
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.
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 ¶
NewClient checks cfg and returns the Client. TLS is required unless AllowInsecure: an ldaps:// URL, or ldap:// with StartTLS.
func (*Client) Authenticate ¶
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 ¶
DirectoryFunc adapts a function to Directory.
func (DirectoryFunc) Authenticate ¶
Authenticate calls f.
type Entry ¶
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.
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 ¶
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 ¶
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 (*Plugin) Operations ¶
Operations lists the sign-in.
func (*Plugin) OptionErr ¶
OptionErr is every error the options recorded; auth.New refuses to start while it is not nil (auth.OptionChecker).
func (*Plugin) RateLimits ¶
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 ¶
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 ¶
SignInCredentials counts userID's linked directory accounts, leaving out exceptID, for the core's last-sign-in-method check.
func (*Plugin) TypedMigrations ¶
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 ¶
UserTables declares that account rows belong to the user in user_id, so DeleteUser removes them.
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 ¶
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.