13 Commits

4 changed files with 637 additions and 98 deletions

View File

@ -1,12 +1,13 @@
# glapi # glapi
Go Ldap API is an HTTP API to an LDAP backend. Go Ldap API is an HTTP API to an LDAP backend.
Only supporting read at the moment, maybe one day it will write, and break LDAP backends with ease and joy! Only supporting read at the moment, maybe one day it will write, and break LDAP backends with ease and joy!
UPDATE: Yay, the day has come!
## Usage ## Usage
Start glapi with parameters on command line: Start glapi with parameters on command line:
``` ```
glapi -ldap-host ldap://ldap.example.org -ldap-base-dn dc=example,dc=org -ldap-user cn=yo,dc=example,dc=org -ldap-pass 'here_is_the_password' glapi -ldap-host ldap://ldap.example.org -ldap-base-dn ou=configurations,dc=example,dc=org -ldap-auth-base-dn ou=users,dc=example,dc=org -ldap-user cn=yo,dc=example,dc=org -ldap-pass 'here_is_the_password'
``` ```
or point it to a configuration file : or point it to a configuration file :
@ -16,16 +17,31 @@ glapi -config glapi.env
## Configuration file ## Configuration file
``` ```
LISTEN="127.0.0.1:8080" LISTEN="0.0.0.0:8443"
LDAP_HOST="ldap://ldap.example.org" LDAP_HOST="ldap://ldap.example.org"
# The base DN exposed to API. Could be buried in LDAP tree so we expose only a subset of directory.
LDAP_BASE_DN="dc=example,dc=org" LDAP_BASE_DN="dc=example,dc=org"
LDAP_USER="cn=yo,dc=example,dc=org"
LDAP_PASS='here_is_the_password' # This account search for valid user provided by authenticating client.
# Then glapi bind with client provided credentials to operate LDAP.
# Thus this account only needs bind privilege, and read access to users organizational unit
LDAP_USER="cn=ldapreaduser,dc=example,dc=org"
LDAP_PASS='here_lies_the_password'
# This base DN is where we seach for authenticating accounts. This way we can chose not to expose them to the API.
LDAP_AUTH_BASE_DN="ou=users,dc=example,dc=org"
# Https support
HTTPS=true
SSL_CERTIFICATE=/etc/ssl/certs/server.pem
SSL_PRIVATE_KEY=/etc/ssl/private/server.key
``` ```
## Querying API ## Querying API
### Search Entries
Search LDAP entries through the whole subtree, specifying organizationalUnit, commnName, objectClass, attribute to retrieve. Search LDAP entries through the whole subtree, specifying organizationalUnit, commnName, objectClass, attribute to retrieve.
Each of these parameters can be replaced by "ALL" to act like a wildcard. Each of these parameters can be replaced by "ALL" to act like a wildcard.
"admin" user should exist in LDAP_AUTH_BASE_DN and searchable with cn=admin
``` ```
% curl -u admin:admin http://127.0.0.1:8080/ou=domains/yo/person | jq % curl -u admin:admin http://127.0.0.1:8080/ou=domains/yo/person | jq
[ [
@ -166,7 +182,7 @@ hasSubordinates: FALSE
[...] [...]
``` ```
### Output format #### Output format
Default output is in json. The following formats are supported: Default output is in json. The following formats are supported:
- json (default) - json (default)
- text (ini style) - text (ini style)
@ -206,7 +222,7 @@ uid: yo
``` ```
### Select attributes to get #### Select attributes to get
You can select attributes to get by adding them in 4th position : You can select attributes to get by adding them in 4th position :
``` ```
% curl -u admin:admin "http://127.0.0.1:8080/ou=domains/yo/person/mail?format=textvalue" % curl -u admin:admin "http://127.0.0.1:8080/ou=domains/yo/person/mail?format=textvalue"
@ -216,3 +232,31 @@ yo@example.org
% curl -u admin:admin "http://127.0.0.1:8080/ou=domains/yo/person/mail?format=textvalue-nodn" % curl -u admin:admin "http://127.0.0.1:8080/ou=domains/yo/person/mail?format=textvalue-nodn"
yo@example.org yo@example.org
``` ```
### Create entries
Create a new OU =users", then a new user :
```
% curl -u "admin:admin" --header "Content-Type: application/json" -X POST
--data '{"objectClass":["organizationalUnit","top"],"ou":"users"' \
https://127.0.0.1:8443/ou=users,dc=example,dc=org
% curl -u "admin:admin" --header "Content-Type: application/json" -X POST
--data '{"objectClass":["person","top"],"cn":"newuser","sn":"New"}' \
https://127.0.0.1:8443/cn=newuser,ou=users,dc=example,dc=org
```
### Modify entries
Add a description to the new account:
```
% curl -u "admin:admin" --header "Content-Type: application/json" -X PUT
--data '{"objectClass":["person","top"],"cn":"newuser","sn":"New","description":"Test account"}' \
https://127.0.0.1:8443/cn=newuser,ou=users,dc=example,dc=org
```
Missing attributes will be removed from entry.
### Delete entries
Remove newuser :
```
% curl -u "admin:admin" -X DELETE \
https://127.0.0.1:8443/cn=newuser,ou=users,dc=example,dc=org
```

View File

@ -1,11 +1,17 @@
LISTEN="0.0.0.0:8081" LISTEN="0.0.0.0:8080"
LDAP_HOST="ldap://ldap.example.org" LDAP_HOST="ldap://ldap.example.org"
LDAP_BASE_DN="dc=example,dc=org" # The base DN exposed to API. Could be buried in LDAP tree so we expose only a subset of directory.
LDAP_BASE_DN="ou=configuration,dc=example,dc=org"
# Credentials used for every op in ldap, so this account needs write access if you want to update ldap # This account search for valid users provided by authenticating clients.
LDAP_USER="cn=ldapuser,dc=example,dc=org" # Then glapi bind with client provided credentials to operate LDAP.
# Thus this account only needs bind privilege, and read access to users organizational unit
LDAP_USER="cn=ldapreaduser,dc=example,dc=org"
LDAP_PASS='here_lies_the_password' LDAP_PASS='here_lies_the_password'
# This base DN is where we seach for authenticating accounts. This way we can chose not to expose them to the API.
LDAP_AUTH_BASE_DN="ou=users,dc=example,dc=org"
# Https support # Https support
HTTPS=false HTTPS=false
SSL_CERTIFICATE=/etc/ssl/certs/server.pem SSL_CERTIFICATE=/etc/ssl/certs/server.pem

205
ldap.go
View File

@ -19,31 +19,54 @@ type MyLdap struct {
User string User string
Pass string Pass string
BaseDN string BaseDN string
AuthBaseDN string
} }
var ( var (
mutex sync.Mutex mutex sync.Mutex
) )
func marshalResultToStrMap(sr *ldap.SearchResult) map[string][]string {
var res = make(map[string][]string)
for _, e := range sr.Entries {
for _, a := range e.Attributes {
for _, v := range a.Values {
res[a.Name] = append(res[a.Name], v)
}
}
}
return res
}
func isStringInArray(strarr []string, searched string) bool {
for _, s := range strarr {
if strings.EqualFold(s, searched) {
return true
}
}
return false
}
func connectLdap(myldap *MyLdap) (*MyLdap, error) { func connectLdap(myldap *MyLdap) (*MyLdap, error) {
var err error var err error
var conLdap *MyLdap var conLdap *MyLdap
myldap.Conn, err = ldap.DialURL(myldap.Host) myldap.Conn, err = ldap.DialURL(myldap.Host)
if err != nil { if err != nil {
log.Errorf("Error dialing LDAP: %v", err) //log.Errorf("Error dialing LDAP: %v", err)
return conLdap, err return conLdap, err
} }
err = myldap.Conn.Bind(myldap.User, myldap.Pass) err = myldap.Conn.Bind(myldap.User, myldap.Pass)
if err != nil { if err != nil {
log.Errorf("Error binding LDAP: ", err) //log.Errorf("Error binding LDAP: %v", err)
return conLdap, err return conLdap, err
} }
return myldap, err return myldap, err
} }
func ldapSearch(conLdap *MyLdap, searchReq *ldap.SearchRequest, attempt int) (*ldap.SearchResult, error) { func internalLdapSearch(conLdap *MyLdap, searchReq *ldap.SearchRequest, attempt int) (*ldap.SearchResult, error) {
var err error var err error
if conLdap.Conn == nil { if conLdap.Conn == nil {
conLdap, err = connectLdap(conLdap) conLdap, err = connectLdap(conLdap)
@ -65,36 +88,59 @@ func ldapSearch(conLdap *MyLdap, searchReq *ldap.SearchRequest, attempt int) (*l
return result, err return result, err
} else { } else {
attempt = attempt + 1 attempt = attempt + 1
return ldapSearch(conLdap, searchReq, attempt) return internalLdapSearch(conLdap, searchReq, attempt)
} }
} }
return result, err return result, err
} }
func doLdapSearch(myldap *MyLdap, baseDn, cn, class, attributes string) (*ldap.SearchResult, error) { func searchByCn(myldap *MyLdap, baseDn, cn, class, attributes string) (*ldap.SearchResult, error) {
var filter string var filter string
if false == strings.EqualFold(cn, "ALL") {
filter = fmt.Sprintf("(cn=%s)", cn)
} else {
filter = cn
}
return doLdapSearch(myldap, baseDn, false, filter, class, attributes)
}
func searchByDn(myldap *MyLdap, dn, attributes string) (*ldap.SearchResult, error) {
// We cant search for a full DN, so separate cn and base dn, then remove basedn as doLdapSearch already append it
filter := strings.Split(dn, ",")[0]
rem := strings.Split(dn, ",")[1:]
bdn := strings.Join(rem, ",")
bdn = strings.Replace(bdn, fmt.Sprintf(",%s", myldap.BaseDN), "", 1)
return doLdapSearch(myldap, bdn, false, filter, "ALL", "ALL")
}
func doLdapSearch(myldap *MyLdap, baseDn string, baseDnIsAbsolute bool, filter, class, attributes string) (*ldap.SearchResult, error) {
var fFilter string
var realBaseDn string var realBaseDn string
var realAttributes []string var realAttributes []string
// Build search filter // Build final search filter
if strings.EqualFold(class, "ALL") { if strings.EqualFold(class, "ALL") {
filter = fmt.Sprintf("(objectClass=*)") fFilter = fmt.Sprintf("(objectClass=*)")
} else { } else {
filter = fmt.Sprintf("(objectClass=%s)", class) fFilter = fmt.Sprintf("(objectClass=%s)", class)
} }
if false == strings.EqualFold(cn, "ALL") { if false == strings.EqualFold(filter, "ALL") {
filter = fmt.Sprintf("(&%s(cn=%s))", filter, ldap.EscapeFilter(cn)) fFilter = fmt.Sprintf("(&%s(%s))", fFilter, ldap.EscapeFilter(filter))
} }
log.Debugf("LDAP search filter: %s", filter) log.Debugf("LDAP search filter: %s", fFilter)
// Build absolute search base DN from configuration & provided DN (which is relative) // Build absolute search base DN from configuration & provided DN (which is relative)
if strings.EqualFold(baseDn, "ALL") { if strings.EqualFold(baseDn, "ALL") || len(baseDn) == 0 {
realBaseDn = fmt.Sprintf("%s", myldap.BaseDN) realBaseDn = fmt.Sprintf("%s", myldap.BaseDN)
} else { } else {
realBaseDn = fmt.Sprintf("%s,%s", baseDn, myldap.BaseDN) if len(baseDn) > 0 && baseDnIsAbsolute {
realBaseDn = fmt.Sprintf("%s", baseDn)
} else {
realBaseDn = fmt.Sprintf("%s,%s", baseDn, myldap.BaseDN)
}
} }
log.Debugf("LDAP search base dn: %s", realBaseDn) log.Debugf("LDAP search base dn: %s", realBaseDn)
@ -109,12 +155,141 @@ func doLdapSearch(myldap *MyLdap, baseDn, cn, class, attributes string) (*ldap.S
log.Debugf("LDAP search attributes to return (all if empty): %v", realAttributes) log.Debugf("LDAP search attributes to return (all if empty): %v", realAttributes)
searchReq := ldap.NewSearchRequest(realBaseDn, ldap.ScopeWholeSubtree, 0, 0, 0, searchReq := ldap.NewSearchRequest(realBaseDn, ldap.ScopeWholeSubtree, 0, 0, 0,
false, filter, realAttributes, []ldap.Control{}) false, fFilter, realAttributes, []ldap.Control{})
result, err := ldapSearch(myldap, searchReq, 0) result, err := internalLdapSearch(myldap, searchReq, 0)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return result, nil return result, nil
} }
func findUserFullDN(myldap *MyLdap, username string) (string, error) {
filter := fmt.Sprintf("cn=%s", username)
sr, err := doLdapSearch(myldap, myldap.AuthBaseDN, true, filter, "ALL", "")
if err != nil {
return "", err
}
if len(sr.Entries) == 0 {
return "", fmt.Errorf("User not found with %s", filter)
} else if len(sr.Entries) > 1 {
return "", fmt.Errorf("More than one object (%d) found with %s", len(sr.Entries), filter)
}
result := sr.Entries[0].DN
return result, nil
}
func createEntry(myldap *MyLdap, dn string, attributes map[string]interface{}) error {
log.Debugf("Creating DN %s with attributes %v", dn, attributes)
addReq := ldap.NewAddRequest(dn, nil)
// Remove dn from map if exists
delete(attributes, "dn")
//build attributes list
for key, val := range attributes {
var v []string
strval := fmt.Sprintf("%s",val)
if strings.HasPrefix(strval, "[") && strings.HasSuffix(strval, "]") {
for _, va := range val.([]interface{}) {
v = append(v, va.(string))
}
} else {
v = append(v, strval)
}
addReq.Attribute(key, v)
}
err := myldap.Conn.Add(addReq)
return err
}
func updateEntry(myldap *MyLdap, dn string, attributes map[string]interface{}) error {
log.Debugf("Updating DN %s with attributes %v", dn, attributes)
// Remove dn from map if exists
delete(attributes, "dn")
// First get the current object so we can build a list of add, modify, delete attributes
sr, err := searchByDn(myldap, dn, "ALL")
if err != nil {
return err
}
if len(sr.Entries) == 0 {
return fmt.Errorf("Object %s not found", dn)
}
actualAttrs := marshalResultToStrMap(sr)
// convert attributes to map[string][]string
var attrsMap = make(map[string][]string)
for key, val := range attributes {
var v []string
strval := fmt.Sprintf("%s",val)
if strings.HasPrefix(strval, "[") && strings.HasSuffix(strval, "]") {
for _, va := range val.([]interface{}) {
v = append(v, va.(string))
}
} else {
v = append(v, strval)
}
attrsMap[key] = v
}
modReq := ldap.NewModifyRequest(dn, nil)
// Now compare. We browse required attributes to get modify and add operations
opnr := 0
for k, v := range attrsMap {
fv := 0
va, found := actualAttrs[k]
if found {
for _, cv := range va {
if isStringInArray(v, cv) {
fv += 1
}
}
if fv != len(va) {
modReq.Replace(k, v)
opnr++
}
} else {
modReq.Add(k, v)
opnr++
}
}
// now browse actual attributes to get delete operations
for k, v := range actualAttrs {
_, found := attrsMap[k]
if false == found {
modReq.Delete(k, v)
opnr++
}
}
if opnr == 0 {
return fmt.Errorf("No modification required")
}
// Finally execute operations
err = myldap.Conn.Modify(modReq)
return err
}
func deleteEntry(myldap *MyLdap, dn string) error {
log.Debugf("Deleting DN %s", dn)
delReq := ldap.NewDelRequest(dn, nil)
err := myldap.Conn.Del(delReq)
return err
}

456
main.go
View File

@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"flag" "flag"
"time" "time"
"errors"
"strings" "strings"
"net/http" "net/http"
"encoding/json" "encoding/json"
@ -17,10 +18,13 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
//"github.com/gin-gonic/gin/render"
) )
var ( var (
gVersion = "0.5.1" gVersion = "0.5.5"
gRoLdap *MyLdap
) )
func marshalResultToText(res *ldap.SearchResult, delimiter string, showValueName, showDN bool) string { func marshalResultToText(res *ldap.SearchResult, delimiter string, showValueName, showDN bool) string {
@ -91,7 +95,6 @@ func sendResponse(c *gin.Context, res *ldap.SearchResult, format string) {
txtRes := marshalResultToText(res, "", true, false) txtRes := marshalResultToText(res, "", true, false)
log.Debugf("%v\n", string(txtRes)) log.Debugf("%v\n", string(txtRes))
c.String(http.StatusOK, string(txtRes)) c.String(http.StatusOK, string(txtRes))
} }
} }
@ -108,7 +111,7 @@ func checkIfModifiedSince(c *gin.Context, myldap *MyLdap, baseDn, cn, class, att
log.Debugf("ifModifiedSince: %s", ifModifiedSince) log.Debugf("ifModifiedSince: %s", ifModifiedSince)
res, err := doLdapSearch(myldap, baseDn, cn, class, "modifyTimestamp") res, err := searchByCn(myldap, baseDn, cn, class, "modifyTimestamp")
if err != nil { if err != nil {
log.Errorf("Error searching modifyTimestamp for %s in %s : %v", cn, baseDn, err) log.Errorf("Error searching modifyTimestamp for %s in %s : %v", cn, baseDn, err)
return true, err return true, err
@ -142,9 +145,7 @@ func checkIfModifiedSince(c *gin.Context, myldap *MyLdap, baseDn, cn, class, att
return true, nil return true, nil
} }
// Basic Authentication handler with local hardcoded account - do not use
// Basic Authentication handler
// TODO: Where to store accounts?
func basicAuth(c *gin.Context) { func basicAuth(c *gin.Context) {
user, password, hasAuth := c.Request.BasicAuth() user, password, hasAuth := c.Request.BasicAuth()
if hasAuth && user == "admin" && password == "admin" { if hasAuth && user == "admin" && password == "admin" {
@ -156,100 +157,156 @@ func basicAuth(c *gin.Context) {
} }
} }
// 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 initRouter(r *gin.Engine, myldap *MyLdap) { 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) { r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "pong", "message": "pong",
}) })
return
}) })
// All following routes need authentication // All following routes need authentication
r.GET("/:ou/:cn/:class", basicAuth, func(c *gin.Context) { /* 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") ou := c.Param("ou")
cn := c.Param("cn") cn := c.Param("cn")
class := c.Param("class") 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
}
// json format is the default // json format is the default
format := c.DefaultQuery("format", "json") format := c.DefaultQuery("format", "json")
log.Printf("Format : %s", format)
res, err := searchByCn(ldapCon, ou, cn, class, "ALL")
res, err := doLdapSearch(myldap, ou, cn, class, "ALL")
// If OU does not exist, we'll get err='LDAP Result Code 32 "No Such Object"'
// If OU does not exist, we'll get err='LDAP Result Code 32 "No Such Object"' if err != nil {
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) log.Errorf("Error searching %s in %s : %v", cn, ou, err)
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
sendResponse(c, res, format)
return
})
r.HEAD("/:ou/:cn/:class", basicAuth, func(c *gin.Context) {
ou := c.Param("ou")
cn := c.Param("cn")
class := c.Param("class")
format := c.DefaultQuery("format", "json")
log.Printf("Format : %s", format)
modified, err := checkIfModifiedSince(c, myldap, ou, cn, class, "ALL")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if modified {
res, err := doLdapSearch(myldap, 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", basicAuth, 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")
log.Printf("Format : %s", format)
res, err := doLdapSearch(myldap, 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) sendResponse(c, res, format)
return return
}) })
r.HEAD("/:ou/:cn/:class/:attribute", basicAuth, func(c *gin.Context) { r.HEAD("/:ou/:cn/:class", ldapBasicAuth, func(c *gin.Context) {
ou := c.Param("ou") ou := c.Param("ou")
cn := c.Param("cn") cn := c.Param("cn")
attr := c.Param("attribute")
class := c.Param("class") class := c.Param("class")
format := c.DefaultQuery("format", "json") format := c.DefaultQuery("format", "json")
log.Printf("Format : %s", format)
modified, err := checkIfModifiedSince(c, myldap, ou, cn, class, attr) // Get user authenticated LDAP connection from context
ldapCon, err := getLdapConFromContext(c)
if err != nil {
log.Error(err)
c.AbortWithError(http.StatusInternalServerError, nil)
return
}
modified, err := checkIfModifiedSince(c, ldapCon, ou, cn, class, "ALL")
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
if modified { if modified {
res, err := doLdapSearch(myldap, ou, cn, class, attr) res, err := searchByCn(ldapCon, ou, cn, class, "ALL")
if err != nil { if err != nil {
log.Errorf("Error searching %s in %s : %v", cn, ou, err) log.Errorf("Error searching %s in %s : %v", cn, ou, err)
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -261,6 +318,244 @@ func initRouter(r *gin.Engine, myldap *MyLdap) {
} }
return 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
}
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
}
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
}
// 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
}
// 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
}
// 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() { func main() {
@ -270,6 +565,7 @@ func main() {
var ldapUser string var ldapUser string
var ldapPass string var ldapPass string
var ldapBaseDN string var ldapBaseDN string
var ldapAuthBaseDN string
var tlsPrivKey string var tlsPrivKey string
var tlsCert string var tlsCert string
var doTls bool var doTls bool
@ -278,9 +574,10 @@ func main() {
flag.StringVar(&confFile, "config", "", "Path to the config file (optional)") 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(&listen, "listen-addr", "0.0.0.0:8080", "listen address for server")
flag.StringVar(&ldapHost, "ldap-host", "", "ldap host to connect to") flag.StringVar(&ldapHost, "ldap-host", "", "ldap host to connect to")
flag.StringVar(&ldapUser, "ldap-user", "", "ldap username") flag.StringVar(&ldapUser, "ldap-user", "", "ldap read-only username")
flag.StringVar(&ldapPass, "ldap-pass", "", "ldap password") flag.StringVar(&ldapPass, "ldap-pass", "", "ldap password")
flag.StringVar(&ldapBaseDN, "ldap-base-dn", "", "ldap base DN") 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.BoolVar(&doTls, "https", false, "Serve over TLS")
flag.StringVar(&tlsPrivKey, "ssl-private-key", "", "SSL Private key") flag.StringVar(&tlsPrivKey, "ssl-private-key", "", "SSL Private key")
flag.StringVar(&tlsCert, "ssl-certificate", "", "SSL certificate (PEM format)") flag.StringVar(&tlsCert, "ssl-certificate", "", "SSL certificate (PEM format)")
@ -295,6 +592,10 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
if false == debug {
debug = viper.GetBool("DEBUG")
}
if strings.EqualFold(listen, "0.0.0.0:8080") && len(confFile) > 0 { if strings.EqualFold(listen, "0.0.0.0:8080") && len(confFile) > 0 {
l := viper.GetString("LISTEN") l := viper.GetString("LISTEN")
@ -335,6 +636,14 @@ func main() {
log.Fatal("No ldap-base-dn defined!") 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 { if false == doTls {
doTls = viper.GetBool("HTTPS") doTls = viper.GetBool("HTTPS")
} }
@ -362,9 +671,14 @@ func main() {
r := gin.Default() r := gin.Default()
ldap := MyLdap{Host: ldapHost, User: ldapUser, Pass: ldapPass, BaseDN: ldapBaseDN} gRoLdap = &MyLdap{Host: ldapHost, User: ldapUser, Pass: ldapPass, BaseDN: ldapBaseDN, AuthBaseDN: ldapAuthBaseDN}
_, err := connectLdap(gRoLdap)
initRouter(r, &ldap) if err != nil {
log.Fatalf("Cannot connect to ldap: %v", err)
}
initRouter(r)
if doTls { if doTls {
r.RunTLS(listen, tlsCert, tlsPrivKey) r.RunTLS(listen, tlsCert, tlsPrivKey)