blob: 52522e418411ab8e56018a4997a8e8cc0742cc6f [file]
package ldap
import (
"crypto/tls"
"crypto/x509"
"embed"
"encoding/binary"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/apache/answer-plugins/connector-ldap/i18n"
"github.com/segmentfault/pacman/log"
"github.com/apache/answer-plugins/util"
"github.com/apache/answer/plugin"
"github.com/go-ldap/ldap/v3"
)
//go:embed info.yaml
var Info embed.FS
//go:embed login.html
var loginHTML embed.FS
const (
LdapAttributeDn = "dn"
LdapAttributeUid = "uid"
LdapAttributeCn = "cn"
LdapAttributeMail = "mail"
LdapAttributeDisplayName = "displayName"
LdapAttributeSamAccountName = "sAMAccountName"
LdapAttributeObjectGUID = "objectGUID"
DefaultExternalIDAttr = "entryUUID"
)
type Connector struct {
Config *ConnectorConfig
}
type ConnectorConfig struct {
Name string `json:"name"`
Server string `json:"server"`
BaseDN string `json:"base_dn"`
BindDN string `json:"bind_dn"`
BindPassword string `json:"bind_password"`
UserAttr string `json:"user_attr"`
ExternalIDAttr string `json:"external_id_attr"`
TLSCACertPath string `json:"tls_ca_cert_path"`
}
var _ plugin.Connector = &Connector{}
var loginHTMLContent string
func init() {
plugin.Register(&Connector{
Config: &ConnectorConfig{},
})
htmlContent, err := loginHTML.ReadFile("login.html")
if err != nil {
log.Errorf("failed to read embedded html file: %v", err)
}
loginHTMLContent = string(htmlContent)
if "" == loginHTMLContent {
log.Error("html file is empty")
}
}
func (g *Connector) Info() plugin.Info {
info := &util.Info{}
info.GetInfo(Info)
return plugin.Info{
Name: plugin.MakeTranslator(i18n.InfoName),
SlugName: info.SlugName,
Description: plugin.MakeTranslator(i18n.InfoDescription),
Author: info.Author,
Version: info.Version,
Link: info.Link,
}
}
func (g *Connector) ConnectorName() plugin.Translator {
if g.Config.Name != "" {
return plugin.MakeTranslator(g.Config.Name)
}
return plugin.MakeTranslator(i18n.ConnectorName)
}
func (g *Connector) ConnectorSlugName() string {
return "ldap"
}
func (g *Connector) ConnectorLogoSVG() string {
return ""
}
func (g *Connector) ConnectorRequireState() bool {
return true
}
func (g *Connector) ConnectorSender(ctx *plugin.GinContext, receiverURL string) string {
state := ctx.Request.URL.Query().Get("state")
if state != "" {
receiverURL = receiverURL + "?state=" + url.QueryEscape(state)
}
htmlContent := strings.Replace(loginHTMLContent, "RECEIVER_URL_PLACEHOLDER", receiverURL, -1)
htmlContent = strings.Replace(htmlContent, "LOGIN_TITLE_PLACEHOLDER", plugin.Translate(ctx, i18n.LoginTitle), -1)
htmlContent = strings.Replace(htmlContent, "LOGIN_SUBTITLE_PLACEHOLDER", plugin.Translate(ctx, i18n.LoginSubtitle), -1)
htmlContent = strings.Replace(htmlContent, "LOGIN_USERNAME_PLACEHOLDER", plugin.Translate(ctx, i18n.LoginUsername), -1)
htmlContent = strings.Replace(htmlContent, "LOGIN_PASSWORD_PLACEHOLDER", plugin.Translate(ctx, i18n.LoginPassword), -1)
htmlContent = strings.Replace(htmlContent, "LOGIN_SUBMIT_PLACEHOLDER", plugin.Translate(ctx, i18n.LoginSubmit), -1)
ctx.Writer.WriteHeader(200)
ctx.Writer.Header().Set("Content-Type", "text/html")
err := writeHtmlContent(ctx, htmlContent)
if err != nil {
log.Errorf("failed to write HTML response: %v", err)
}
return ""
}
func writeHtmlContent(ctx *plugin.GinContext, htmlContent string) error {
ctx.Writer.WriteHeader(200)
ctx.Writer.Header().Set("Content-Type", "text/html")
_, err := ctx.Writer.Write([]byte(htmlContent))
return err
}
func (g *Connector) ConfigFields() []plugin.ConfigField {
return []plugin.ConfigField{
createTextInput("name", i18n.ConfigNameTitle, i18n.ConfigNameDescription, g.Config.Name, true, false),
createTextInput("server", i18n.ConfigServerTitle, i18n.ConfigServerDescription, g.Config.Server, true, false),
createTextInput("base_dn", i18n.ConfigBaseDNTitle, i18n.ConfigBaseDNDescription, g.Config.BaseDN, true, false),
createTextInput("bind_dn", i18n.ConfigBindDNTitle, i18n.ConfigBindDNDescription, g.Config.BindDN, true, false),
createTextInput("bind_password", i18n.ConfigBindPasswordTitle, i18n.ConfigBindPasswordDescription, g.Config.BindPassword, true, true),
createTextInput("user_attr", i18n.ConfigUserAttrTitle, i18n.ConfigUserAttrDescription, g.Config.UserAttr, true, false),
createTextInput("external_id_attr", i18n.ConfigExternalIDAttrTitle, i18n.ConfigExternalIDAttrDescription, externalIDAttrOrDefault(g.Config.ExternalIDAttr), true, false),
createTextInput("tls_ca_cert_path", i18n.ConfigTLSCACertPathTitle, i18n.ConfigTLSCACertPathDescription, g.Config.TLSCACertPath, false, false),
}
}
func (g *Connector) ConfigReceiver(config []byte) error {
c := &ConnectorConfig{}
if err := json.Unmarshal(config, c); err != nil {
return fmt.Errorf("invalid config json: %w", err)
}
g.Config = c
return nil
}
func (c *Connector) ConnectorReceiver(ctx *plugin.GinContext, receiverURL string) (userInfo plugin.ExternalLoginUserInfo, err error) {
if err := checkSameOrigin(ctx.Request, receiverURL); err != nil {
return userInfo, fmt.Errorf("csrf check failed: %w", err)
}
username, password, err := extractCredentials(ctx.Request)
if err != nil {
return userInfo, err
}
l, err := connectLDAP(c.Config.Server, c.Config.TLSCACertPath)
if err != nil {
return userInfo, fmt.Errorf("failed to connect to LDAP server: %w", err)
}
defer l.Close()
if err := bindServiceAccount(l, c.Config.BindDN, c.Config.BindPassword); err != nil {
return userInfo, fmt.Errorf("service account bind failed: %w", err)
}
externalIDAttr := externalIDAttrOrDefault(c.Config.ExternalIDAttr)
entry, err := searchUser(l, c.Config.BaseDN, c.Config.UserAttr, externalIDAttr, username)
if err != nil {
return userInfo, err
}
err = l.Bind(entry.DN, password)
if err != nil {
return userInfo, fmt.Errorf("invalid username or password")
}
userInfo, err = extractUserInfo(entry, externalIDAttr)
if err != nil {
return userInfo, err
}
return userInfo, nil
}
var connectLDAP = dialWithTLS
func bindServiceAccount(l ldap.Client, bindDN, bindPassword string) error {
return l.Bind(bindDN, bindPassword)
}
func searchUser(l ldap.Client, baseDN, userAttr, externalIDAttr, username string) (*ldap.Entry, error) {
attributes := []string{LdapAttributeDn, LdapAttributeUid, LdapAttributeCn, LdapAttributeMail, LdapAttributeDisplayName, LdapAttributeSamAccountName, externalIDAttr}
searchRequest := ldap.NewSearchRequest(
baseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
fmt.Sprintf("(%s=%s)", userAttr, ldap.EscapeFilter(username)),
attributes,
nil,
)
sr, err := l.Search(searchRequest)
if err != nil || len(sr.Entries) == 0 {
return nil, fmt.Errorf("user not found: %w", err)
}
return sr.Entries[0], nil
}
func checkSameOrigin(request *http.Request, receiverURL string) error {
expected, err := url.Parse(receiverURL)
if err != nil {
return fmt.Errorf("invalid receiver URL: %w", err)
}
if origin := request.Header.Get("Origin"); origin != "" {
originURL, err := url.Parse(origin)
if err != nil || originURL.Scheme != expected.Scheme || originURL.Host != expected.Host {
return fmt.Errorf("request origin %q does not match site origin", origin)
}
return nil
}
if referer := request.Header.Get("Referer"); referer != "" {
refererURL, err := url.Parse(referer)
if err != nil || refererURL.Scheme != expected.Scheme || refererURL.Host != expected.Host {
return fmt.Errorf("request referer %q does not match site origin", referer)
}
return nil
}
return fmt.Errorf("missing Origin and Referer headers")
}
func extractCredentials(request *http.Request) (username string, password string, err error) {
err = request.ParseForm()
if err != nil {
log.Errorf("failed to parse form: %v", err)
return "", "", err
}
username = request.FormValue("username")
password = request.FormValue("password")
if username == "" || password == "" {
log.Errorf("missing username and/or password")
err = fmt.Errorf("missing username or password")
}
return
}
func extractUserInfo(entry *ldap.Entry, externalIDAttr string) (plugin.ExternalLoginUserInfo, error) {
displayName := entry.GetAttributeValue(LdapAttributeDisplayName)
if displayName == "" {
displayName = entry.GetAttributeValue(LdapAttributeCn)
}
username := entry.GetAttributeValue(LdapAttributeUid)
if username == "" {
username = entry.GetAttributeValue(LdapAttributeSamAccountName)
}
externalID, err := extractExternalID(entry, externalIDAttr)
if err != nil {
return plugin.ExternalLoginUserInfo{}, err
}
email := entry.GetAttributeValue(LdapAttributeMail)
if email == "" {
return plugin.ExternalLoginUserInfo{}, fmt.Errorf("email is required")
}
return plugin.ExternalLoginUserInfo{
ExternalID: externalID,
DisplayName: displayName,
Username: username,
Email: email,
}, nil
}
func externalIDAttrOrDefault(externalIDAttr string) string {
if externalIDAttr == "" {
return DefaultExternalIDAttr
}
return externalIDAttr
}
func extractExternalID(entry *ldap.Entry, externalIDAttr string) (string, error) {
if externalIDAttr == LdapAttributeObjectGUID {
raw := entry.GetRawAttributeValue(externalIDAttr)
if len(raw) != 16 {
return "", fmt.Errorf("missing or invalid %s attribute", externalIDAttr)
}
return formatObjectGUID(raw), nil
}
externalID := entry.GetAttributeValue(externalIDAttr)
if externalID == "" {
return "", fmt.Errorf("missing %s attribute", externalIDAttr)
}
return externalID, nil
}
func formatObjectGUID(guid []byte) string {
return fmt.Sprintf("%08x-%04x-%04x-%x-%x",
binary.LittleEndian.Uint32(guid[0:4]),
binary.LittleEndian.Uint16(guid[4:6]),
binary.LittleEndian.Uint16(guid[6:8]),
guid[8:10],
guid[10:16],
)
}
func createTextInput(name, title, desc, value string, require bool, password bool) plugin.ConfigField {
uiOptions := plugin.ConfigFieldUIOptions{
InputType: plugin.InputTypeText,
}
if password {
uiOptions = plugin.ConfigFieldUIOptions{
InputType: plugin.InputTypePassword,
}
}
return plugin.ConfigField{
Name: name,
Type: plugin.ConfigTypeInput,
Title: plugin.MakeTranslator(title),
Description: plugin.MakeTranslator(desc),
Required: require,
UIOptions: uiOptions,
Value: value,
}
}
func createBoolInput(name, title, desc string, value bool, require bool) plugin.ConfigField {
return plugin.ConfigField{
Name: name,
Type: plugin.ConfigTypeCheckbox,
Title: plugin.MakeTranslator(title),
Description: plugin.MakeTranslator(desc),
Required: require,
UIOptions: plugin.ConfigFieldUIOptions{},
Value: value,
}
}
func dialWithTLS(server string, certPath string) (ldap.Client, error) {
serverURL, err := url.Parse(server)
if err != nil {
return nil, fmt.Errorf("invalid LDAP server URL: %w", err)
}
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: serverURL.Hostname(),
}
if certPath != "" {
certPool := x509.NewCertPool()
certData, err := os.ReadFile(certPath)
if err != nil {
log.Errorf("failed to read cert file: %v", err)
return nil, fmt.Errorf("failed to read LDAP cert: %w", err)
}
if !certPool.AppendCertsFromPEM(certData) {
log.Errorf("failed to append cert from %s", certPath)
return nil, fmt.Errorf("failed to append cert")
}
tlsConfig.RootCAs = certPool
}
if strings.HasPrefix(server, "ldaps://") {
return ldap.DialURL(server, ldap.DialWithTLSConfig(tlsConfig))
}
conn, err := ldap.DialURL(server)
if err != nil {
log.Errorf("initial plain connection failed: %v", err)
return nil, err
}
if err := conn.StartTLS(tlsConfig); err != nil {
log.Errorf("startTLS failed: %v", err)
conn.Close()
return nil, err
}
return conn, nil
}