glapi/main.go

705 lines
19 KiB
Go

// Go Ldap Api
// Copyright (c) 2022 yo000 <johan@nosd.in>
//
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)
}
}