Getting Started With LDAP in Go
Недавно пришлось писать доброе количество кода на Go, который взаимодействует с AD, для одного моего клиента. AD использует легковесный протокол доступа (LDAP) для клиент серверного подключения. LDAP - старый и могущественный протокол для взаимодействия с сервисов, несмотря на это, многие мои друзья спорят, что это реливкия прошлого на данный момент. Я с ними не согласен, но мое объяснение может занять целый отдельный пост.
Два три года назад я столкнулся с LDAP вызовом. Пришлось писать некоторый Go код, который должен использовать LDAP для групп и некоторых других авторизационных вещей. В то время библиотеки были в плачевном состоянии. К сожалению, это не было выходом в то время, но к счатью, я быстро изучил работу в Go с LDAP, что поменялось к лучшему.
Эта статья предоставляет базовое введение в удивительный модуль go-ldap. Что-то вроде ввдения, о котором я мечта, когда я начал работать над LDAP проектом для моего клиента. Кроме того, я хотел иметь некую ссылку на инструкцию к которой я смог бы вернуться в будущем если понадобится. Я надеюсь вы не только найдете этот пост полезным, но так же научитесь чему-то новому. Давайте начнем.
Подключение
Прежде чем мы сможем сделать что-нибудь с AD нужно подключиться к серверу. Давайте, для начала, подключимся к серверу AD с помощью LDAP, мне кажется это естественно и правильно с точки зрения смысла. Еще Go модули описанные в этом посту называдются ldap-go, давайте разберемся с LDAP. Модуль go-ldap предоставляет несколько возможностей для подключения к LDAP серверу. Пройдеся по ним подробнее.
Все типи LDAP подключения обрабатыватся DialURL
функцией. Есть несколько других функции доступных в модуле, но документация предполагает что DialURL
останется единственно рабочей. Как предписывает название функции, вы передаете ей URL и функция пытается подключиться к удаленному LDAP серверу и вернуть подключение если всё прошло успешно.
Пример кода можно найти ниже:
ldapURL := "ldaps://ldap.example.com:636"
l, err := ldap.DialURL(ldapURL)
if err != nil {
log.Fatal(err)
}
defer l.Close()
Этот код пытается установить TLS подключение к удаленному серверу. DialURL выводит тип подключения из URL который был передан функции, который в этом случае является ldaps(с s - безопасный).
Если вам нужны подробности TLS конфигурации, функция принимает самоподписанные TLS конфиги через допольнительный параметр:
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()
Пример кода выше дан только в качестве демонстрации! Ни когда не пропускайте проверку TLS когда создаете её.
Если вы не хотите использовать TLS, можно просто опустить "s" в URL адресе:
ldapURL := "ldap://ldap.example.com:389"
l, err := ldap.DialURL(ldapURL)
if err != nil {
log.Fatal(err)
}
defer l.Close()
Вы можете так же опустить порт из адреса. Код выше показывает его для краткости. Если вы опустите порт DialURL
функция автоматически подставит порт 639 для ldaps или 389 для ldap подключений. По умолчанию LDAP порт так же доступен через глобальные переменные DefaultLdapsPort и DefaultLdapPort.
Как вариант, вы можете использовать NewConn(conn net.Conn, isTLS bool)
функцию которая позволяет вам использовать чистое net.Conn
подключение, которое вам может понадобиться, в том или ином случае.
Наконец, вы можете так же обновить существующее подключение до TLS используя функцию StartTLS()
:
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)
}
Теперь зная как подключиться к LDAP серверу мы можем перейти к следующему шагу.
Связывание
Связывание это шаг, где LDAP сервере уже аутентифицирует клиента. Если клиент успешно прошол аутентификацию, сервер предоставляет ему доступ основываясь на его привилегиях.
Есть несколько путей для создания LDAP связываний используя ldap-go. Начнем с простого случая: неаутентифицируемое связываение.
Иногда LDAP сервер дает ограниченный доступ только для чтения для неаутентифицируемых клиентов. Выпонить его можно следующим образом:
// Подключяемя к серверу как делали выше
err = l.UnauthenticatedBind("cn=read-only-admin,dc=example,dc=com")
if err != nil {
log.Fatal(err)
}
Если вам, всё равно нужно аутентифицироваться, в вашем распоряжении есть 2 варианта:
SimpleBind
и Bind
. Последнее - это хорошая обертка вокруг первого, поэтому я люблю использовать её.
// Подключяемя к серверу как делали выше
err = l.Bind("cn=read-only-admin,dc=example,dc=com", "p4ssw0rd")
if err != nil {
log.Fatal(err)
}
Наконец вы можете сделать External
свзяывание которое согласно официальному заявлению позволяет клиентам запрашивать у сервера использование доступов созданных во вне по отношению к механизму клиента.
Это воплощает в реальность, то что клиент связывается в UNIX сокет(используется ldapi://) и происходит SASL/TLS аутентификация "непрямо" через UNIX сокет.
Я никогда не использовать эту форму аутентификация, поэтому я не могу что-то про него рассказать, но я думаю это полезно, в качестве sidecar
когда вы подключаетесь своим сайдкаром к процессу через UNIX socket в котором ваш процесс сайдкара обрабатывает LDAP аутентификацию(через коммуникацию) от вашего имени.
LDAP CRUD
Теперь, то что мы подключились и аутентифицировались мы можем навредить. Если используемый аккаунт имеет верные доступы, вы можете начать добавлять, изменять, искать и удалять LDAP записи. Давайте посмотрим, на каждую из них в отдельности.
В общем, мы будем работать с тремя базовыми записями: groups, users и машины.
Добавление и изменение
Вы можете создать новую LDAP запись используя Add
функцию. Она принимает простой параметр AddRequest
. Вы можете собрать AddRequest
вручную(структура AddRequest
экспортируется вместе со всеми своими полями) или вы можете использовать простую функцию помощника внутри библиотеку. Мы рассмотрим оба этих случая.
Я решил сгруппировать оба примера добавление и изменение, так как они связаны очень тесно, о чем я не догадывался, а вы увидите дальше.
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)
}
```
Теперь 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код.
Сопособ codeпол forучше, brevityкоторый asделает thatтоже wasсамое:
// 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атрибуты makeobjectClass
sureправильного yourтипа(top
objectClassи attributes are of the right type ("top" and "group"group
).
NextВторое, theinstanceType
instanceTypehex-число hexвыглядит numberпугающим, looksно aэто bitименно, intimidating,то butчто it is exactly whatждет AD expectsесли ifвы youхотите wantсоздать to"writable" 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знакомую groupldap 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запрос thisAddRequest
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в theAddRequest
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Сервер DirectoryAD serverхранит storesпароль aв quotedвиде password inкодировки little-endian UTF16 encoded inв base64. ThatК wasсчастью, aдля bitменя oflinux 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пользователя userunicodePwd
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создать (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)