// Go Ldap Api // Copyright (c) 2022 yo000 // package main import ( "os" "fmt" "flag" "time" "errors" "strings" "net/http" "encoding/json" "github.com/spf13/viper" "github.com/gin-gonic/gin" "github.com/go-ldap/ldap/v3" log "github.com/sirupsen/logrus" //"github.com/gin-gonic/gin/render" ) var ( gVersion = "0.5.8" gRoLdap *MyLdap ) func marshalResultToText(res *ldap.SearchResult, delimiter string, showValueName, showDN bool) string { var txtRes string for _, e := range res.Entries { if showDN { if showValueName { txtRes = fmt.Sprintf("%s%s\n", txtRes, e.DN) } else { txtRes = fmt.Sprintf("%sdn%s%s\n", txtRes, delimiter, e.DN) } } for _, a := range e.Attributes { for _, v := range a.Values { if showValueName { txtRes = fmt.Sprintf("%s%s\n", txtRes, v) } else { txtRes = fmt.Sprintf("%s%s%s%s\n", txtRes, a.Name, delimiter, v) } } } // No DN = No linefeed between entries if showDN { txtRes = fmt.Sprintf("%s\n", txtRes) } } return txtRes } func sendResponse(c *gin.Context, res *ldap.SearchResult, format string) { // 404 Not found if len(res.Entries) == 0 { if strings.EqualFold(format, "json") { c.JSON(http.StatusNotFound, gin.H{"error": "No result"}) } else { c.String(http.StatusNotFound, "No result") } return } log.Debugf("Got %d results", len(res.Entries)) if strings.EqualFold(format, "json") { jsonRes, err := json.Marshal(res.Entries) if err != nil { log.Errorf("Error marshalling result to json: %v", err) } log.Debugf("%v\n", string(jsonRes)) c.String(http.StatusOK, string(jsonRes)) } else if strings.EqualFold(format, "text") { txtRes := marshalResultToText(res, "=", false, true) log.Debugf("%v\n", string(txtRes)) c.String(http.StatusOK, string(txtRes)) } else if strings.EqualFold(format, "ldif") { txtRes := marshalResultToText(res, ": ", false, true) log.Debugf("%v\n", string(txtRes)) c.String(http.StatusOK, string(txtRes)) } else if strings.EqualFold(format, "textvalue") { txtRes := marshalResultToText(res, "", true, true) log.Debugf("%v\n", string(txtRes)) c.String(http.StatusOK, string(txtRes)) } else if strings.EqualFold(format, "textvalue-nodn") { txtRes := marshalResultToText(res, "", true, false) log.Debugf("%v\n", string(txtRes)) c.String(http.StatusOK, string(txtRes)) } } func checkIfModifiedSince(c *gin.Context, myldap *MyLdap, baseDn, cn, class, attributes string) (bool, error) { // FIXME: We need to cache the last result, because if an item is deleted from LDAP we won't see it and // we will return 304. So deletions will never make their way to Rspamd // For now, lets always return "Modified" return true, nil if len(c.Request.Header["If-Modified-Since"]) > 0 { t := strings.Replace(c.Request.Header["If-Modified-Since"][0], "GMT", "+0000", 1) ifModifiedSince, _ := time.Parse(time.RFC1123Z, t) log.Debugf("ifModifiedSince: %s", ifModifiedSince) res, err := searchByCn(myldap, baseDn, cn, class, "modifyTimestamp") if err != nil { log.Errorf("Error searching modifyTimestamp for %s in %s : %v", cn, baseDn, err) return true, err } // modifyTimestamp format mtFmt := "20060102150405Z" // Compare each object timestamp hasNewer := false for _, e := range res.Entries { for _, a := range e.Attributes { if strings.EqualFold(a.Name, "modifyTimestamp") { mt, _ := time.Parse(mtFmt, a.Values[0]) log.Debugf("%s modifyTimestamp: %s", e.DN, mt) if mt.Unix() > ifModifiedSince.Unix() { log.Debugf("%s is newer than %s: %s", e.DN, ifModifiedSince, mt) hasNewer = true break } } } if hasNewer { break } } if false == hasNewer { return false, nil } } return true, nil } // Basic Authentication handler with local hardcoded account - do not use func basicAuth(c *gin.Context) { user, password, hasAuth := c.Request.BasicAuth() if hasAuth && user == "admin" && password == "admin" { log.Infof("[%s]: User %s successfully authenticated", c.Request.RemoteAddr, user) } else { c.AbortWithStatus(http.StatusUnauthorized) c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") return } } // Basic Authentication handler to ldap func ldapBasicAuth(c *gin.Context) { var err error // Get user & password from http client user, password, hasAuth := c.Request.BasicAuth() if hasAuth { // First find the full DN for provided username if gRoLdap.Conn == nil { gRoLdap, err = connectLdap(gRoLdap) if err != nil { log.Errorf("[%s]: Cannot connect to LDAP: %v", c.Request.RemoteAddr, err) c.AbortWithStatus(http.StatusUnauthorized) c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") return } } userDn, err := findUserFullDN(gRoLdap, user) if err != nil { log.Errorf("[%s]: Cannot connect to LDAP: %v", c.Request.RemoteAddr, err) c.AbortWithStatus(http.StatusUnauthorized) c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") return } // Then open this ldap connection bound to client credentials, using found full Dn cl, err := connectLdap(&MyLdap{Host: gRoLdap.Host, User: userDn, Pass: password, BaseDN: gRoLdap.BaseDN}) if err != nil { log.Errorf("[%s]: Cannot connect to LDAP: %v", c.Request.RemoteAddr, err) c.AbortWithStatus(http.StatusUnauthorized) c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") return } else { log.Infof("[%s]: User %s successfully authenticated with bind DN %s", c.Request.RemoteAddr, user, userDn) // Store LDAP connection into gin context c.Set("ldapCon", cl) } } else { c.AbortWithStatus(http.StatusUnauthorized) c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") return } } func getLdapConFromContext(c *gin.Context) (*MyLdap, error) { ldapCon, exist := c.Get("ldapCon") if exist != true { return nil, errors.New("Cannot get connection from context") } return ldapCon.(*MyLdap), nil } func initRouter(r *gin.Engine) { r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "pong", }) return }) // All following routes need authentication /* panic: ':ou' in new path '/:ou/:cn/:class' conflicts with existing wildcard ':dn' in existing prefix '/:dn' r.GET("/:dn", ldapBasicAuth, func(c *gin.Context) { dn := c.Param("dn") // Get user authenticated LDAP connection from context ldapCon, err := getLdapConFromContext(c) if err != nil { log.Error(err) c.AbortWithError(http.StatusInternalServerError, nil) return } // json format is the default format := c.DefaultQuery("format", "json") res, err := searchByDn(ldapCon, dn, "ALL") // If DN does not exist, we'll get err='LDAP Result Code 32 "No Such Object"' if err != nil { if strings.Contains(err.Error(), "LDAP Result Code 32") { c.AbortWithError(http.StatusNotFound, err) return } else { log.Errorf("Error searching %s: %v", dn, err) c.AbortWithError(http.StatusInternalServerError, err) return } } sendResponse(c, res, format) return })*/ r.GET("/:ou/:cn/:class", ldapBasicAuth, func(c *gin.Context) { ou := c.Param("ou") cn := c.Param("cn") class := c.Param("class") // Get user authenticated LDAP connection from context ldapCon, err := getLdapConFromContext(c) if err != nil { log.Error(err) c.AbortWithError(http.StatusInternalServerError, nil) return } defer ldapCon.Conn.Close() // json format is the default format := c.DefaultQuery("format", "json") res, err := searchByCn(ldapCon, ou, cn, class, "ALL") // If OU does not exist, we'll get err='LDAP Result Code 32 "No Such Object"' if err != nil { if strings.Contains(err.Error(), "LDAP Result Code 32") { c.AbortWithError(http.StatusNotFound, err) return } else { log.Errorf("Error searching %s in %s : %v", cn, ou, err) c.AbortWithError(http.StatusInternalServerError, err) return } } sendResponse(c, res, format) return }) r.HEAD("/:ou/:cn/:class", ldapBasicAuth, func(c *gin.Context) { ou := c.Param("ou") cn := c.Param("cn") class := c.Param("class") format := c.DefaultQuery("format", "json") // Get user authenticated LDAP connection from context ldapCon, err := getLdapConFromContext(c) if err != nil { log.Error(err) c.AbortWithError(http.StatusInternalServerError, nil) return } defer ldapCon.Conn.Close() modified, err := checkIfModifiedSince(c, ldapCon, ou, cn, class, "ALL") if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } if modified { res, err := searchByCn(ldapCon, ou, cn, class, "ALL") if err != nil { log.Errorf("Error searching %s in %s : %v", cn, ou, err) c.AbortWithError(http.StatusInternalServerError, err) return } sendResponse(c, res, format) } else { c.String(http.StatusNotModified, "") } return }) r.GET("/:ou/:cn/:class/:attribute", ldapBasicAuth, func(c *gin.Context) { ou := c.Param("ou") cn := c.Param("cn") attr := c.Param("attribute") class := c.Param("class") format := c.DefaultQuery("format", "json") // Get user authenticated LDAP connection from context ldapCon, err := getLdapConFromContext(c) if err != nil { log.Error(err) c.AbortWithError(http.StatusInternalServerError, nil) return } defer ldapCon.Conn.Close() res, err := searchByCn(ldapCon, ou, cn, class, attr) if err != nil { if strings.Contains(err.Error(), "LDAP Result Code 32") { c.AbortWithError(http.StatusNotFound, err) return } else { log.Errorf("Error searching %s in %s : %v", cn, ou, err) c.AbortWithError(http.StatusInternalServerError, err) return } } sendResponse(c, res, format) return }) r.HEAD("/:ou/:cn/:class/:attribute", ldapBasicAuth, func(c *gin.Context) { ou := c.Param("ou") cn := c.Param("cn") attr := c.Param("attribute") class := c.Param("class") format := c.DefaultQuery("format", "json") // Get user authenticated LDAP connection from context ldapCon, err := getLdapConFromContext(c) if err != nil { log.Error(err) c.AbortWithError(http.StatusInternalServerError, nil) return } defer ldapCon.Conn.Close() modified, err := checkIfModifiedSince(c, ldapCon, ou, cn, class, attr) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } if modified { res, err := searchByCn(ldapCon, ou, cn, class, attr) if err != nil { log.Errorf("Error searching %s in %s : %v", cn, ou, err) c.AbortWithError(http.StatusInternalServerError, err) return } sendResponse(c, res, format) } else { c.String(http.StatusNotModified, "") } return }) /* 2 call methods : Either DN in url, or DN in body using /add : * * curl -u "admin:admin" -H "Content-Type: application/json" -X POST * --data '{"objectClass":["person","top"],"cn":"newuser","sn":"New"}' \ * https://localhost:8443/cn=newuser,ou=users,dc=example,dc=org * * curl -u "admin:admin" -H "Content-Type: application/json" -X POST * --data '{"dn":"cn=newuser,ou=users,dc=example,dc=org","objectClass":["person","top"],"cn":"newuser","sn":"New"}' \ * https://localhost:8443/add */ r.POST("/:dn", ldapBasicAuth, func(c *gin.Context) { dn := c.Param("dn") // Get user authenticated LDAP connection from context ldapCon, err := getLdapConFromContext(c) if err != nil { //log.Error(err) c.AbortWithError(http.StatusInternalServerError, err) return } defer ldapCon.Conn.Close() // Unmarshall json body to a map if c.Request.Header.Get("Content-Type") == "application/json" { var attributes map[string]interface{} err := c.ShouldBindJSON(&attributes) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } // Get dn in body if called with "http://1.2.3.4/add" if strings.EqualFold(dn, "add") { dn = attributes["dn"].(string) } if len(dn) == 0 { c.AbortWithError(http.StatusBadRequest, err) return } err = createEntry(ldapCon, dn, attributes) if err != nil { if strings.Contains(err.Error(), "LDAP Result Code 50") { c.AbortWithStatus(http.StatusUnauthorized) return // "Entry Already Exists" } else if strings.Contains(err.Error(), "LDAP Result Code 68") { c.JSON(http.StatusCreated, gin.H{"message": "Entry already exists"}) /* This returns 201/Created with Location header, although 303/SeeOther is specified * c.Render(http.StatusSeeOther, render.Redirect{ Code: 303, Location: fmt.Sprintf("http://1.2.3.4/%s", dn), Request: c.Request, })*/ return } else { c.AbortWithError(http.StatusBadRequest, err) return } } c.JSON(http.StatusCreated, gin.H{"message": "Successfully created"}) } }) /* * curl -u "admin:admin" --header "Content-Type: application/json" -X PUT * --data '{"objectClass":["person","top"],"cn":"newuser","sn":"New","description":"Test account"}' \ * https://localhost:8443/cn=newuser,ou=users,dc=example,dc=org * or * curl -u "admin:admin" -H "Content-Type: application/json" -X PUT * -d '{"dn":"cn=newuser,ou=users,dc=example,dc=org", \ * "objectClass":["person","top"],"cn":"newuser","sn":"New","description":"Test account"}' \ * https://localhost:8443/modify */ r.PUT("/:dn", ldapBasicAuth, func(c *gin.Context) { dn := c.Param("dn") // Get user authenticated LDAP connection from context ldapCon, err := getLdapConFromContext(c) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } defer ldapCon.Conn.Close() // Unmarshall json body to a map if c.Request.Header.Get("Content-Type") == "application/json" { var attributes map[string]interface{} err := c.ShouldBindJSON(&attributes) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } // Get dn in body if called with "http://1.2.3.4/modify" if strings.EqualFold(dn, "modify") { dn = attributes["dn"].(string) } if len(dn) == 0 { c.AbortWithError(http.StatusBadRequest, err) return } err = updateEntry(ldapCon, dn, attributes) if err != nil { if strings.Contains(err.Error(), "LDAP Result Code 50") { c.AbortWithStatus(http.StatusUnauthorized) return } else if strings.Contains(err.Error(), "No modification required") { c.JSON(http.StatusNoContent, gin.H{"message": "No modification required"}) return } else { c.AbortWithError(http.StatusBadRequest, err) return } } c.JSON(http.StatusCreated, gin.H{"message": "Successfully updated"}) } }) /* 2 call methods : Either DN in url, or DN in body using /delete : * curl -i -u "admin:admin" -X DELETE https://localhost:8443/cn=newuser,ou=users,dc=example,dc=org * or * curl -i -u "admin:admin" -X DELETE -H "Content-Type: application/json" -d '{"dn":"cn=newuser,ou=users,dc=example,dc=org"}' https://localhost:8443/delete * * Each leaf have to be deleted (cannot delete if subordinates) */ r.DELETE("/:dn", ldapBasicAuth, func(c *gin.Context) { dn := c.Param("dn") // Get user authenticated LDAP connection from context ldapCon, err := getLdapConFromContext(c) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } defer ldapCon.Conn.Close() // Unmarshall json body to a map if c.Request.Header.Get("Content-Type") == "application/json" { var attributes map[string]interface{} err := c.ShouldBindJSON(&attributes) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } // Get dn in body if called with "http://1.2.3.4/delete" if strings.EqualFold(dn, "delete") { dn = attributes["dn"].(string) } if len(dn) == 0 { c.AbortWithError(http.StatusBadRequest, err) return } err = deleteEntry(ldapCon, dn) if err != nil { //log.Errorf("Error creating %s: %v", dn, err) c.AbortWithError(http.StatusBadRequest, err) return } c.JSON(http.StatusOK, gin.H{"message": "Successfully deleted"}) } else { err = deleteEntry(ldapCon, dn) if err != nil { c.AbortWithError(http.StatusBadRequest, err) return } } }) } func main() { var confFile string var listen string var ldapHost string var ldapUser string var ldapPass string var ldapBaseDN string var ldapAuthBaseDN string var tlsPrivKey string var tlsCert string var doTls bool var debug bool var showversion bool flag.StringVar(&confFile, "config", "", "Path to the config file (optional)") flag.StringVar(&listen, "listen-addr", "0.0.0.0:8080", "listen address for server") flag.StringVar(&ldapHost, "ldap-host", "", "ldap host to connect to") flag.StringVar(&ldapUser, "ldap-user", "", "ldap read-only username") flag.StringVar(&ldapPass, "ldap-pass", "", "ldap password") flag.StringVar(&ldapBaseDN, "ldap-base-dn", "", "ldap base DN") flag.StringVar(&ldapAuthBaseDN, "ldap-auth-base-dn", "", "ldap base DN to find authenticating users") flag.BoolVar(&doTls, "https", false, "Serve over TLS") flag.StringVar(&tlsPrivKey, "ssl-private-key", "", "SSL Private key") flag.StringVar(&tlsCert, "ssl-certificate", "", "SSL certificate (PEM format)") flag.BoolVar(&debug, "debug", false, "Set log level to debug") flag.BoolVar(&showversion, "version", false, "Show version then exit") flag.Parse() if showversion { fmt.Printf("GlApi v%s\n", gVersion) return } if len(confFile) > 0 { viper.SetConfigFile(confFile) if err := viper.ReadInConfig(); err != nil { log.Fatalf("Could not open config file: %v", err) os.Exit(1) } } if false == debug { debug = viper.GetBool("DEBUG") } if strings.EqualFold(listen, "0.0.0.0:8080") && len(confFile) > 0 { l := viper.GetString("LISTEN") if len(l) > 0 { listen = l } } if len(ldapHost) == 0 { l := viper.GetString("LDAP_HOST") if len(l) > 0 { ldapHost = l } else { log.Fatal("No ldap-host defined!") } } if len(ldapUser) == 0 { l := viper.GetString("LDAP_USER") if len(l) > 0 { ldapUser = l } else { log.Fatal("No ldap-user defined!") } } if len(ldapPass) == 0 { l := viper.GetString("LDAP_PASS") if len(l) > 0 { ldapPass = l } else { log.Fatal("No ldap-pass defined!") } } if len(ldapBaseDN) == 0 { l := viper.GetString("LDAP_BASE_DN") if len(l) > 0 { ldapBaseDN = l } else { log.Fatal("No ldap-base-dn defined!") } } if len(ldapAuthBaseDN) == 0 { l := viper.GetString("LDAP_AUTH_BASE_DN") if len(l) > 0 { ldapAuthBaseDN = l } else { log.Fatal("No ldap-auth-base-dn defined!") } } if false == doTls { doTls = viper.GetBool("HTTPS") } if doTls && len(tlsCert) == 0 { l := viper.GetString("SSL_CERTIFICATE") if len(l) > 0 { tlsCert = l } else { log.Fatal("SSL certificate must be set to use https!") } } if doTls && len(tlsPrivKey) == 0 { l := viper.GetString("SSL_PRIVATE_KEY") if len(l) > 0 { tlsPrivKey = l } else { log.Fatal("SSL private key must be set to use https!") } } log.Println("Starting Go Ldap API v.", gVersion) if debug { log.SetLevel(log.DebugLevel) } r := gin.Default() gRoLdap = &MyLdap{Host: ldapHost, User: ldapUser, Pass: ldapPass, BaseDN: ldapBaseDN, AuthBaseDN: ldapAuthBaseDN} _, err := connectLdap(gRoLdap) if err != nil { log.Fatalf("Cannot connect to ldap: %v", err) } initRouter(r) if doTls { r.RunTLS(listen, tlsCert, tlsPrivKey) } else { r.Run(listen) } }