ldapauth

package
v1.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 29, 2026 License: AGPL-3.0 Imports: 9 Imported by: 0

Documentation

Overview

Package ldapauth implements the search-and-bind flow used to authenticate a user against an LDAP/Active Directory server and to read the attributes (email, display name, group membership) needed for local auto-provisioning and role sync. It has no DB dependency beyond the Config it's handed - see web/auth.go for how it's wired into the local-first login flow, and models.Settings for where the connection config is stored.

Index

Constants

This section is empty.

Variables

View Source
var ErrADReadOnly = errors.New("ldap: Active Directory accounts are managed with Active Directory's own tools")

ErrADReadOnly is returned when a directory write is attempted against an Active Directory server. Factum browses AD and can reset passwords through the existing write-back path, but account and group edits stay in AD's own tools.

View Source
var ErrWritebackMissing = errors.New("ldap: directory changes need ldap_writeback.bind_dn and ldap_writeback.bind_password in the config file")

ErrWritebackMissing is returned when a directory write is attempted without the elevated config-file identity.

Functions

func ChangePassword

func ChangePassword(cfg Config, wb WritebackConfig, username, newPassword string) error

ChangePassword looks up username's DN using cfg's ordinary search/bind service account (same lookup as Authenticate), then rebinds on a fresh connection as wb's elevated identity to perform the actual password write. The write itself is server-specific, driven by cfg.ServerType:

  • "generic" (OpenLDAP-compatible): the RFC 3062 password-modify extended operation, which lets the server pick the correct password hash scheme itself.
  • "ad": Active Directory doesn't implement RFC 3062. The password must be written directly to the unicodePwd attribute, UTF-16LE-encoded and quoted, which AD refuses outside an encrypted connection - so TLSMode == "none" is rejected up front with a clear error instead of an opaque failure from the wire.

func CreateGroup added in v1.5.0

func CreateGroup(cfg Config, wb WritebackConfig, in GroupInput) (string, error)

CreateGroup adds a groupOfNames entry. Members must be DNs under the base.

func CreatePerson added in v1.5.0

func CreatePerson(cfg Config, wb WritebackConfig, in PersonInput) (string, error)

CreatePerson adds an inetOrgPerson under parent and sets its password.

func DNBelowBase added in v1.5.0

func DNBelowBase(dn, base string) bool

DNBelowBase reports whether dn is strictly under base.

func DNInScope added in v1.5.0

func DNInScope(dn, base string) bool

DNInScope reports whether dn is the base itself or an entry under it. Comparison follows NormalizeDN (case-insensitive, no RFC 4514 folding).

func DeleteGroup added in v1.5.0

func DeleteGroup(cfg Config, wb WritebackConfig, dn string) error

DeleteGroup deletes a group entry. The caller removes Factum rows that stored the DN.

func DeletePerson added in v1.5.0

func DeletePerson(cfg Config, wb WritebackConfig, dn string) error

DeletePerson removes the user from group member attributes, then deletes the entry. The local Factum shadow row is left for the caller.

func EntryKind added in v1.5.0

func EntryKind(objectClasses []string) string

EntryKind classifies objectClass values for the directory tree. Group wins over person, and person wins over container.

func LookupGroups added in v1.2.0

func LookupGroups(cfg Config, username string) (groups []string, found bool, err error)

LookupGroups searches for username with the service account and returns memberOf. It does not check a password. found is false when no entry matches. Callers that already proved the password another way (MS-CHAPv2 against Active Directory) use this for the device-role check.

func NormalizeDN

func NormalizeDN(dn string) string

NormalizeDN lowercases and trims a DN for case-insensitive comparison against LdapRoleMapping.GroupDN - AD/LDAP DN string comparison is case-insensitive per RFC 4514 for the attribute types in normal use here.

func TestConnection

func TestConnection(cfg Config) error

TestConnection dials, binds as the search identity (anonymous when BindDN is blank) and issues a zero-result-tolerant search under BaseDN - used by the admin "Test Connection" button (via TestServers, one host at a time). Returns nil on success.

func UpdateGroup added in v1.5.0

func UpdateGroup(cfg Config, wb WritebackConfig, in GroupInput) (string, error)

UpdateGroup changes a groupOfNames or groupOfUniqueNames entry. A cn change renames a cn-RDN entry and returns the new DN so the caller can retarget role mappings. posixGroup and Active Directory groups are refused.

func UpdatePerson added in v1.5.0

func UpdatePerson(cfg Config, wb WritebackConfig, in PersonInput) (string, error)

UpdatePerson changes an inetOrgPerson's attributes. A uid change renames the entry when uid is the RDN, and group member attributes that pointed at the old DN are rewritten. Password, when set, is a separate password-modify after the attribute update.

Types

type Config

type Config struct {
	Host string
	Port uint16
	// Host2/Port2 are an optional second server for redundancy. connect()
	// tries Host first and falls back to Host2 if dialing Host fails.
	// Port2 of 0 means "same as Port".
	Host2         string
	Port2         uint16
	TLSMode       string // "none" | "starttls" | "ldaps"
	SkipTLSVerify bool
	// ServerType is "ad" | "generic" (OpenLDAP-compatible). Server-specific
	// behavior that can't be expressed the same way on both (e.g. how a
	// password change is written back) branches on this.
	ServerType string
	// BindDN/BindPassword are the search identity. Both may be blank:
	// that is an anonymous bind (RFC 4513 §5.1.1). A non-empty BindDN
	// still requires a password - go-ldap (and bindService) refuse an
	// empty password in that case, because some servers treat it as an
	// unauthenticated bind that succeeds without checking anything.
	BindDN       string
	BindPassword string
	BaseDN       string
	// UserFilter is a fmt.Sprintf template with one %s for the escaped
	// username, e.g. "(sAMAccountName=%s)" for AD or "(uid=%s)" for
	// OpenLDAP.
	UserFilter string
	// AttrUsername is the attribute holding the value used as the login
	// username (the value UserFilter's %s is matched against) -
	// "sAMAccountName" for AD, "uid" for OpenLDAP by default. Ordinary
	// login already knows the username (it's what was typed into the
	// form); this is only needed by LookupByEmail, which has to derive it
	// from the directory instead.
	AttrUsername    string
	AttrEmail       string
	AttrDisplayName string
	AttrMobile      string
	// AttrGroups is the attribute holding group DNs on the user entry, e.g.
	// "memberOf" for both AD and OpenLDAP (with the memberOf overlay).
	AttrGroups string
}

Config is a projection of models.Settings' LDAP fields, kept separate so this package doesn't need to import gorm or know about the DB row shape.

func ConfigFromServer added in v1.5.0

func ConfigFromServer(s *models.LdapServer) Config

ConfigFromServer builds a Config for one ldap_servers row. Host2 is unused: failover is the ordered list of rows, not a second host on the same config.

func ConfigFromSettings

func ConfigFromSettings(s *models.Settings) Config

ConfigFromSettings builds a Config from the DB-backed Settings row. Blank attribute fields fall back to their standard AD/OpenLDAP name rather than being left unrequested - an admin who just enables LDAP and fills in the connection details (without also typing out every attribute name) still gets Name/Email/Mobile/group-based roles populated correctly, instead of them silently staying empty (Name then falling back further, to the raw login username).

type Entry

type Entry struct {
	DN            string
	RDN           string // display label - the value of the DN's leftmost attribute, e.g. "Admins"
	ObjectClasses []string
	IsGroup       bool
	// Kind is "person", "group", "container", or "other". The directory
	// tree uses it to decide which entries are leaves and which can be edited.
	Kind string
}

Entry is one child node returned by Browse - enough for a tree-browser UI to render a label, know whether to offer it as a selectable group, and request its own children on expand (by DN).

func Browse

func Browse(cfg Config, dn string) ([]Entry, error)

Browse lists the immediate children of dn (or cfg.BaseDN if dn is blank) for a tree-browser UI - used so an admin setting up a group->role mapping can navigate the directory and click the group they mean instead of having to know/type its exact DN. Binds as the configured search identity, same as TestConnection/Authenticate.

type GroupInput added in v1.5.0

type GroupInput struct {
	DN          string
	ParentDN    string
	CN          string
	Description string
	Members     []string
}

GroupInput is a groupOfNames entry. Members are user DNs. A groupOfNames entry must have at least one member.

type PersonInput added in v1.5.0

type PersonInput struct {
	DN          string
	ParentDN    string
	UID         string
	CN          string
	SN          string
	Mail        string
	DisplayName string
	Mobile      string
	Password    string
}

PersonInput is a new or updated inetOrgPerson. ParentDN is the container the entry is created under. DN is the existing entry on update. Password is required on create and optional on update; it is applied with the RFC 3062 password-modify operation so the server chooses the hash.

type Record added in v1.5.0

type Record struct {
	DN            string
	RDN           string
	Kind          string
	ObjectClasses []string
	UID           string
	CN            string
	SN            string
	Mail          string
	DisplayName   string
	Mobile        string
	Description   string
	Members       []string
	// MemberAttr is "member" or "uniqueMember" for a group Factum can edit.
	// Empty for a group type that is only shown (posixGroup, AD group).
	MemberAttr string
}

Record is one directory entry's editable attributes. Members is set for groups (member or uniqueMember). Person fields are set for people.

func ReadEntry added in v1.5.0

func ReadEntry(cfg Config, dn string) (*Record, error)

ReadEntry loads one entry's display attributes. It uses the search identity and may fail over to the second host.

type ServerResult added in v1.0.2

type ServerResult struct {
	Host  string
	Port  uint16
	Error error // nil = success
}

ServerResult is one host's outcome from TestServers.

func TestServers added in v1.0.2

func TestServers(cfg Config) []ServerResult

TestServers dials+binds each configured server independently (no failover between them) so the admin "Test Connection" button can report whether redundancy actually works, not just that one of the two happened to answer. An empty Host yields an empty slice.

type UserInfo

type UserInfo struct {
	DN string
	// Username is the value of Config.AttrUsername on the matched entry -
	// only populated by LookupByEmail, which (unlike Authenticate) doesn't
	// already know it from the caller's login input.
	Username    string
	Email       string
	DisplayName string
	Mobile      string
	Groups      []string // raw group DNs from Config.AttrGroups
}

UserInfo is what a successful directory lookup yields for provisioning and role-sync purposes.

func Authenticate

func Authenticate(cfg Config, username, password string) (bool, *UserInfo, error)

Authenticate performs the search+bind flow: bind as the configured search identity (anonymous when BindDN is blank), search BaseDN with UserFilter for username, then re-bind (on a fresh connection) as the single matched DN with password to verify credentials.

Return contract mirrors web.AuthenticateUser's existing split:

  • (true, info, nil): credentials verified, info populated.
  • (false, nil, nil): bad credentials - service bind ok, but either no user matched the filter or the user-DN bind was rejected (LDAPResultInvalidCredentials). Same shape as a wrong password.
  • (false, nil, err): infra/config problem - dial failure, service-account bind failure, malformed filter, search error, ambiguous match (>1 entries - almost certainly a misconfigured UserFilter), or any bind failure that isn't LDAPResultInvalidCredentials. Callers should treat this as a real error (e.g. 500), not silently show "wrong password".

func LookupByEmail

func LookupByEmail(cfg Config, email string) (*UserInfo, error)

LookupByEmail searches for a directory entry whose AttrEmail attribute matches email, binding as the same read-only service account Authenticate/Browse use. Unlike Authenticate, it never verifies a password - a match only means "an account with this email exists in the directory," not that the caller is that person - so callers must not treat the result as an authenticated identity. Used by the forgot-password flow to auto-provision an LDAP user's local row before they've ever logged in (see web.provisionLDAPUserByEmail), the one case where a login attempt (and thus Authenticate, which already knows the username) hasn't happened yet.

Return contract mirrors Authenticate's:

  • (info, nil): exactly one entry matched, info.Username populated from AttrUsername.
  • (nil, nil): no entry matched, or Config.AttrEmail/AttrUsername is blank - not an error.
  • (nil, err): infra/config problem, or an ambiguous match (>1 entries).

type WritebackConfig

type WritebackConfig struct {
	BindDN   string
	Password string
}

WritebackConfig is the elevated identity permitted to reset another user's password - deliberately separate from Config.BindDN/BindPassword (the read-only search/bind service account, DB-stored via models.Settings and returned in full by GET /api/admin/settings). Password write-back needs much stronger directory permissions than a search bind, so this comes from util.ConfigRoot.LdapWriteback (YAML config file only, never the DB) and is passed in by the caller, same as Config itself - this package still has no DB dependency of its own.

Jump to

Keyboard shortcuts

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