Если вы видите что-то необычное, просто сообщите мне. Skip to main content

Getting Started With LDAP in Go

RecentlyНедавно Iпришлось hadписать toдоброе writeколичество aкода fairна amountGo, ofкоторый Goвзаимодействует codeс whichAD, interactsдля withодного Activeмоего Directory (AD) for one of my clients.клиента. AD usesиспользует Lightweightлегковесный Directoryпротокол Access Protocolдоступа (LDAP) [1]для forклиент client-serverсерверного communication.подключения. LDAP is- aстарый veryи matureмогущественный andпротокол powerfulдля protocolвзаимодействия toс interactсервисов, withнесмотря directoryна services,это, thoughмногие someмои ofдрузья myспорят, friendsчто argueэто thatреливкия it’sпрошлого aна bitданный ofмомент. aЯ relicс ofними pastне atсогласен, thisно point.мое Iобъяснение disagreeможет withзанять thisцелый sentiment,отдельный but my explanation why would probably take a whole another blog post.пост.

AroundДва twoтри orгода threeназад yearsя agoстолкнулся I faced a similarс LDAP challenge.вызовом. IПришлось hadписать to write someнекоторый Go codeкод, whichкоторый wouldдолжен useиспользовать LDAP forдля groupгрупп membershipи andнекоторых someдругих otherавторизационных authorizationвещей. relatedВ things.то Backвремя thenбиблиотеки были в плачевном состоянии. К сожалению, это не было выходом в то время, но к счатью, я быстро изучил работу в Go с LDAP, что поменялось к лучшему.

Эта статья предоставляет базовое введение в удивительный модуль go-ldap. Что-то вроде ввдения, о котором я мечта, когда я начал работать над LDAP Goпроектом librariesдля wereмоего inклиента. aКроме prettyтого, direя state.хотел Iиметь endedнекую upссылку shellingна outинструкцию toк theкоторой wellя knownсмог LDAPбы command-lineвернуться tools.в Unfortunately,будущем thatесли wasn’tпонадобится. anЯ optionнадеюсь thisвы time,не butтолько luckilyнайдете Iэтот quicklyпост learnedполезным, thingsно inтак theже научитесь чему-то новому. Давайте начнем.

Подключение

Прежде чем мы сможем сделать что-нибудь с AD нужно подключиться к серверу. Давайте, для начала, подключимся к серверу AD с помощью LDAP, мне кажется это естественно и правильно с точки зрения смысла. Еще Go LDAPмодули haveописанные changedв andэтом forпосту theназывадются better!

ldap-go,

Thisдавайте blogразберемся postс providesLDAP. a basic introduction to the wonderfulМодуль go-ldap module.предоставляет It’sнесколько theвозможностей kindдля ofподключения introduction I wish it had existed when I started working on theк LDAP projectсерверу. forПройдеся myпо client.ним Besides, I wanted to have some sort of reference guide I could get back to in the future if I needed to. I hope you will not only find this post helpful, but that you will also learn something new. Let’s get started!подробнее.

Connect

Все

Before you can do anything with AD you need connect to the AD server. Let’s call the AD serverтипи LDAP serverподключения fromобрабатыватся nowDialURL on;функцией. itЕсть feelsнесколько aдругих bitфункции moreдоступных naturalв andмодуле, semanticallyно correctдокументация toпредполагает me.что PlusDialURL theостанется Goединственно moduleрабочей. IКак describeпредписывает inназвание thisфункции, postвы isпередаете calledей ldap-go,URL soи let’sфункция stickпытается toподключиться LDAP.к The go-ldap module provides several options for you to connecto toудаленному LDAP server.серверу Let’sи walkвернуть throughподключение [someесли of]всё themпрошло in more detail.успешно.

AllПример variantsкода ofможно LDAPнайти connections are handled by the DialURL function. There are some other function available in the module, but the docs suggest they have been deprecated in favour of DialURL function. Like the name suggests you provide a URL and the function attempts to connect to the remote LDAP server and returns the connection handle if successful.

See the sample code below:ниже:

ldapURL := "ldaps://ldap.example.com:636"
l, err := ldap.DialURL(ldapURL)
if err != nil {
        log.Fatal(err)
}
defer l.Close()

TheЭтот codeкод aboveпытается attempts to establish aустановить TLS connectionподключение withк theудаленному remote server.серверу. DialURL infersвыводит theтип typeподключения of connection from theиз URL schemeкоторый whichбыл inпередан thisфункции, caseкоторый wasв setэтом toслучае ldapsявляется (noteldaps(с thes “s”- at the end)безопасный).

IfЕсли youвам needнужны more fine grainedподробности TLS configuration,конфигурации, theфункция functionпринимает accepts customсамоподписанные TLS configконфиги viaчерез additionalдопольнительный parameter:параметр:

ldapURL := "ldaps://ldap.example.com:636"
l, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
if err != nil {
        log.Fatal(err)
}
defer l.Close()

NOTE:

Пример Theкода sampleвыше codeприведен aboveтолько isв forкачестве illustrationдемонстрации! purposesНи only!когда Neverне skipпропускайте проверку TLS verificationкогда whenсоздаете creatingеё.
TLS connections!

IfЕсли youвы don’tне wantхотите toиспользовать useTLS, TLSможно youпросто canопустить use"s" omit the “s” in theв URL scheme like so:адресе:

ldapURL := "ldap://ldap.example.com:389"
l, err := ldap.DialURL(ldapURL)
if err != nil {
        log.Fatal(err)
}
defer l.Close()

YouВы canможете alsoтак omitже theопустить portпорт fromиз theадреса. Код выше показывает его для краткости. Если вы опустите порт DialURL функция автоматически подставит порт 639 для ldaps или 389 для ldap подключений. По умолчанию LDAP URL.порт Theтак codeже examplesдоступен aboveчерез showглобальные it for brevity. If you omit the port number the DialURL function automatically uses default port numbers for the particular URL scheme i.e. 636 for ldaps:// and 389 for “clear” (plaintext) lpap:// connections. The default LDAP port numbers are also accessible via the global variablesпеременные DefaultLdapsPort andи DefaultLdapPort.

Alternatively,Как youвариант, canвы useможете theиспользовать NewConn(conn net.Conn, isTLS bool) functionфункцию whichкоторая allowsпозволяет youвам toиспользовать passчистое in a raw net.Conn (seeподключение, here)которое connectionвам whichможет youпонадобиться, mayв haveтом establishedили viaином different means.случае.

Finally,Наконец, youвы canможете alsoтак upgradeже anобновить existingсуществующее “plaintext”подключение connection to theдо TLS oneиспользуя byфункцию using StartTLS() function::

l, err := DialURL("ldap://ldap.example.com:389")
if err != nil {
    log.Fatal(err)
}
defer l.Close()

// Now reconnect with TLS
err = l.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
    log.Fatal(err)
}

NowТеперь thatзная youкак learntподключиться how to connect toк LDAP serverсерверу weмы canможем proceedперейти toк theследующему next step: Binding.шагу.

BindСвязывание

BindingСвязывание isэто theшаг, step where theгде LDAP serverсервере authenticatesуже theаутентифицирует client.клиента. IfЕсли theклиент clientуспешно isпрошол successfullyаутентификацию, authenticated,сервер theпредоставляет serverему grantsдоступ itосновываясь theна accessего based on its privileges.привилегиях.

ThereЕсть areнесколько differentпутей waysдля of doingсоздания LDAP bindingсвязываний usingиспользуя ldap-go. Let’sНачнем startс withпростого theслучая: simplestнеаутентифицируемое case: unauthenticated bind.связываение.

Sometimes theИногда LDAP serversсервер allowдает limitedограниченный read-onlyдоступ accessтолько toдля unauthenticatedчтения clients.для Unauthenticatedнеаутентифицируемых LDAPклиентов. bindВыпонить [1],[2]его canможно beследующим done as followsобразом:

// connectПодключяемя codeк asсерверу shownкак earlierделали выше

err = l.UnauthenticatedBind("cn=read-only-admin,dc=example,dc=com")
if err != nil {
    log.Fatal(err)
}

If you however need to authenticate there are two options at your disposal: SimpleBind and Bind. The latter is a nice wrapper around the former so I prefer to use that one in my code:

// connectПодключяемя codeк asсерверу shownкак earlierделали выше

err = l.Bind("cn=read-only-admin,dc=example,dc=com", "p4ssw0rd")
if err != nil {
    log.Fatal(err)
}

Finally, you can also do an “External” bind which according to the official RFC [3] allows a client to request the server to use credentials established by means external to the mechanism to authenticate the client.

What this translates to in reality is the client binding into UNIX socket (your URL schema must be ldapi://) and the SASL/TLS auth happens “indirectly” over the UNIX socket.

I’ve never used this form of authentication so I can’t speak much about it, but I’m guessing it can be useful in the “sidecar” scenario where you communicate with your sidecar process over a UNIX socket in which your sidecar process handles the LDAP authentication (and communication) on your behalf.

LDAP CRUD

Now that we’ve connected and authenticated we can do some damage. If the account you used for authentication has appropriate permissions you can start Adding, Modifying, Searching and Deleting LDAP records. Let’s have a look at each of these in more detail.

In general, you will take actions on three basic records: groups, users and machine [accounts].

Add and Modify

You can create new LDAP records by using the Add function. It accepts a single parameter: an AddRequest. You can either craft the AddRequest manually (the AddRequest struct is exported along with all of its fields) or you can use the simple helper functions the library provides for you. We will have a look at both cases below.

I decided to group both the Addition and Modifications examples together since they’re more related than I had initially thought as you will see later on!

Adding Groups

Adding groups to AD took me a bit while to figure out but after reading various AD documentation pages I ended up with something like this:

// connect code comes here

addReq := ldap.NewAddRequest("CN=testgroup,ou=Groups,dc=example,dc=com", []ldap.Control{})
var attrs []ldap.Attribute
attr := ldap.Attribute{
      Type: "objectClass",
      Vals: []string{"top", "group"},
}
attrs = append(attrs, attr)

attr = ldap.Attribute{
      Type: "name",
      Vals: []string{"testgroup"},
}
attrs = append(attrs, attr)

attr = ldap.Attribute{
      Type: "sAMAccountName",
      Vals: []string{"testgroup"},
}
attrs = append(attrs, attr)

// make the group writable i.e. modifiable
// https://docs.microsoft.com/en-us/windows/win32/adschema/a-instancetype
instanceType := 0x00000004
attr = ldap.Attribute{
      Type: "instanceType",
      Vals: []string{fmt.Sprintf("%d", instanceType},
}
attrs = append(attrs, attr)

// make the group domain local and the group to be a security group
// https://docs.microsoft.com/en-us/windows/win32/adschema/a-grouptype
groupType := 0x00000004 | 0x80000000
attr = ldap.Attribute{
      Type: "groupType",
      Vals: []string{fmt.Sprintf("%d", groupType)},
}
attrs = append(attrs, attr)

addReq.Attributes = attrs

if err := l.AddRequest(addReq); err != nil {
      log.Fatal("error adding group:", addReq, err)
}
                                 ```
Now, this code looks a bit verbose, and indeed it is. There is a more concise way to do the same thing, but I wanted to show the above code for brevity as that was what my first code looked like.

Here is a nicer way which does the same thing:
```go
// connect code comes here

addReq := ldp.NewAddRequest("CN=testgroup,ou=Groups,dc=example,dc=com", []ldp.Control{})

addReq.Attribute("objectClass", []string{"top", "group"})
addReq.Attribute("name", []string{"testgroup"})
addReq.Attribute("sAMAccountName", []string{"testgroup"})
addReq.Attribute("instanceType", []string{fmt.Sprintf("%d", 0x00000004})
addReq.Attribute("groupType", []string{fmt.Sprintf("%d", 0x00000004 | 0x80000000)})

if err := l.AddRequest(addReq); err != nil {
      log.Fatal("error adding group:", addReq, err)
}

There are a couple of things to highlight. First, you need to make sure your objectClass attributes are of the right type ("top" and "group").

Next the instanceType hex number looks a bit intimidating, but it is exactly what AD expects if you want to create a “writable” i.e. modifiable group record [4].

Finally, the groupType attribute looks even madder! It turns out if you want your group to have a domain local scope whilst also it’s being a security group (as opposed to a distribution group) you need to do a bitwise operation on the flags defined in AD docs [5].

With this in place, you’re good to go. You can verify the group has been created using the familiar LDAP command-line tools:

ldapsearch -LLL -o ldif-wrap=no -b "OU=testgroup,OU=Group,dc=example,dc=com" \
         -D "${LDAP_USERNAME_DN}" -w "${LDAP_BIND_PASSWD}" -h "${LDAP_HOST}" \
         '(CN=testgroup)' cn

Adding Users

Adding users turned out to be a bit more elaborate and it’s something that bit me because it wasn’t very obvious to me how to do it. The best way to learn is by doing so let’s have a look at a concrete example.

Let’s say you want to create a new LDAP user and assign it to some password. Let’s say additionally you don’t want the password to expire. What I originally thought would do the trick was to do all of this in one simple AddRequest in a similar fashion as shown earlier in the group example.

I thought I’d find the right LDAP attributes, shovel them into the AddRequest and that would be job done. I was terribly wrong and it took me some time to figure it out!

It turns out the key is to split the whole process into 3 steps:

Create a disabled account Set the password for the user Enable the user account Knowing this, the resulting code for the step 1 is pretty simple:

// connect code comes here

addReq = ldp.NewAddRequest("CN=fooUser,OU=Users,dc=example,dc=com", []ldp.Control{})
addReq.Attribute("objectClass", []string{"top", "organizationalPerson", "user", "person"})
addReq.Attribute("name", []string{"fooUser"})
addReq.Attribute("sAMAccountName", []string{"fooUser"})
addReq.Attribute("userAccountControl", []string{fmt.Sprintf("%d", 0x0202})
addReq.Attribute("instanceType", []string{fmt.Sprintf("%d", 0x00000004})
addReq.Attribute("userPrincipalName", []string{"fooUser@example.com"})
addReq.Attribute("accountExpires", []string{fmt.Sprintf("%d", 0x00000000})

addReq.Attributes = attrs

if err := l.AddRequest(addReq); err != nil {
      log.Fatal("error adding service:", addReq, err)
}

Now that the account has been created we can move on to the second step: setting the user password.

Active Directory server stores a quoted password in little-endian UTF16 encoded in base64. That was a bit of a mouth full, but that’s literally what I found in docs. Luckily, for me Linux provides a few handy utilities to take care of all of this for you. To create a new password in the right format your can run the command shown below:

echo -n "\"password\"" | iconv -f UTF8 -t UTF16LE | base64 -w 0

Now that you have generated the password for the new user it’s time to set it in LDAP server. You do this by modifying the user account’s unicodePwd attribute. The below code shows how to accomplish that:

// connect code comes here

// https://github.com/golang/text
// According to the MS docs the password needs to be enclosed in quotes o_O
utf16 := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)
pwdEncoded, err := utf16.NewEncoder().String(fmt.Sprintf("%q", userPasswd))
if err != nil {
        log.Fatal(err)
}

modReq := ldap.NewModifyRequest("CN=fooUser,OU=Users,dc=example,dc=com", []ldap.Control{})
modReq.Replace("unicodePwd", []string{pwdEncoded})

if err := l.ModRequest(modReq); err != nil {
      log.Fatal("error setting user password:", modReq, err)
}
the unicode handling code actually comes from the Go text package [6]

Finally you need to enable the user account by modifying its attributes [again]:

modReq := ldap.NewModifyRequest("CN=fooUser,OU=Users,dc=example,dc=com", []ldap.Control{})
modReq.Replace("userAccountControl", []string{fmt.Sprintf("%d", 0x0200})

if err := l.ModRequest(modReq); err != nil {
      log.Fatal("error enabling user account:", modReq, err)
}

Again, you can easily verify the user has been created:

$ ldapsearch -LLL -o ldif-wrap=no -b "OU=fooUser,OU=Users,dc=example,dc=com" \
         -D "{LDAP_USERNAME_DN}" -w "${LDAP_BIND_PASSWD}" -h "${LDAP_HOST}" \
         '(CN=fooUser)' cn

Adding machine accounts

You can also create machine (aka service) accounts in LDAP which are often used in conjunction with Kerberos [7] for storing service attributes and granting access to different services and resources.

Machine account can be created in a similar way the user accounts are created, but there are a few differences.

Having to add "computer" value to the list of objectClass attribute values is probably the most important difference. Other than that the rest should be pretty much the same. Although some people don’t set the password for the machine accounts so you might want to skip that part completely and simply create the new LDAP record in a similar way the groups are created.

Modify DN

Sometimes you need to move an LDAP record between different Organizational Units (OU). The code to accomplish that is super simple:

// connect code comes here

// move fooUser from OU=Users to OU=SuperUsers
req := ldap.NewModifyDNRequest("CN=fooUser,OU=Users,DC=example,DC=com", "CN=fooUser", true, "OU=SuperUsers,DC=example,DC=com")
if err = conn.ModifyDN(req); err != nil {
        log.Fatalf("Failed to modify userDN: %s\n", err)
}

It’s worth noting a few things here.

First, the second parameter is an RDN (relative DN) as opposed to the [full] DN. LDAP server stores records (and other things) in a hierarchical structure (it’s actually a mad complicated graph structure). Every record exists in a rigid organizational hierarchy (probably a projection of business world from the time when AD was created, lol). So the record position is always either relative to some other [partial] DN or absolute if specified using the full DN form.

The third parameter tells the remote server whether it should delete the original record once it has been moved. If we decided to keep it we’d set the value to false. The final parameter is the new parent entry DN.

If you just wanted to rename the CN (or some other attribute) you would omit the last parameter by setting it to empty string i.e. the code which would rename the user fooUser to barUser without moving it around between different OUs would look like this:

// move fooUser to "OU=SuperUsers,dc=example,dc=com"
req := ldap.NewModifyDNRequest("CN=fooUser,OU=Users,DC=example,DC=com", "CN=barUser", true, "")
if err = conn.ModifyDN(req); err != nil {
        log.Fatalf("Failed to modify DN: %s\n", err)
}

Modify Password

Another thing you might need to do occasionally is modiyfing existing passwords. To modify an existing password you’d do something like this:

passwdModReq := ldap.NewPasswordModifyRequest("", "OldPassword", "NewPassword")
if _, err = l.PasswordModify(passwdModReq); err != nil {
        log.Fatalf("failed to modify password: %v", err)
}

If you don’t specify the new password the server will generate one for you automatically and send it back to you:

passwdModReq := ldap.NewPasswordModifyRequest("", "OldPassword", "")
passwdModResp, err := l.PasswordModify(passwdModReq)
if err != nil {
        log.Fatalf("failed to change password: %v", err)
}

newPasswd := passwdModResp.GeneratedPassword
log.Printf("New password: %s\n", newPasswd)
unlike when modifying an user account password when creating new account, you do NOT need to quote the UTF-16 vase64 encoded string!

Delete

Deleting LDAP records is super easy. All you need to do is create an DelRequest by providing the particular record ldap DN and then run the delete command like so:

delReq = ldap.NewDelRequest("CN=fooUser,OU=Users,dc=example,dc=com", []ldap.Control{})

if err := l.Delete(delReq); err != nil {
      log.Fatalf("Error deleting service: %v", err)
}

Again you can easily verify the user account has been deleted using the familiar ldapsearch command shown earlier.

Query

Last but not at least let’s talk about querying the LDAP records and their attributes.

In order to query the LDAP records you need to create a SearchRequest which you then send to the LDAP server using Search function.

The SearchRequest provides various options to fine-tune your query, but we’ll focus on the 3 I find the most important:

BaseDN is the DN you are querying for the records Filter for filtering the results Attributes you are interested in Let’s have a look at a concrete example and explain it in detail:

// connect code comes here

user := "fooUser"
baseDN := "DC=example,DC=com"
filter := fmt.Sprintf("(CN=%s)", ldap.EscapeFilter(user))

// Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, []string{"sAMAccountName"}, []ldap.Control{})

result, err := l.Search(searchReq)
if err != nil {
        return fmt.Errorf("failed to query LDAP: %w", err)
}

log.Println("Got", len(result.Entries), "search results")

We start by creating a new SearchRequest and supply it with the 3 parameters I had mentioned earlier. There is one thing to notice when constructing the SearchRequest. Actually, there are two things.

First, have a proper look at the filter variable which we pass in as a search filter to the query. First thing you will notice is, the record CN is placed within the brackets (). If you don’t do that you’ll get an error when running the search:

LDAP Result Code 201 "Filter Compile Error": ldap: filter does not start with an '(' Second, notice how we escape the input when we create the filter using ldap.Escape() function. This is obviously helpful to avoid all kinds of random LDAP [security] shenanigans.

Finally, let’s talk about the rest of the parameters. By passing ldap.ScopeWholeSubtree we tell LDAP server we want to search records across the whole tree of the given DN.

There are other options available such as ldap.ScopeBaseObject which searches only within the given RDN. But for this example, I wanted to illustrate a domain-wise search.

Another thing to notice is, we pass in a slice of LDAP Attributes we are interested in. If you leave the attribute slice empty the search will return all the LDAP record attributes, which is something you might need, but I wanted to show how you can ask for a select list of attributes. Beware of the size of the attributes if you decide to query all of them!

There are plenty of other options to search LDAP at your disposal. Particularly, you should have a look at SearchWithPaging function which as its name suggests lets you page the query results if you expect huge loads of them.

Display results

Now that you know how to query the records you might want to display them in the terminal in some human-readable form. There are two handy functions at your disposal: Print and PrettyPrint.

Personally I think they seem almost the same, though PrettyPrint lets you indent the result(s) so you can see the AD tree structure more clearly. See for yourself the results of using both of the functions:

This is the result of Print():

DN: CN=fooUser,OU=Users,DC=example,DC=com
sAMAccountName: [fooUser]

This is the result of PrettyPrint(2) (see the attribute 2-space indentation):

  DN: CN=fooUser,OU=Users,DC=example,DC=com
    sAMAccountName: [fooUser]

Conclusion

We have reached the end of this post. Congrats and thank you if you stayed with me until the end! Hopefully, you learned something new and useful which expands your Go toolbox.

When I started using ldap-go library some things were not quite obvious to me, so hopefully the examples in this blog post help whoever ends up having to interact with AD using Go.

As always, if you have any questions or find any inaccuracies in the post let me know in the comments. Until next time!

  • https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol
  • https://tools.ietf.org/html/rfc4513#section-5.1.2
  • https://tools.ietf.org/html/rfc4513#section-6.3.1
  • https://tools.ietf.org/html/rfc4422#appendix-A
  • https://docs.microsoft.com/en-us/windows/win32/adschema/a-instancetype
  • https://docs.microsoft.com/en-us/windows/win32/adschema/a-grouptype
  • https://github.com/golang/text
  • https://en.wikipedia.org/wiki/Kerberos_(protocol)