Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 11 additions & 63 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import (
"fmt"
"log"
"net/http"
"os"
"strings"

"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
)

// struct to hold username and password received from post request
Expand All @@ -35,77 +33,27 @@ func LoginHandler(c *gin.Context) {
return
}

// LDAP stuff
ldapServer := os.Getenv("LDAP_SERVER")
baseDN := os.Getenv("LDAP_BASE_DN")
bindDN := os.Getenv("LDAP_BIND_DN")
bindPassword := os.Getenv("LDAP_BIND_PASSWORD")

// for deployment debugging purposes
if ldapServer == "" || baseDN == "" || bindDN == "" || bindPassword == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "LDAP configuration is missing"})
return
}

// connect to LDAP server
l, err := ldap.DialURL("ldap://" + ldapServer + ":389")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("LDAP bind to %s failed", ldapServer)})
return
}

// make sure connection closes at function return even if error occurs
defer l.Close()

// First bind as service account
err = l.Bind(bindDN, bindPassword)
// Connect to LDAP
ldapConn, err := ConnectToLDAP()
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid ldap service account"})
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("LDAP connection failed: %v", err)})
return
}
defer ldapConn.Close()

// Define search request
searchRequest := ldap.NewSearchRequest(
baseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(sAMAccountName=%s)", username),
[]string{"dn", "memberOf"},
nil,
)

// search for user
sr, err := l.Search(searchRequest)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "user not found in LDAP"})
return
}

// handle user not found
if len(sr.Entries) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found or multiple users found"})
return
}

userDN := sr.Entries[0].DN

// bind as user to verify password
err = l.Bind(userDN, password)
// Authenticate user
_, groups, err := ldapConn.AuthenticateUser(username, password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}

isAdmin := false
// Check if user is admin
isAdmin := CheckIfAdmin(groups)

// check if user is in "Domain Admins"
groups := sr.Entries[0].GetAttributeValues("memberOf")
log.Println("logging user membership: ", groups)
for _, group := range groups {
log.Println("User is a member of: ", group)
if strings.Contains(strings.ToLower(group), "cn=domain admins") {
isAdmin = true
break
}
}

// create session
Expand Down Expand Up @@ -138,8 +86,8 @@ func ProfileHandler(c *gin.Context) {
}

c.JSON(http.StatusOK, gin.H{
"message": username,
"isAdmin": isAdmin,
"username": username,
"isAdmin": isAdmin,
})
}

Expand Down
185 changes: 185 additions & 0 deletions auth/ldap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package auth

import (
"fmt"
"os"
"strings"
"time"

"github.com/go-ldap/ldap/v3"
)

// LDAPConnection holds the LDAP connection and configuration
type LDAPConnection struct {
conn *ldap.Conn
server string
baseDN string
bindDN string
bindPassword string
}

// ConnectToLDAP creates a new LDAP connection and returns it
func ConnectToLDAP() (*LDAPConnection, error) {
// LDAP configuration from environment variables
ldapServer := os.Getenv("LDAP_SERVER")
baseDN := os.Getenv("LDAP_BASE_DN")
bindDN := os.Getenv("LDAP_BIND_DN")
bindPassword := os.Getenv("LDAP_BIND_PASSWORD")

// check LDAP configuration
if ldapServer == "" || baseDN == "" || bindDN == "" || bindPassword == "" {
return nil, fmt.Errorf("LDAP configuration is missing")
}

// connect to LDAP server
conn, err := ldap.DialURL("ldap://" + ldapServer + ":389")
if err != nil {
return nil, fmt.Errorf("LDAP connection failed: %v", err)
}

// bind as service account
err = conn.Bind(bindDN, bindPassword)
if err != nil {
conn.Close()
return nil, fmt.Errorf("LDAP service account bind failed: %v", err)
}

return &LDAPConnection{
conn: conn,
server: ldapServer,
baseDN: baseDN,
bindDN: bindDN,
bindPassword: bindPassword,
}, nil
}

// Close closes the LDAP connection
func (lc *LDAPConnection) Close() {
if lc.conn != nil {
lc.conn.Close()
}
}

// AuthenticateUser authenticates a user against LDAP and returns user info
func (lc *LDAPConnection) AuthenticateUser(username, password string) (string, []string, error) {
// Define search request
searchRequest := ldap.NewSearchRequest(
lc.baseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(sAMAccountName=%s)", username),
[]string{"dn", "memberOf"},
nil,
)

// search for user
sr, err := lc.conn.Search(searchRequest)
if err != nil {
return "", nil, fmt.Errorf("user not found in LDAP: %v", err)
}

// handle user not found
if len(sr.Entries) != 1 {
return "", nil, fmt.Errorf("user not found or multiple users found")
}

userDN := sr.Entries[0].DN
groups := sr.Entries[0].GetAttributeValues("memberOf")

// bind as user to verify password
err = lc.conn.Bind(userDN, password)
if err != nil {
return "", nil, fmt.Errorf("invalid credentials")
}

// rebind as service account for further operations
err = lc.conn.Bind(lc.bindDN, lc.bindPassword)
if err != nil {
return "", nil, fmt.Errorf("failed to rebind as service account")
}

return userDN, groups, nil
}

// GetAllUsers fetches all users from Active Directory
func (lc *LDAPConnection) GetAllUsers() (*UserResponse, error) {
// search for all users
searchRequest := ldap.NewSearchRequest(
lc.baseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(&(objectClass=user)(objectCategory=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))",
[]string{"sAMAccountName", "whenCreated", "memberOf"},
nil,
)

// perform search
sr, err := lc.conn.Search(searchRequest)
if err != nil {
return nil, fmt.Errorf("LDAP search failed: %v", err)
}

var userResponse UserResponse
userResponse.Users = make([]UserWithRoles, 0)

// process each user entry
for _, entry := range sr.Entries {
username := entry.GetAttributeValue("sAMAccountName")
whenCreated := entry.GetAttributeValue("whenCreated")
groups := entry.GetAttributeValues("memberOf")

// skip if no username
if username == "" {
continue
}

// parse and format creation date
createdDate := "Unknown"
if whenCreated != "" {
// AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z
if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil {
createdDate = parsedTime.Format("2006-01-02 15:04:05")
}
}

// check if user is admin
isAdmin := false
for _, group := range groups {
if strings.Contains(strings.ToLower(group), "cn=domain admins") || strings.Contains(strings.ToLower(group), "cn=kamino admin") {
isAdmin = true
break
}
}

// clean up group names (extract CN values)
cleanGroups := make([]string, 0)
for _, group := range groups {
// extract CN from DN format
parts := strings.Split(group, ",")
if len(parts) > 0 && strings.HasPrefix(strings.ToLower(parts[0]), "cn=") {
groupName := strings.TrimPrefix(parts[0], "CN=")
groupName = strings.TrimPrefix(groupName, "cn=")
cleanGroups = append(cleanGroups, groupName)
}
}

user := UserWithRoles{
Username: username,
CreatedDate: createdDate,
IsAdmin: isAdmin,
Groups: cleanGroups,
}

userResponse.Users = append(userResponse.Users, user)
}

return &userResponse, nil
}

// CheckIfAdmin checks if a user is in the Domain Admins group
func CheckIfAdmin(groups []string) bool {
for _, group := range groups {
if strings.Contains(strings.ToLower(group), "cn=domain admins") {
return true
}
}
return false
}
73 changes: 73 additions & 0 deletions auth/users.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package auth

import (
"log"
"net/http"

"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)

type UserResponse struct {
Users []UserWithRoles `json:"users"`
}

type UserWithRoles struct {
Username string `json:"username"`
CreatedDate string `json:"createdDate"`
IsAdmin bool `json:"isAdmin"`
Groups []string `json:"groups"`
}

// helper function that fetches all users from Active Directory
func buildUserResponse() (*UserResponse, error) {
// Connect to LDAP
ldapConn, err := ConnectToLDAP()
if err != nil {
return nil, err
}
defer ldapConn.Close()

// Get all users using the LDAP connection
return ldapConn.GetAllUsers()
}

/*
* ===== ADMIN ENDPOINT =====
* This function returns a list of
* all users and their roles in Active Directory
*/
func GetUsers(c *gin.Context) {
session := sessions.Default(c)
username := session.Get("username")
isAdmin := session.Get("is_admin")

// make sure user is authenticated and is admin
if !isAdmin.(bool) {
log.Printf("Forbidden access attempt by user %s", username)
c.JSON(http.StatusForbidden, gin.H{
"error": "Only Admin users can see all domain users",
})
return
}

// fetch user response
userResponse, err := getAdminUserResponse()

// if error, return error status
if err != nil {
log.Printf("Failed to fetch user list for admin %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to fetch user list from Active Directory",
"details": err.Error(),
})
return
}

log.Printf("Successfully fetched user list for admin %s", username)
c.JSON(http.StatusOK, userResponse)
}

func getAdminUserResponse() (*UserResponse, error) {
return buildUserResponse()
}
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ func main() {
// Proxmox Admin Pod endpoints
admin.GET("/proxmox/pods/all", cloning.GetPods)

// Active Directory User endpoints
admin.GET("/users", auth.GetUsers)

// get port to run server on via. PC_PORT env variable
port := os.Getenv("PC_PORT")
if port == "" {
Expand Down
2 changes: 1 addition & 1 deletion proxmox/cloning/cloning.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ func makeCloneRequest(config *proxmox.ProxmoxConfig, vm proxmox.VirtualResource,

// finds lowest available POD ID between 1001 - 1255
func nextPodID(config *proxmox.ProxmoxConfig, c *gin.Context) (string, int, error) {
podResponse, err := getPodResponse(config)
podResponse, err := getAdminPodResponse(config)

// if error, return error status
if err != nil {
Expand Down
Loading