Compare commits

...

20 Commits

Author SHA1 Message Date
bf80945168 rename make testsd target to test 2024-10-24 18:08:43 +00:00
e18737c043 cleanup gitignore 2024-10-23 22:46:43 +00:00
162eda70b9 fix make PHONY targets 2024-10-23 18:58:06 +00:00
0d1dd540c0 update go.mod 2024-10-21 19:56:01 +00:00
b8b0504a70 add unit tests for various utility functions,
add integration test for LDAPClient,
add aiutomatic openldap configuration for testing through make,
add make targets for tests
improve make targets for build/clean,
update README with build and test instructions
2024-10-19 04:16:17 +00:00
99242b70a0 add starttls support,
add starttls option to config
2024-10-18 04:38:26 +00:00
fd84f9a991 Update README.md 2024-10-16 05:03:58 +00:00
0689ee46fd improve ModGroup to perform NOP 2024-10-15 21:34:34 +00:00
ca0832a010 update go mod 2024-10-14 22:21:44 +00:00
5d41b605b9 add better ldap response error handling 2024-10-14 22:21:10 +00:00
03177eb4d9 add mail attribute to user,
bump API version to 1.0.3
2024-10-12 22:33:34 +00:00
95ad75b20d go mod tidy 2024-10-10 20:59:01 +00:00
8cefdb0b01 update go version and dependencies 2024-10-10 20:57:29 +00:00
eacc349cac fix critical userPassword bug,
improve ldap user/group data handling
2024-10-05 00:08:58 +00:00
bf0596d385 add memberOf attribute to users,
bump version to 1.0.1
2024-07-26 01:25:46 +00:00
f11e5ccc31 fix default session cookie max age,
disable cgo in build
2024-07-18 20:22:12 +00:00
8f8f6bd1e8 add installation instructions to README 2024-07-06 03:11:30 +00:00
d41bca141c add version route 2024-07-06 02:46:10 +00:00
05e0c02fe8 rename config.template,json to template.config.json 2024-06-27 02:40:09 +00:00
alu
eea5b8599e Merge pull request 'Rewrite API in GO' (#1) from go-rewrite into main
Reviewed-on: #1
2024-06-21 23:33:29 +00:00
17 changed files with 1903 additions and 121 deletions

4
.gitignore vendored
View File

@ -1,3 +1,3 @@
**/go.sum go.sum
**/config.json
dist/* dist/*
**/config.json

View File

@ -1,9 +1,23 @@
build: clean .PHONY: build test clean dev-init dev-reinit
go build -ldflags="-s -w" -o dist/ .
test: clean build: clean
go run . @echo "======================== Building Binary ======================="
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
test: dev-reinit
@echo "======================== Running Tests ========================="
go test -v -cover -coverpkg=./app/ -coverprofile coverage ./test/
@echo "======================= Coverage Report ========================"
go tool cover -func=coverage
@rm -f coverage
clean: clean:
@echo "======================== Cleaning Project ======================"
go clean go clean
rm -f dist/* rm -f dist/*
dev-init:
@cd scripts; make dev-init
dev-reinit:
@cd scripts; make dev-reinit

View File

@ -0,0 +1,53 @@
# ProxmoxAAS LDAP - Simple REST API for LDAP
ProxmoxAAS LDAP provides a simple API for managing users and groups in a simplified LDAP server. Expected LDAP configuration can be initialized using [open-ldap-setup](https://git.tronnet.net/tronnet/open-ldap-setup).
## Installation
### Prerequisites
- Initialized LDAP server with the following configuration
- Structure
- Users: ou=people,...
- objectType: inetOrgPerson
- At least 1 user which is a member of admin group
- Groups: ou=groups,...
- objectType: groupOfNames
- At least 1 admin group
- Permissions:
- Admin group should have write access
- Users should have write access to own attributes (cn, sn, userPassword)
- Enable anonymous binding
- Load MemberOf Policy:
- olcMemberOfDangling: ignore
- olcMemberOfRefInt: TRUE
- olcMemberOfGroupOC: groupOfNames
- olcMemberOfMemberAD: member
- olcMemberOfMemberOfAD: memberOf
- Password Policy and TLS are recommended but not required
### Installation
1. Download `proxmoxaas-ldap` binary and `template.config.json` file from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases)
2. Rename `template.config.json` to `config.json` and modify:
- ldapURL: url to the ldap server ie. `ldap://ldap.domain.net`
- baseDN: base DN ie. `dc=domain,dc=net`
- sessionSecretKey: random value used to randomize cookie values, replace with any sufficiently large random string
3. Run the binary
## Building and Testing from Source
Building requires the go toolchain. Testing requires the go toolchain, make, and apt. Currently only supports Debian.
### Building from Source
1. Clone the repository
2. Run `go get` to get requirements
3. Run `make` to build the binary
### Testing Source
1. Clone the repository
2. Run `go get` to get requirements
3. Run `make dev-init` to install test requirements including openldap (slapd), ldap-utils, debconf-utils
4. Run `make tests` to run all tests

View File

@ -15,15 +15,22 @@ import (
) )
var LDAPSessions map[string]*LDAPClient var LDAPSessions map[string]*LDAPClient
var AppVersion = "1.0.5"
var APIVersion = "1.0.4"
func Run() { func Run() {
gob.Register(LDAPClient{}) gob.Register(LDAPClient{})
log.Printf("Starting ProxmoxAAS-LDAP version %s\n", APIVersion)
configPath := flag.String("config", "config.json", "path to config.json file") configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse() flag.Parse()
config := GetConfig(*configPath) config, err := GetConfig(*configPath)
log.Println("Initialized config from " + *configPath) if err != nil {
log.Fatal("Error when reading config file: ", err)
}
log.Printf("Read in config from %s\n", *configPath)
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
router := gin.Default() router := gin.Default()
@ -36,8 +43,14 @@ func Run() {
}) })
router.Use(sessions.Sessions(config.SessionCookieName, store)) router.Use(sessions.Sessions(config.SessionCookieName, store))
log.Printf("Started API router and cookie store (Name: %s Params: %+v)\n", config.SessionCookieName, config.SessionCookie)
LDAPSessions = make(map[string]*LDAPClient) LDAPSessions = make(map[string]*LDAPClient)
router.GET("/version", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"version": APIVersion, "app-version": AppVersion})
})
router.POST("/ticket", func(c *gin.Context) { router.POST("/ticket", func(c *gin.Context) {
var body Login var body Login
if err := c.ShouldBind(&body); err != nil { // bad request from binding if err := c.ShouldBind(&body); err != nil { // bad request from binding
@ -81,7 +94,7 @@ func Run() {
uuid := SessionUUID.(string) uuid := SessionUUID.(string)
delete(LDAPSessions, uuid) delete(LDAPSessions, uuid)
session.Options(sessions.Options{MaxAge: -1}) // set max age to -1 so it is deleted session.Options(sessions.Options{MaxAge: -1}) // set max age to -1 so it is deleted
_ = session.Save() session.Save()
c.JSON(http.StatusUnauthorized, gin.H{"auth": false}) c.JSON(http.StatusUnauthorized, gin.H{"auth": false})
}) })
@ -100,7 +113,7 @@ func Run() {
} }
status, res := LDAPSession.GetAllUsers() status, res := LDAPSession.GetAllUsers()
c.JSON(status, res) c.JSON(status, HandleResponse(res))
}) })
router.POST("/users/:userid", func(c *gin.Context) { router.POST("/users/:userid", func(c *gin.Context) {
@ -117,20 +130,24 @@ func Run() {
return return
} }
var body User
if err := c.ShouldBind(&body); err != nil { // bad request from binding
c.JSON(http.StatusBadRequest, gin.H{"auth": false, "error": err.Error()})
return
}
// check if user already exists // check if user already exists
status, res := LDAPSession.GetUser(c.Param("userid")) status, res := LDAPSession.GetUser(c.Param("userid"))
if status != 200 && ldap.IsErrorWithCode(res["error"].(error), ldap.LDAPResultNoSuchObject) { // user does not already exist, create new user if status != 200 && ldap.IsErrorWithCode(res["error"].(error), ldap.LDAPResultNoSuchObject) { // user does not already exist, create new user
var body UserRequired // all user attributes required for new users
if err := c.ShouldBind(&body); err != nil { // attempt to bind user data
c.JSON(http.StatusBadRequest, gin.H{"auth": false, "error": err.Error()})
return
}
status, res = LDAPSession.AddUser(c.Param("userid"), body) status, res = LDAPSession.AddUser(c.Param("userid"), body)
c.JSON(status, res) c.JSON(status, HandleResponse(res))
} else { // user already exists, attempt to modify user } else { // user already exists, attempt to modify user
var body UserOptional // all user attributes optional for new users
if err := c.ShouldBind(&body); err != nil { // attempt to bind user data
c.JSON(http.StatusBadRequest, gin.H{"auth": false, "error": err.Error()})
return
}
status, res = LDAPSession.ModUser(c.Param("userid"), body) status, res = LDAPSession.ModUser(c.Param("userid"), body)
c.JSON(status, res) c.JSON(status, HandleResponse(res))
} }
}) })
@ -149,7 +166,7 @@ func Run() {
} }
status, res := LDAPSession.GetUser(c.Param("userid")) status, res := LDAPSession.GetUser(c.Param("userid"))
c.JSON(status, res) c.JSON(status, HandleResponse(res))
}) })
router.DELETE("/users/:userid", func(c *gin.Context) { router.DELETE("/users/:userid", func(c *gin.Context) {
@ -167,7 +184,7 @@ func Run() {
} }
status, res := LDAPSession.DelUser(c.Param("userid")) status, res := LDAPSession.DelUser(c.Param("userid"))
c.JSON(status, res) c.JSON(status, HandleResponse(res))
}) })
router.GET("/groups", func(c *gin.Context) { router.GET("/groups", func(c *gin.Context) {
@ -185,7 +202,7 @@ func Run() {
} }
status, res := LDAPSession.GetAllGroups() status, res := LDAPSession.GetAllGroups()
c.JSON(status, res) c.JSON(status, HandleResponse(res))
}) })
router.GET("/groups/:groupid", func(c *gin.Context) { router.GET("/groups/:groupid", func(c *gin.Context) {
@ -203,7 +220,7 @@ func Run() {
} }
status, res := LDAPSession.GetGroup(c.Param("groupid")) status, res := LDAPSession.GetGroup(c.Param("groupid"))
c.JSON(status, res) c.JSON(status, HandleResponse(res))
}) })
router.POST("/groups/:groupid", func(c *gin.Context) { router.POST("/groups/:groupid", func(c *gin.Context) {
@ -226,14 +243,14 @@ func Run() {
return return
} }
// check if user already exists // check if group already exists
status, res := LDAPSession.GetGroup(c.Param("groupid")) status, res := LDAPSession.GetGroup(c.Param("groupid"))
if status != 200 && ldap.IsErrorWithCode(res["error"].(error), ldap.LDAPResultNoSuchObject) { // user does not already exist, create new user if status != 200 && ldap.IsErrorWithCode(res["error"].(error), ldap.LDAPResultNoSuchObject) { // group does not already exist, create new group
status, res = LDAPSession.AddGroup(c.Param("groupid"), body) status, res = LDAPSession.AddGroup(c.Param("groupid"), body)
c.JSON(status, res) c.JSON(status, HandleResponse(res))
} else { // user already exists, attempt to modify user } else { // group already exists, attempt to modify group
status, res = LDAPSession.ModGroup(c.Param("groupid"), body) status, res = LDAPSession.ModGroup(c.Param("groupid"), body)
c.JSON(status, res) c.JSON(status, HandleResponse(res))
} }
}) })
@ -252,7 +269,7 @@ func Run() {
} }
status, res := LDAPSession.DelGroup(c.Param("groupid")) status, res := LDAPSession.DelGroup(c.Param("groupid"))
c.JSON(status, res) c.JSON(status, HandleResponse(res))
}) })
router.POST("/groups/:groupid/members/:userid", func(c *gin.Context) { router.POST("/groups/:groupid/members/:userid", func(c *gin.Context) {
@ -270,7 +287,7 @@ func Run() {
} }
status, res := LDAPSession.AddUserToGroup(c.Param("userid"), c.Param("groupid")) status, res := LDAPSession.AddUserToGroup(c.Param("userid"), c.Param("groupid"))
c.JSON(status, res) c.JSON(status, HandleResponse(res))
}) })
router.DELETE("/groups/:groupid/members/:userid", func(c *gin.Context) { router.DELETE("/groups/:groupid/members/:userid", func(c *gin.Context) {
@ -288,8 +305,10 @@ func Run() {
} }
status, res := LDAPSession.DelUserFromGroup(c.Param("userid"), c.Param("groupid")) status, res := LDAPSession.DelUserFromGroup(c.Param("userid"), c.Param("groupid"))
c.JSON(status, res) c.JSON(status, HandleResponse(res))
}) })
log.Printf("Starting LDAP API on port %s\n", strconv.Itoa(config.ListenPort))
router.Run("0.0.0.0:" + strconv.Itoa(config.ListenPort)) router.Run("0.0.0.0:" + strconv.Itoa(config.ListenPort))
} }

View File

@ -1,6 +1,8 @@
package app package app
import ( import (
"crypto/tls"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -8,6 +10,7 @@ import (
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
) )
// LDAPClient wrapper struct containing the connection, baseDN, peopleDN, and groupsDN
type LDAPClient struct { type LDAPClient struct {
client *ldap.Conn client *ldap.Conn
basedn string basedn string
@ -15,8 +18,20 @@ type LDAPClient struct {
groupsdn string groupsdn string
} }
// returns a new LDAPClient from the config
func NewLDAPClient(config Config) (*LDAPClient, error) { func NewLDAPClient(config Config) (*LDAPClient, error) {
LDAPConn, err := ldap.DialURL(config.LdapURL) LDAPConn, err := ldap.DialURL(config.LdapURL)
if err != nil {
return nil, err
}
if config.StartTLS {
err = LDAPConn.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, err
}
}
return &LDAPClient{ return &LDAPClient{
client: LDAPConn, client: LDAPConn,
basedn: config.BaseDN, basedn: config.BaseDN,
@ -25,6 +40,7 @@ func NewLDAPClient(config Config) (*LDAPClient, error) {
}, err }, err
} }
// bind a user using username and password to the LDAPClient
func (l LDAPClient) BindUser(username string, password string) error { func (l LDAPClient) BindUser(username string, password string) error {
userdn := fmt.Sprintf("uid=%s,%s", username, l.peopledn) userdn := fmt.Sprintf("uid=%s,%s", username, l.peopledn)
return l.client.Bind(userdn, password) return l.client.Bind(userdn, password)
@ -34,8 +50,8 @@ func (l LDAPClient) GetAllUsers() (int, gin.H) {
searchRequest := ldap.NewSearchRequest( searchRequest := ldap.NewSearchRequest(
l.peopledn, // The base dn to search l.peopledn, // The base dn to search
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(&(objectClass=inetOrgPerson))", // The filter to apply "(&(objectClass=inetOrgPerson))", // The filter to apply
[]string{"dn", "cn", "sn", "mail", "uid"}, // A list attributes to retrieve []string{"dn", "cn", "sn", "mail", "uid", "memberOf"}, // A list attributes to retrieve
nil, nil,
) )
@ -50,15 +66,8 @@ func (l LDAPClient) GetAllUsers() (int, gin.H) {
var results = []gin.H{} // create list of results var results = []gin.H{} // create list of results
for _, entry := range searchResponse.Entries { // for each result, for _, entry := range searchResponse.Entries { // for each result,
results = append(results, gin.H{ user := LDAPEntryToLDAPUser(entry)
"dn": entry.DN, results = append(results, LDAPUserToGin(user))
"attributes": gin.H{
"cn": entry.GetAttributeValue("cn"),
"sn": entry.GetAttributeValue("sn"),
"mail": entry.GetAttributeValue("mail"),
"uid": entry.GetAttributeValue("uid"),
},
})
} }
return http.StatusOK, gin.H{ return http.StatusOK, gin.H{
@ -68,11 +77,43 @@ func (l LDAPClient) GetAllUsers() (int, gin.H) {
} }
} }
func (l LDAPClient) AddUser(uid string, user User) (int, gin.H) { func (l LDAPClient) GetUser(uid string) (int, gin.H) {
if user.CN == "" || user.SN == "" || user.UserPassword == "" { searchRequest := ldap.NewSearchRequest( // setup search for user by uid
fmt.Sprintf("uid=%s,%s", uid, l.peopledn), // The base dn to search
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(&(objectClass=inetOrgPerson))", // The filter to apply
[]string{"dn", "cn", "sn", "mail", "uid", "memberOf"}, // A list attributes to retrieve
nil,
)
searchResponse, err := l.client.Search(searchRequest) // perform search
if err != nil {
return http.StatusBadRequest, gin.H{ return http.StatusBadRequest, gin.H{
"ok": false, "ok": false,
"error": "Missing one of required fields: cn, sn, userpassword", "error": err,
}
}
entry := searchResponse.Entries[0]
user := LDAPEntryToLDAPUser(entry)
result := LDAPUserToGin(user)
return http.StatusOK, gin.H{
"ok": true,
"error": nil,
"user": result,
}
}
func (l LDAPClient) AddUser(uid string, user UserRequired) (int, gin.H) {
if user.CN == "" || user.SN == "" || user.UserPassword == "" || user.Mail == "" {
return http.StatusBadRequest, gin.H{
"ok": false,
"error": ldap.NewError(
ldap.LDAPResultUnwillingToPerform,
errors.New("missing one of required fields: cn, sn, mail, userpassword"),
),
} }
} }
@ -82,7 +123,8 @@ func (l LDAPClient) AddUser(uid string, user User) (int, gin.H) {
) )
addRequest.Attribute("sn", []string{user.SN}) addRequest.Attribute("sn", []string{user.SN})
addRequest.Attribute("cn", []string{user.CN}) addRequest.Attribute("cn", []string{user.CN})
addRequest.Attribute("userPassword", []string{user.CN}) addRequest.Attribute("mail", []string{user.Mail})
addRequest.Attribute("userPassword", []string{user.UserPassword})
addRequest.Attribute("objectClass", []string{"inetOrgPerson"}) addRequest.Attribute("objectClass", []string{"inetOrgPerson"})
err := l.client.Add(addRequest) err := l.client.Add(addRequest)
@ -99,46 +141,14 @@ func (l LDAPClient) AddUser(uid string, user User) (int, gin.H) {
} }
} }
func (l LDAPClient) GetUser(uid string) (int, gin.H) { func (l LDAPClient) ModUser(uid string, user UserOptional) (int, gin.H) {
searchRequest := ldap.NewSearchRequest( // setup search for user by uid if user.CN == "" && user.SN == "" && user.UserPassword == "" && user.Mail == "" {
fmt.Sprintf("uid=%s,%s", uid, l.peopledn), // The base dn to search
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(&(objectClass=inetOrgPerson))", // The filter to apply
[]string{"dn", "cn", "sn", "mail", "uid"}, // A list attributes to retrieve
nil,
)
searchResponse, err := l.client.Search(searchRequest) // perform search
if err != nil {
return http.StatusBadRequest, gin.H{ return http.StatusBadRequest, gin.H{
"ok": false, "ok": false,
"error": err, "error": ldap.NewError(
} ldap.LDAPResultUnwillingToPerform,
} errors.New("requires one of fields: cn, sn, mail, userpassword"),
),
entry := searchResponse.Entries[0]
result := gin.H{
"dn": entry.DN,
"attributes": gin.H{
"cn": entry.GetAttributeValue("cn"),
"sn": entry.GetAttributeValue("sn"),
"mail": entry.GetAttributeValue("mail"),
"uid": entry.GetAttributeValue("uid"),
},
}
return http.StatusOK, gin.H{
"ok": true,
"error": nil,
"user": result,
}
}
func (l LDAPClient) ModUser(uid string, user User) (int, gin.H) {
if user.CN == "" && user.SN == "" && user.UserPassword == "" {
return http.StatusBadRequest, gin.H{
"ok": false,
"error": "Requires one of fields: cn, sn, userpassword",
} }
} }
@ -152,6 +162,9 @@ func (l LDAPClient) ModUser(uid string, user User) (int, gin.H) {
if user.SN != "" { if user.SN != "" {
modifyRequest.Replace("sn", []string{user.SN}) modifyRequest.Replace("sn", []string{user.SN})
} }
if user.Mail != "" {
modifyRequest.Replace("mail", []string{user.Mail})
}
if user.UserPassword != "" { if user.UserPassword != "" {
modifyRequest.Replace("userPassword", []string{user.UserPassword}) modifyRequest.Replace("userPassword", []string{user.UserPassword})
} }
@ -214,13 +227,8 @@ func (l LDAPClient) GetAllGroups() (int, gin.H) {
var results = []gin.H{} // create list of results var results = []gin.H{} // create list of results
for _, entry := range searchResponse.Entries { // for each result, for _, entry := range searchResponse.Entries { // for each result,
results = append(results, gin.H{ group := LDAPEntryToLDAPGroup(entry)
"dn": entry.DN, results = append(results, LDAPGroupToGin(group))
"attributes": gin.H{
"cn": entry.GetAttributeValue("cn"),
"member": entry.GetAttributeValues("member"),
},
})
} }
return http.StatusOK, gin.H{ return http.StatusOK, gin.H{
@ -248,13 +256,8 @@ func (l LDAPClient) GetGroup(gid string) (int, gin.H) {
} }
entry := searchResponse.Entries[0] entry := searchResponse.Entries[0]
result := gin.H{ group := LDAPEntryToLDAPGroup(entry)
"dn": entry.DN, result := LDAPGroupToGin(group)
"attributes": gin.H{
"cn": entry.GetAttributeValue("cn"),
"member": entry.GetAttributeValues("member"),
},
}
return http.StatusOK, gin.H{ return http.StatusOK, gin.H{
"ok": true, "ok": true,
@ -287,7 +290,22 @@ func (l LDAPClient) AddGroup(gid string, group Group) (int, gin.H) {
} }
func (l LDAPClient) ModGroup(gid string, group Group) (int, gin.H) { func (l LDAPClient) ModGroup(gid string, group Group) (int, gin.H) {
return 200, gin.H{ modifyRequest := ldap.NewModifyRequest(
fmt.Sprintf("cn=%s,%s", gid, l.groupsdn),
nil,
)
modifyRequest.Replace("cn", []string{gid})
err := l.client.Modify(modifyRequest)
if err != nil {
return http.StatusBadRequest, gin.H{
"ok": false,
"error": err,
}
}
return http.StatusOK, gin.H{
"ok": true, "ok": true,
"error": nil, "error": nil,
} }

View File

@ -2,13 +2,16 @@ package app
import ( import (
"encoding/json" "encoding/json"
"log"
"os" "os"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
) )
type Config struct { type Config struct {
ListenPort int `json:"listenPort"` ListenPort int `json:"listenPort"`
LdapURL string `json:"ldapURL"` LdapURL string `json:"ldapURL"`
StartTLS bool `json:"startTLS"`
BaseDN string `json:"baseDN"` BaseDN string `json:"baseDN"`
SessionSecretKey string `json:"sessionSecretKey"` SessionSecretKey string `json:"sessionSecretKey"`
SessionCookieName string `json:"sessionCookieName"` SessionCookieName string `json:"sessionCookieName"`
@ -20,17 +23,17 @@ type Config struct {
} }
} }
func GetConfig(configPath string) Config { func GetConfig(configPath string) (Config, error) {
content, err := os.ReadFile(configPath) content, err := os.ReadFile(configPath)
if err != nil { if err != nil {
log.Fatal("Error when opening config file: ", err) return Config{}, err
} }
var config Config var config Config
err = json.Unmarshal(content, &config) err = json.Unmarshal(content, &config)
if err != nil { if err != nil {
log.Fatal("Error during parsing config file: ", err) return Config{}, err
} }
return config return config, nil
} }
type Login struct { // login body struct type Login struct { // login body struct
@ -38,11 +41,103 @@ type Login struct { // login body struct
Password string `form:"password" binding:"required"` Password string `form:"password" binding:"required"`
} }
type User struct { // add or modify user body struct type LDAPUserAttributes struct {
CN string
SN string
Mail string
UID string
MemberOf []string
}
type LDAPUser struct {
DN string
Attributes LDAPUserAttributes
}
func LDAPEntryToLDAPUser(entry *ldap.Entry) LDAPUser {
return LDAPUser{
DN: entry.DN,
Attributes: LDAPUserAttributes{
CN: entry.GetAttributeValue("cn"),
SN: entry.GetAttributeValue("sn"),
Mail: entry.GetAttributeValue("mail"),
UID: entry.GetAttributeValue("uid"),
MemberOf: entry.GetAttributeValues("memberOf"),
},
}
}
func LDAPUserToGin(user LDAPUser) gin.H {
return gin.H{
"dn": user.DN,
"attributes": gin.H{
"cn": user.Attributes.CN,
"sn": user.Attributes.SN,
"mail": user.Attributes.Mail,
"uid": user.Attributes.UID,
"memberOf": user.Attributes.MemberOf,
},
}
}
type LDAPGroupAttributes struct {
CN string
Member []string
}
type LDAPGroup struct {
DN string
Attributes LDAPGroupAttributes
}
func LDAPEntryToLDAPGroup(entry *ldap.Entry) LDAPGroup {
return LDAPGroup{
DN: entry.DN,
Attributes: LDAPGroupAttributes{
CN: entry.GetAttributeValue("cn"),
Member: entry.GetAttributeValues("member"),
},
}
}
func LDAPGroupToGin(group LDAPGroup) gin.H {
return gin.H{
"dn": group.DN,
"attributes": gin.H{
"cn": group.Attributes.CN,
"member": group.Attributes.Member,
},
}
}
type UserOptional struct { // add or modify user body struct
CN string `form:"cn"` CN string `form:"cn"`
SN string `form:"sn"` SN string `form:"sn"`
Mail string `form:"mail"`
UserPassword string `form:"userpassword"` UserPassword string `form:"userpassword"`
} }
type UserRequired struct { // add or modify user body struct
CN string `form:"cn" binding:"required"`
SN string `form:"sn" binding:"required"`
Mail string `form:"mail" binding:"required"`
UserPassword string `form:"userpassword" binding:"required"`
}
type Group struct { // add or modify group body struct type Group struct { // add or modify group body struct
} }
func HandleResponse(response gin.H) gin.H {
if response["error"] != nil {
err := response["error"].(error)
LDAPerr := err.(*ldap.Error)
response["error"] = gin.H{
"code": LDAPerr.ResultCode,
"result": ldap.LDAPResultCodeMap[LDAPerr.ResultCode],
"message": LDAPerr.Err.Error(),
}
return response
} else {
return response
}
}

View File

@ -1,6 +1,7 @@
{ {
"listenPort": 80, "listenPort": 80,
"ldapURL": "ldap://localhost", "ldapURL": "ldap://localhost",
"startTLS": true,
"basedn": "dc=example,dc=com", "basedn": "dc=example,dc=com",
"sessionSecretKey": "super secret key", "sessionSecretKey": "super secret key",
"sessionCookieName": "PAASLDAPAuthTicket", "sessionCookieName": "PAASLDAPAuthTicket",
@ -8,6 +9,6 @@
"path": "/", "path": "/",
"httpOnly": true, "httpOnly": true,
"secure": false, "secure": false,
"maxAge": 7200000 "maxAge": 7200
} }
} }

28
go.mod
View File

@ -1,6 +1,6 @@
module proxmoxaas-ldap module proxmoxaas-ldap
go 1.22.4 go 1.23.2
require ( require (
github.com/gin-contrib/sessions v1.0.1 github.com/gin-contrib/sessions v1.0.1
@ -11,35 +11,37 @@ require (
require ( require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.11.8 // indirect github.com/bytedance/sonic v1.12.3 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/go-playground/validator/v10 v10.22.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect github.com/goccy/go-json v0.10.3 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.3.0 // indirect github.com/gorilla/sessions v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/knz/go-libedit v1.10.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.24.0 // indirect golang.org/x/crypto v0.28.0 // indirect
golang.org/x/net v0.26.0 // indirect golang.org/x/net v0.30.0 // indirect
golang.org/x/sys v0.21.0 // indirect golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

18
scripts/Makefile Normal file
View File

@ -0,0 +1,18 @@
.PHONY: prerequisites dev-init dev-reinit
prerequisites:
@echo "=================== Installing Prerequisites ==================="
apt install debconf-utils slapd ldap-utils sudo gettext
git clone https://git.tronnet.net/tronnet/open-ldap-setup
cd open-ldap-setup/; bash gencert.sh < ../gencert.conf;
rm -rf open-ldap-setup/
dev-init: prerequisites dev-reinit
dev-reinit:
@echo "====================== Initializing Slapd ======================"
cat debconf-slapd.conf | debconf-set-selections
DEBIAN_FRONTEND=noninteractive dpkg-reconfigure slapd
git clone https://git.tronnet.net/tronnet/open-ldap-setup
cd open-ldap-setup/; bash setup.sh < ../setup.conf;
rm -rf open-ldap-setup/

View File

@ -0,0 +1,16 @@
slapd slapd/password1 password admin
slapd slapd/internal/adminpw password admin
slapd slapd/internal/generated_adminpw password admin
slapd slapd/password2 password admin
slapd slapd/unsafe_selfwrite_acl note
slapd slapd/purge_database boolean true
slapd slapd/domain string test.paasldap
slapd slapd/ppolicy_schema_needs_update select abort installation
slapd slapd/invalid_config boolean true
slapd slapd/move_old_database boolean true
slapd slapd/backend select MDB
slapd shared/organization string paasldap
slapd slapd/dump_database_destdir string /var/backups/slapd-VERSION
slapd slapd/no_configuration boolean false
slapd slapd/dump_database select when needed
slapd slapd/password_mismatch note

2
scripts/gencert.conf Normal file
View File

@ -0,0 +1,2 @@
paasldap
localhost

10
scripts/setup.conf Normal file
View File

@ -0,0 +1,10 @@
dc=test,dc=paasldap
adminuser
adminuser@test.paasldap
admin
user
admin123
admin123
/etc/ssl/certs/ldap-ca-cert.pem
/etc/ldap/ldap-server-cert.pem
/etc/ldap/ldap-server-key.pem

1
test/bad_config.json Normal file
View File

@ -0,0 +1 @@
{,,}

1237
test/integration_test.go Normal file

File diff suppressed because it is too large Load Diff

14
test/test_config.json Normal file
View File

@ -0,0 +1,14 @@
{
"listenPort": 80,
"ldapURL": "ldap://localhost",
"startTLS": true,
"basedn": "dc=test,dc=paasldap",
"sessionSecretKey": "test",
"sessionCookieName": "PAASLDAPAuthTicket",
"sessionCookie": {
"path": "/",
"httpOnly": true,
"secure": false,
"maxAge": 7200
}
}

156
test/test_utils.go Normal file
View File

@ -0,0 +1,156 @@
package tests
import (
"fmt"
"math/rand"
"net/http"
"proxmoxaas-ldap/app"
"reflect"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
)
func RandInt(min int, max int) int {
return rand.Intn(max+1-min) + min
}
func RandString(n int) string {
var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
// random ldap style DN
func RandDN(n int) string {
return fmt.Sprintf("cn=%s,ou=%s,dc=%s,dc=%s", RandString(n), RandString(n), RandString(n), RandString(n))
}
// typically for testing values of a variable
func AssertEquals[T comparable](t *testing.T, label string, a T, b T) {
t.Helper()
if a != b {
t.Errorf(`%s = %#v; expected %#v.`, label, a, b)
}
}
// asserting the success or failure of a generic error
func AssertError(t *testing.T, label string, gotErr error, expectErr error) {
t.Helper()
if gotErr != nil && expectErr != nil {
if gotErr.Error() != expectErr.Error() {
t.Errorf(`%s returned %s; expected %s`, label, gotErr.Error(), expectErr.Error())
}
} else {
if gotErr != expectErr {
t.Errorf(`%s returned %s; expected %s`, label, gotErr.Error(), expectErr.Error())
}
}
}
// typically for asserting the success or failure of an ldap result
func AssertLDAPError(t *testing.T, label string, gotErr any, expectErrCode uint16) {
t.Helper()
expectError := ldap.LDAPResultCodeMap[expectErrCode]
if expectErrCode == ldap.LDAPResultSuccess { // expect success
if gotErr != nil { // got an error
gotErr := gotErr.(error)
t.Errorf(`%s returned %s; expected %s.`, label, gotErr.Error(), "success")
} // did not get an error
} else { // expect error
if gotErr == nil { // did not get an error
t.Errorf(`%s returned %s; expected %s.`, label, "success", expectError)
return
}
gotErr := gotErr.(error)
if !ldap.IsErrorWithCode(gotErr, expectErrCode) { // got an error that does not match the expected error
t.Errorf(`%s returned %s; expected %s.`, label, gotErr.Error(), expectError)
} // got the expected error
}
}
// typically for asserting the success or failure of an http result
func AssertStatus(t *testing.T, label string, gotCode int, expectCode int) {
t.Helper()
if expectCode == http.StatusOK {
if gotCode != http.StatusOK { // got an error
t.Errorf(`%s returned %d; expected %d.`, label, gotCode, expectCode)
}
} else { // expect error
if gotCode == http.StatusOK { // did not get an error
t.Errorf(`%s returned %d; expected %d.`, label, gotCode, expectCode)
} else if gotCode != expectCode { // got an error that does not match the expected error
t.Errorf(`%s returned %d; expected %d.`, label, gotCode, expectCode)
}
}
}
// compare if two users are equal, accepts LDAPUser or gin.H
func AssertLDAPUserEquals(t *testing.T, label string, a any, b app.LDAPUser) {
t.Helper()
aObj, ok := a.(app.LDAPUser)
if ok {
if !reflect.DeepEqual(aObj, b) {
t.Errorf(`%s = %#v; expected %#v.`, label, aObj, b)
}
return
}
aGin, ok := a.(gin.H)
if ok {
bGin := app.LDAPUserToGin(b)
if !reflect.DeepEqual(aGin, bGin) {
t.Errorf(`%s = %#v; expected %#v.`, label, aGin, bGin)
}
return
}
// not a supported type
t.Errorf(`%s = %#v; expected %#v.`, label, a, b)
}
// compare if two users are equal, accepts LDAPUser or gin.H
func AssertLDAPGroupEquals(t *testing.T, label string, a any, b app.LDAPGroup) {
t.Helper()
aObj, ok := a.(app.LDAPGroup)
if ok {
if !reflect.DeepEqual(aObj, b) {
t.Errorf(`%s = %#v; expected %#v.`, label, aObj, b)
}
return
}
aGin, ok := a.(gin.H)
if ok {
bGin := app.LDAPGroupToGin(b)
if !reflect.DeepEqual(aGin, bGin) {
t.Errorf(`%s = %#v; expected %#v.`, label, aGin, bGin)
}
return
}
// not a supported type
t.Errorf(`%s = %#v; expected %#v.`, label, a, b)
}
var _config, _ = app.GetConfig("test_config.json")
var BaseDN = _config.BaseDN
var PeopleDN = fmt.Sprintf("ou=people,%s", BaseDN)
var GroupDN = fmt.Sprintf("ou=groups,%s", BaseDN)
type User struct {
username string
password string
userObj app.LDAPUser
}
type Group struct {
groupname string
groupObj app.LDAPGroup
}

126
test/unit_test.go Normal file
View File

@ -0,0 +1,126 @@
package tests
import (
"errors"
"fmt"
app "proxmoxaas-ldap/app"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
)
// test the GetConfig utility function because it used in other tests
func TestConfig_ValidPath(t *testing.T) {
config, err := app.GetConfig("test_config.json")
AssertError(t, "GetConfig()", err, nil)
AssertEquals(t, "config.ListenPort", config.ListenPort, 80)
AssertEquals(t, "config.LdapURL", config.LdapURL, "ldap://localhost")
AssertEquals(t, "config.BaseDN", config.BaseDN, "dc=test,dc=paasldap")
AssertEquals(t, "config.SessionSecretKey", config.SessionSecretKey, "test")
AssertEquals(t, "config.SessionCookieName", config.SessionCookieName, "PAASLDAPAuthTicket")
AssertEquals(t, "config.SessionCookie.Path", config.SessionCookie.Path, "/")
AssertEquals(t, "config.SessionCookie.HttpOnly", config.SessionCookie.HttpOnly, true)
AssertEquals(t, "config.SessionCookie.Secure", config.SessionCookie.Secure, false)
AssertEquals(t, "config.SessionCookie.MaxAge", config.SessionCookie.MaxAge, 7200)
}
func TestConfig_InvalidPath(t *testing.T) {
badFileName := RandString(16)
_, err := app.GetConfig(badFileName)
expectedErr := fmt.Errorf("open %s: no such file or directory", badFileName)
AssertError(t, "GetConfig()", err, expectedErr)
_, err = app.GetConfig("bad_config.json")
expectedErr = fmt.Errorf("invalid character ',' looking for beginning of object key string")
AssertError(t, "GetConfig()", err, expectedErr)
}
// test the LDAPEntryToUser and LDAPUserToGin utility functions
func TestLDAPUserDataPipeline(t *testing.T) {
var memberOf []string
for i := 0; i < RandInt(5, 20); i++ {
memberOf = append(memberOf, RandDN(16))
}
expectedUser := app.LDAPUser{
DN: RandDN(16),
Attributes: app.LDAPUserAttributes{
CN: RandString(16),
SN: RandString(16),
Mail: RandString(16),
UID: RandString(16),
MemberOf: memberOf,
},
}
attributes := make(map[string][]string)
attributes["cn"] = []string{expectedUser.Attributes.CN}
attributes["sn"] = []string{expectedUser.Attributes.SN}
attributes["mail"] = []string{expectedUser.Attributes.Mail}
attributes["uid"] = []string{expectedUser.Attributes.UID}
attributes["memberOf"] = expectedUser.Attributes.MemberOf
entry := ldap.NewEntry(expectedUser.DN, attributes)
user := app.LDAPEntryToLDAPUser(entry)
AssertLDAPUserEquals(t, "LDAPEntryToLDAPUser(entry) -> user", user, expectedUser)
json := app.LDAPUserToGin(user)
AssertLDAPUserEquals(t, "LDAPUserToGin(user) -> json", json, expectedUser)
}
// test the LDAPEntryToGroup and LDAPGroupToGin utility functions
func TestLDAPGroupDataPipeline(t *testing.T) {
var member []string
for i := 0; i < RandInt(5, 20); i++ {
member = append(member, RandDN(16))
}
expectedGroup := app.LDAPGroup{
DN: RandDN(16),
Attributes: app.LDAPGroupAttributes{
Member: member,
},
}
attributes := make(map[string][]string)
attributes["member"] = expectedGroup.Attributes.Member
entry := ldap.NewEntry(expectedGroup.DN, attributes)
group := app.LDAPEntryToLDAPGroup(entry)
AssertLDAPGroupEquals(t, "LDAPEntryToLDAPGroup(entry) -> group", group, expectedGroup)
json := app.LDAPGroupToGin(group)
AssertLDAPGroupEquals(t, "LDAPGroupToGin(group) -> json", json, expectedGroup)
}
func TestHandleResponse(t *testing.T) {
for errorCode := range ldap.LDAPResultCodeMap {
expectedMessage := RandString(16)
LDAPerr := ldap.NewError(errorCode, errors.New(expectedMessage))
res := gin.H{
"error": LDAPerr,
}
LDAPResult := ldap.LDAPResultCodeMap[errorCode]
handledResponseError := (app.HandleResponse(res))["error"].(gin.H)
AssertEquals(t, `HandleResponse(res)["error"]["code"]`, handledResponseError["code"].(uint16), errorCode)
AssertEquals(t, `HandleResponse(res)["error"]["result"]`, handledResponseError["result"].(string), LDAPResult)
AssertEquals(t, `HandleResponse(res)["error"]["message"]`, handledResponseError["message"].(string), expectedMessage)
}
res := gin.H{
"ok": true,
"status": RandInt(0, 600),
}
handledResponse := app.HandleResponse(res)
AssertEquals(t, `HandleResponse(res)["ok"]`, handledResponse["ok"].(bool), res["ok"].(bool))
AssertEquals(t, `HandleResponse(res)["satus"]`, handledResponse["status"].(int), res["status"].(int))
AssertEquals(t, `HandleResponse(res)["error"]`, handledResponse["error"], nil)
}