glapi/main.go

626 lines
17 KiB
Go
Raw Normal View History

2022-10-08 18:52:11 +02:00
// Go Ldap Api
// Copyright (c) 2022 yo000 <johan@nosd.in>
//
package main
import (
"os"
"fmt"
"flag"
"time"
"errors"
2022-10-08 18:52:11 +02:00
"strings"
"net/http"
2022-10-08 18:52:11 +02:00
"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"
2022-10-08 18:52:11 +02:00
)
var (
gVersion = "0.5.4"
gRoLdap *MyLdap
2022-10-08 18:52:11 +02:00
)
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"})
2022-10-08 18:52:11 +02:00
} else {
c.String(http.StatusNotFound, "No result")
2022-10-08 18:52:11 +02:00
}
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))
2022-10-08 18:52:11 +02:00
} else if strings.EqualFold(format, "text") {
txtRes := marshalResultToText(res, "=", false, true)
log.Debugf("%v\n", string(txtRes))
c.String(http.StatusOK, string(txtRes))
2022-10-08 18:52:11 +02:00
} else if strings.EqualFold(format, "ldif") {
txtRes := marshalResultToText(res, ": ", false, true)
log.Debugf("%v\n", string(txtRes))
c.String(http.StatusOK, string(txtRes))
2022-10-08 18:52:11 +02:00
} else if strings.EqualFold(format, "textvalue") {
txtRes := marshalResultToText(res, "", true, true)
log.Debugf("%v\n", string(txtRes))
c.String(http.StatusOK, string(txtRes))
2022-10-08 18:52:11 +02:00
} else if strings.EqualFold(format, "textvalue-nodn") {
txtRes := marshalResultToText(res, "", true, false)
log.Debugf("%v\n", string(txtRes))
c.String(http.StatusOK, string(txtRes))
2022-10-08 18:52:11 +02:00
}
}
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)
2022-11-12 20:45:02 +01:00
res, err := searchByCn(myldap, baseDn, cn, class, "modifyTimestamp")
2022-10-08 18:52:11 +02:00
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
2022-10-08 18:52:11 +02:00
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)
2022-10-08 18:52:11 +02:00
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) {
2022-10-08 18:52:11 +02:00
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
2022-10-08 18:52:11 +02:00
"message": "pong",
})
return
2022-10-08 18:52:11 +02:00
})
// All following routes need authentication
r.GET("/:ou/:cn/:class", ldapBasicAuth, func(c *gin.Context) {
2022-10-08 18:52:11 +02:00
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
}
2022-10-08 18:52:11 +02:00
// json format is the default
format := c.DefaultQuery("format", "json")
2022-11-12 20:45:02 +01:00
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 {
log.Errorf("Error searching %s in %s : %v", cn, ou, err)
c.AbortWithError(http.StatusInternalServerError, err)
2022-10-08 18:52:11 +02:00
return
}
sendResponse(c, res, format)
return
2022-10-08 18:52:11 +02:00
})
r.HEAD("/:ou/:cn/:class", ldapBasicAuth, func(c *gin.Context) {
2022-10-08 18:52:11 +02:00
ou := c.Param("ou")
cn := c.Param("cn")
class := c.Param("class")
2022-10-08 18:52:11 +02:00
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, "ALL")
2022-10-08 18:52:11 +02:00
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
2022-10-08 18:52:11 +02:00
return
}
if modified {
2022-11-12 20:45:02 +01:00
res, err := searchByCn(ldapCon, ou, cn, class, "ALL")
2022-10-08 18:52:11 +02:00
if err != nil {
log.Errorf("Error searching %s in %s : %v", cn, ou, err)
c.AbortWithError(http.StatusInternalServerError, err)
2022-10-08 18:52:11 +02:00
return
}
sendResponse(c, res, format)
} else {
c.String(http.StatusNotModified, "")
2022-10-08 18:52:11 +02:00
}
return
})
r.GET("/:ou/:cn/:class/:attribute", ldapBasicAuth, func(c *gin.Context) {
2022-10-08 18:52:11 +02:00
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
}
2022-10-08 18:52:11 +02:00
2022-11-12 20:45:02 +01:00
res, err := searchByCn(ldapCon, ou, cn, class, attr)
2022-10-08 18:52:11 +02:00
if err != nil {
log.Errorf("Error searching %s in %s : %v", cn, ou, err)
c.AbortWithError(http.StatusInternalServerError, err)
2022-10-08 18:52:11 +02:00
return
}
sendResponse(c, res, format)
return
})
r.HEAD("/:ou/:cn/:class/:attribute", ldapBasicAuth, func(c *gin.Context) {
2022-10-08 18:52:11 +02:00
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)
2022-10-08 18:52:11 +02:00
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
2022-10-08 18:52:11 +02:00
return
}
if modified {
2022-11-12 20:45:02 +01:00
res, err := searchByCn(ldapCon, ou, cn, class, attr)
2022-10-08 18:52:11 +02:00
if err != nil {
log.Errorf("Error searching %s in %s : %v", cn, ou, err)
c.AbortWithError(http.StatusInternalServerError, err)
2022-10-08 18:52:11 +02:00
return
}
sendResponse(c, res, format)
} else {
c.String(http.StatusNotModified, "")
2022-10-08 18:52:11 +02:00
}
return
})
2022-11-12 20:45:02 +01:00
/* 2 call methods : Either DN in url, or DN in body using /add :
* * curl -u "admin:admin" -H "Content-Type: application/json" -X POST
2022-11-12 20:45:02 +01:00
* --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
2022-11-12 20:45:02 +01:00
*/
r.POST("/:dn", ldapBasicAuth, func(c *gin.Context) {
dn := c.Param("dn")
2022-11-12 20:45:02 +01:00
// 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
}
2022-11-12 20:45:02 +01:00
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
2022-11-12 20:45:02 +01:00
} 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
*/
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
}
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 :
2022-11-12 20:45:02 +01:00
* 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
2022-11-12 20:45:02 +01:00
*
* 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
}
2022-11-12 20:45:02 +01:00
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
}
}
2022-11-12 20:45:02 +01:00
})
2022-10-08 18:52:11 +02:00
}
func main() {
var confFile string
var listen string
var ldapHost string
var ldapUser string
var ldapPass string
var ldapBaseDN string
2022-10-09 09:16:23 +02:00
var tlsPrivKey string
var tlsCert string
var doTls bool
2022-10-08 18:52:11 +02:00
var debug 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")
2022-10-08 18:52:11 +02:00
flag.StringVar(&ldapPass, "ldap-pass", "", "ldap password")
flag.StringVar(&ldapBaseDN, "ldap-base-dn", "", "ldap base DN")
2022-10-09 09:16:23 +02:00
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)")
2022-10-08 18:52:11 +02:00
flag.BoolVar(&debug, "debug", false, "Set log level to debug")
flag.Parse()
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")
}
2022-10-08 18:52:11 +02:00
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!")
}
}
2022-10-09 09:16:23 +02:00
if false == doTls {
doTls = viper.GetBool("HTTPS")
2022-10-09 09:16:23 +02:00
}
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!")
}
}
2022-10-08 18:52:11 +02:00
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}
_, err := connectLdap(gRoLdap)
if err != nil {
log.Fatalf("Cannot connect to ldap: %v", err)
}
initRouter(r)
2022-10-08 18:52:11 +02:00
2022-10-09 09:16:23 +02:00
if doTls {
r.RunTLS(listen, tlsCert, tlsPrivKey)
} else {
r.Run(listen)
}
2022-10-08 18:52:11 +02:00
}