LDAP에 대해 알아보자

Tim Kang
Ecube Labs
Published in
20 min readApr 13, 2020
산맥같은 LDAP의 로고. 시작부터 험난한 여정을 예고하는듯 하다.

개요

갑자기 이큐브랩 솔루션에서 SSO가 필요해져 구현하게 되었습니다.
일반적으로 SSO를 구현하는 방법은 OAuth2나 SAML2를 사용하는것으로 보였습니다.
일단 SAML2를 지원하기 위해 찾아보니, LDAP와 같은 디렉터리 서비스가 필요했습니다.
그냥 오픈소스를 이용해 디렉터리 서비스를 마련해도 됐었지만, 얕더라도 LDAP에 대한 이해를 하기 위해 정리도 할 겸 이 글을 작성하게 되었습니다. (SSO에 대한 글은 너무 좋은 글들이 많아서 스킵…)

실제로 사용 가능하고 가벼운 LDAP 서버를 구현하는게 목표이고, 그 과정에 필요한 사전 지식들을 정리해보고자 합니다 😃

LDAP가 뭐지?

  • Lightweight Directory Access Protocol 의 약자.
  • 인터넷 기반 분산 디렉터리 서비스를 위한 프로토콜
  • 디렉터리 서비스를 제공하기 위한 표준급(다들 쓰는?) 프로토콜

위 항목을 보면 결국 LDAP는 프로토콜일 뿐, 보통 디렉터리 서비스를 제공하기 위해 사용하는것을 알 수 있습니다.

History

조금만 더 알아보겠습니다.
1993년 7월에 미시간 대학교를 주도로 디렉터리 접속 프로토콜인 LDAP가 만들어졌습니다.
더 정확히는 1993년 7월에 인터넷 표준으로 RFC1487가 제정됐습니다.

LDAP는 기존에 X.500에서 정의된 프로토콜의 한 종류인 DAP를 단순화 시킨 프로토콜입니다.
DAP는 OSI의 모든 레이어를 지원하며 운영에 많은 컴퓨팅 리소스를 필요로 하는 아주 무거운 프로토콜이라고 합니다.
그래서 더 가볍게 사용하기 위해 경량화 시킨것이죠.

Lightweight?

그런데 우리가 사용하려고 찾아보니 전혀 가벼워 보이지 않습니다.
그럼 어디가 가벼워졌다고 하는걸까요?
이 “Lightweight” 라는 의미는 프로토콜의 스펙을 말하는것은 아니었습니다.
오히려 DAP의 스펙은 최대한 유지하면서 네트워크의 부담을 최대한 줄인 버전이라고 봐야겠습니다.

스펙이 짱짱한것에 비해 네트워크의 부담이 적으니 30년이 지난 지금까지도 잘 사용되고 있는것이겠죠? 🙂

그럼 디렉터리 서비스가 뭔데??

디렉터리 모델은 꽤 오래전(30y+)부터 존재하고 있었습니다.
앞에서 말했듯 이 디렉터리 모델(정확히는 DAP)을 기반으로 구현된 LDAP는 미시간 대학교의 똑똑한 사람들이 다듬어서 새로운 규약으로 정리한것인데, 디렉터리 서비스는 이것을 이용해 계층적으로 데이터를 관리할 수 있도록 해주는 프로그램이라고 볼 수 있습니다. (like DBMS…)

지금은 대부분 기업의 사용자 정보를 Centralize하고, 수천 명의 사용자를 논리적 그룹으로 나누고 다른 시스템에서도 통합된 사용자 정보를 공유하는 용도로 많이 사용하고 있습니다.

정리하자면 사실상 트리 구조로 데이터를 담아두고, 찾기 쉬운것을 원한다면 어떤 데이터를 담아도 상관이 없습니다.
단지 적합한 용도로서 조직의 사용자나 주소록 정보를 관리하는것이 꼽히는것이죠.
그것을 목적으로 만들어진 프로토콜이니까요.

그럼 데이터베이스랑 별로 다르지 않네?

이것에 대해 LDAP에서는 이렇게 설명하고 있습니다.

디렉토리 서버 (보다 기술적으로는 Directory Server Agent, 디렉토리 시스템 에이전트 또는 DSA라고 함)는 항목 트리로 표시되는 정보를 저장하는 네트워크 데이터베이스 유형입니다. 이것은 행과 열로 구성된 테이블을 사용하는 관계형 데이터베이스와는 다르기 때문에 디렉토리 서버는 NoSQL이라는 용어보다 훨씬 오래 사용되었지만 디렉토리 서버는 NoSQL 데이터베이스 유형으로 간주 될 수 있습니다.

여러 문서에서 디렉터리 서비스는 데이터베이스와 다르다고 말하지만, LDAP에서 공식적으로 “네트워크 데이터베이스 유형”, “NoSQL 데이터베이스 유형으로 간주 될 수 있습니다.” 라고 말합니다.

우리는 디렉터리 서비스(서버)를 네트워크 데이터베이스 서버라고 불러도 되겠네요 😄

디렉터리 서비스가 왜 필요하지?

위에서 언급한대로 기업같은 대규모 조직에서 사용자나 주소록 등의 정보를 중앙 집중화하여 관리하기 위해 사용합니다.
조금 더 자세한 상황은 아래와 같습니다.

  • 구성원 정보를 잘 관리하고 싶은 대규모 조직
  • 여러 솔루션이나 시스템 별 ID/PW를 자꾸 까먹는 사람
  • 구성원의 변화가 잦은 조직

우리의 고객들은 이런 디렉터리 서비스를 이용한 IdP(Identity Provider)를 운영하고 있을것이고, 우리는 고객의 IdP를 이용해서 Single sign-on을 가능하게 해주면 됩니다!

Shallow dive

LDAP가 대충 뭐고, 왜 생겼는지 알았으니 구현을 위해 필요한 정보를 조금만 더 알아보겠습니다.

LDAPv3

여러 규약들이 그렇듯이 LDAP도 major 버전이 몇개 있는데, 최근에 많이 사용하는 v3를 대부분 지원하거나 사용한다고 하니 우리도 v3만 지원해도 큰 문제가 될것같진 않습니다.

우리가 사용할 LDAP v3는 RFC2251에 정의되어 있습니다.

LDAP 클라이언트

LDAP 서버가 있다면 당연히 LDAP 서버에 접근하는 클라이언트가 있을거고, 실제로 클라이언트는 누가 될까요?

이큐브랩 구성원 정보가 담긴 LDAP 서버가 있다고 가정을 하겠습니다.
그러면 LDAP 서버의 클라이언트는 ERP 서버가 될수도 있겠죠.

LDAP 서버에 있는 사용자 정보로(만) 자격 증명이 가능해지니 LDAP 서버에 등록된 사용자(만) ERP 서버에 로그인을 할수 있게 되는것입니다.

LDAP는 바이너리 프로토콜

LDAP는 메세지 내용을 ASN.1(Abstract Syntax Notation One)이라는 언어로 표현하고, 이 메세지를 BER(Basic Encoding Rules) 라는 포맷으로 인코딩해서 메세지를 주고받는다고 하는데요,
이 BER 인코딩의 결과가 바이너리라서 이 내용을 사람이 보고 읽을수 있는 텍스트는 아닙니다.

여기서 중요한건 LDAP는 “사람이 읽을수 없는 형태”로 데이터를 주고 받는다는 사실입니다. (binary vs text)

우리는 용도에 따라 적합하게 설계된 프로토콜 위에서 데이터를 주고받으면 그만이니까요 😁

ASN.1 & BER의 비교 (spec 참고)

위에서 언급한 이 두가지 요소가 어떻게 다른지 조금 알아보겠습니다.

ASN.1은 프로그래밍 언어와 비슷한 반면, BER은 해당 언어의 컴파일러와 비슷합니다.
컴파일러의 결과물은 플랫폼마다 다를수 있지만 대부분의 high level 프로그래밍 언어는 플랫폼에 종속적이지 않습니다.

프로그래밍 언어인 C언어로 비유하자면, C로 작성한 코드는 C언어가 아니며 그저 C로 작성된것입니다.
그리고 C로 작성된 코드는 특정 플랫폼(ex. Intel x86, …)에서 실행하려면 플랫폼에 맞는 프로그램으로 컴파일되어야만 합니다.

ASN.1BER의 관계 또한 이것과 비슷합니다.

  • C → ASN.1
  • C로 작성한 코드 → ASN.1로 표현한 표준
  • 컴파일러 → BER
  • 프로그램 → BER 인코딩 결과

이것을 적용하여 다시 설명해보겠습니다.

ASN.1(C)은 표준(코드)을 작성하는 언어입니다. 그리고 여기서 말하는 작성된 표준(작성된 코드)은 ASN.1(C)이 아니지만 ASN.1(C)으로 작성된것이죠.
ASN.1(C)으로 작성한 표준(코드)을 어떤 의미로 보면 “ASN.1 데이터(C 파일이나 코드조각)” 라고 볼수도 있습니다.
(그러나 이제 우리는 엄밀히 따지면 “ASN.1 데이터” 와 “ASN.1”는 다르다고 이야기 할 수 있게 되었죠.)

그러나 이렇게 작성된 “ASN.1 데이터(C 파일이나 코드조각)”를 LAN으로 전송하려면(실행하려면) Octet string같은걸로 인코딩(컴파일)해야 합니다. (정확히는 BER Values 참고)
이것을 BER(컴파일러)으로 인코딩(컴파일)하여 LAN을 통해 클라이언트에게 전달합니다.

두가지 개념의 설명을 돕기 위해 비유를 해봤는데, 이해하는데 도움이 되었길 바랍니다 🙏

또한 LDAP에서의 ASN.1, BER의 자세한 동작 원리를 설명한 글들은 많은듯 하니 LDAP 서버를 구현하기 위한 개념 설명은 여기까지만 하겠습니다.

LDAP는 비동기 프로토콜

HTTP는 기본적으로 동기 프로토콜이라 하나의 요청을 보내면 해당 커넥션은 응답을 받을때까지 다른 동작을 하지 않고 기다립니다.

그러나 LDAP는 하나의 커넥션에서 여러 메세지를 주고 받을수 있고, 각 메세지에 대한 응답도 타이밍이 제각각일수 있습니다.
이런 특성을 가지니 당연히 메세지마다 ID가 존재해서 어떤 요청에 대한 응답인지 알수 있겠죠.

참고 1, 참고 2를 보면 동기 프로토콜과의 제일 큰 차이점은 클락의 동기화가 필요 없다는 부분인데, LDAP 서버를 구현해보지 않았기 때문인지… 찾아봐도 당장은 어떤 특성을 위해 비동기 프로토콜로 설계 했는지는 모르겠네요.
어쩌면 굳이 동기 프로토콜을 사용할 필요가 없기 때문 일수도 있겠지만 당장 큰 문제는 없기 때문에 다음에 찾아봐야겠습니다.

LDAP의 디렉터리 구조

구성 요소를 알아보기 전에 어떻게 저장을 하는지 구경해보고 가겠습니다.

엔트리는 계층적 트리 구조로, 아래와 같이 구성됩니다.

dc:         com
|
ecubelabs
/ \
ou: People servers
/ \ ..
cn: .. Tim.Kang

또한 신뢰성이나 가용성을 개선하기 위해 쉽게 복제할수 있는 아키텍쳐로 이루어져 있다고 합니다.

LDAP의 구성요소

이정도로 LDAP를 구현하기 위한 내용을 알았으면 슬슬 LDAP를 구현할 수 있어야겠죠.
그러려면 LDAP가 어떤 요소로 이루어졌는지 알아야합니다.

LDAP.com에서 말하는 기본 컨셉은 이렇습니다.

Entry

LDAP 서버는 n개의 엔트리가 Tree 구조로 들어있습니다.

각 엔트리는 DN라 칭해지는 고유 ID와 n개의 속성이 들어있는데, 각 특성은 아래와 같습니다.

  • DN: 이 값을 통해 어디에 속한 엔트리인지 알 수 있습니다.
  • Attribute (속성): 각 속성은 n개 이상 존재할 수 있으며 Dictionary의 Key/value처럼 이름, 값을 가지고 있습니다.

더 자세한 설명은 아래를 참고하세요.

DN (Distinguished name, 고유명), RDN (Relative DN)

데이터베이스의 PK와 비슷하며, 해당 항목과 복잡한 트리 구조인 DIT(Directory Information Tree) 계층 구조에서 해당 항목과 항목의 위치를 고유하게 식별하기 위해 존재합니다.
그리고 LDAP에서의 DN은 파일 시스템의 파일 경로와 매우 유사합니다.

/users/tim/friends.md

이러한 경로 전체가 DN이라고 볼 수 있고, 여기서 “/users/tim”이 상위 엔트리의 DN이라고 보면 됩니다.

또한 LDAP의 DN은 RDN을 0개 이상 가질수 있습니다.
각 RDN은 1개 이상의 속성을 가지고 있습니다.

예를 들어 “uid=tim.kang”는 “uid”라는 속성에 “tim.kang”라는 값을 가진 RDN을 뜻합니다.
당연히 여러개의 속성을 가질수도 있는데, 더 자세한 예시는 컨셉 페이지를 참고하세요.

Attributes

속성은 특정 항목에 대한 데이터를 보유합니다.
각 속성은 속성 유형과 0개 이상의 실제 데이터 값이 있습니다.

이 실제 데이터에는 Attribute option(속성 옵션)도 포함되는데,
이 부분은 자세히 짚고 넘어가지 않을 예정이라 LDAP 스키마 문서속성 문법 문서를 참고하세요.

Object Classes

객체 클래스는 특정 유형의 객체나 프로세스, 엔티티 등과 관련 될 수있는 속성 유형의 Collection을 지정하는 스키마 요소입니다.
모든 항목에는 도메인 모델처럼 객체 종류를 나타내는 객체 클래스가 있으며, 0개 이상의 보조 객체가 있을수도 있습니다.

속성 유형과 마찬가지로 객체 클래스에는 객체 식별자(OID)가 존재해야 하지만 이름이 0개일수도 있다고 합니다.
객체 클래스에는 필요한 속성 유형 Sets이 필요하다고 하는데, optional 하게 선택될 수 있는 유형 sets도 존재한다고 합니다.

Object Identifiers (OIDs, 객체 식별자)

OID는 문서에서 설명하고 있는대로 LDAP 프로토콜 뿐만 아니라 다른 컴퓨팅 영역에서도 다양한 요소를 고유하게 식별하는 용도로 사용되는 문자열입니다.
또한 OID는 마침표로 구분 된 숫자로 구성되는데, “1.2.840.113556.1.4.473”같은 형태로 구성됩니다. (자세한 스펙은 RFC4512 참고)

LDAP에서는 OID가 아래같은 용도를 위해 사용된다고 합니다. (RFC4520에도 이렇게 정의되어 있습니다.)

  • LDAP 요청 및 응답 제어의 식별자 역할
  • LDAP extended 요청 및 응답 유형의 식별자 역할
  • 객체 클래스같은 스키마 요소에 대한 식별자 역할

만약 OID를 만들고싶다면 https://pen.iana.org/pen/PenApplication.page 에서 양식을 작성하여 제출하고 기업에 대한 OID를 할당받아야 합니다.
Base OID를 받고나면 규칙에 따라 OID를 할당해서 사용하면 됩니다. (규칙은 OID 문서 참고)
기업에 할당된 모든 OID 목록을 보려면 https://www.iana.org/assignments/enterprise-numbers/enterprise-numbers 를 확인하면 됩니다.

OID를 제대로 할당받지 않고 가짜 OID를 사용할수도 있지만, 테스트를 하는 경우를 제외하더라도 사용하는 시스템에 따라 형식이 맞지 않는 OID를 지원하지 않는 경우가 있으니 권장하지 않는다고 합니다.

그러나, 우리가 일반적으로 사용하는 OID는 이미 LDAP OID Reference guide에 잘 정리되어 있으며, 검색을 하고싶으면 OID Repository 또는 OID Reference 사이트에서 찾을 수 있습니다 🥰 (검색 예시)

LDAP CLI

LDAP 서버를 구축해도 테스트를 하려면 ldap cli를 사용하거나 다른 클라이언트 프로그램을 사용해야 합니다.

다행히 macOS인 경우 기본적으로 ldap cli가 설치되어 있습니다.
명령어는 Oracle LDAP CLI Commands 페이지를 보면 간단히 따라할 수 있습니다.

예를 들어, Search 요청은 아래처럼 할 수 있습니다.

$ ldapsearch -h <ldap server host> -p 636 -s sub -D "cn=<Your RootDN>" -w <Your Password> -b "ou=people, dc=ecubelabs, dc=com" "objectclass=*"# extended LDIF
#
# LDAPv3
# base <ou=people,dc=ecubelabs,dc=com> with scope subtree
# filter: objectclass=*
# requesting: ALL
#
# people, ecubelabs.com
dn: ou=people, dc=ecubelabs, dc=com
description: All people in organization
objectclass: organizationalunit
ou: people
# tim, people, ecubelabs.com
dn: cn=tim, ou=people, dc=ecubelabs, dc=com
cn: tim
...
userpassword: ...

그리고 이것은 아래에서 다루는 명령어를 직접 써보기 위해 사용해볼 예정입니다.

LDIF

LDAP CLI를 사용해서 디렉터리 서버에 있는 데이터를 조작하려면 LDIF라는 표현 형식을 알아둬야 합니다.

LDAP는 바이너리 프로토콜이라 테스트를 하려면 사람이 직접 표현하고 쓰기가 어렵습니다.
그래서 이걸 해결하기 위해 LDIF가 생겼는데, AWS의 디렉터리 서비스 문서에는 아래와 같이 설명하고 있습니다.

LDIF 파일은 LDAP(Lightweight Directory Access Protocol) 디렉터리 콘텐츠 및 업데이트 요청을 표현하기 위한 표준 일반 텍스트 데이터 교환 형식입니다. LDIF는 레코드 세트(객체 또는 엔트리당 한 개의 레코드)로서 디렉터리 콘텐츠를 전달합니다. 또한 추가, 수정, 삭제, 이름 바꾸기와 같은 업데이트 요청들을 레코드 세트(업데이트 요청당 한 개의 레코드)로서 표현합니다.

LDIF를 구성하는 필드는 일반적으로 아래와 같습니다.

DN

위에서 언급했던 그 DN(Distinguished name)과 똑같다고 생각하면 됩니다.
하나의 엔트리를 표현한 LDIF에서는 1개의 DN 필드가 존재합니다.

DC

말 그대로 도메인의 구성요소(Domain component)를 뜻하는 필드인데, DN으로 표현하면 보통 아래와 같이 구성합니다.

dc=ecubelabs,dc=com

특별한 경우가 없다면 대부분의 디렉토리 서비스에서는 이런식의 DC를 사용할것으로 생각됩니다.

OU

Organizational unit의 약자입니다.
조직을 구성하는 개체들의 묶음으로 나눠서 관리하는게 일반적인것으로 보입니다.
그리고 일반적으로 DC의 하위에 위치하도록 관리합니다.

“people”이라는 OU가 있다고 가정하고 DN으로 표현하면 아래와 같이 구성됩니다.

ou=people,dc=ecubelabs,dc=com

CN

Common name의 약자입니다.

보통은 LDAP 서버에 쿼리해서 가져오는 객체는 이 CN이 아닐까 싶습니다.
위키에서는 CN을 이렇게 설명하고 있습니다.

이것은 귀하가 쿼리하고 있는 개별 개체 (개인 이름, 회의실, 레시피 이름, 직책 등)를 나타냅니다.

이것 또한 DN으로 표현하면 아래와 같습니다.

cn=tim,ou=people,dc=ecubelabs,dc=com

DN의 표현?

앞서 각 필드를 DN으로 표현할 수 있다고 했습니다.
그리고 DN은 데이터베이스의 PK와 비슷하다고 했죠.
게다가 파일 시스템의 파일 경로와도 비슷하게 표현된다고 했습니다.

다시 한번 비교하면서 살펴보겠습니다.

/company/ecubelabs/people/tim.md

위와 같은 경로에 tim.md이라는 파일이 있고, 그 안에 여러 정보들이 들어있다고 생각해보겠습니다.

그럼 이걸 LDIF로 표현하면 아래와 같습니다.

dn: cn=tim, ou=people, dc=ecubelabs, dc=com
objectClass: localperson
...
cn: tim
ou: SW
...
userpassword: ...

DN만 보면 “cn=tim, ou=people, dc=ecubelabs, dc=com” 이라는 값으로 정의되는데, 경로를 역순으로 표현했다고 받아들여도 무방해보입니다.

LDIF -> “people” OU

아래는 people이라는 OU를 표현한 예시입니다.

dn: ou=people, o=ecubelabs
ou: people
description: All people in organisation
objectclass: organizationalunit

LDAP Operations

LDAP Operation Types 문서를 보면 10개의 명령어가 있는것을 알 수 있는데요, 여기서는 기본적인 커맨드에 대해서만 짚어보고 넘어가겠습니다. 😄

Bind

LDAP의 Bind 요청은 SASL 인증을 포함한 단순 자격증명(like id/pw)으로 인증하는 기능을 제공합니다.
클라이언트가 서버에 다른 유형의 요청을 하려면 해당 커넥션에서 Bind를 요청한 뒤에 사용해야 합니다. 이것은 다른 유형의 요청을 하기 전에 Bind를 수행할 필요가 없다는 이야기와 같습니다.

기본적으로 Bind 요청은 3가지의 동작이 포함됩니다.

  • 클라이언트가 사용 할 LDAP 프로토콜 버전을 서버가 알 수 있도록 명시합니다.
    이 값은 정수여야 하며, 최신 앱은 항상 3을 사용하도록 해야합니다.
  • 인증 할 사용자의 DN을 명시합니다.
    익명 인증인 경우 비워둘 수 있지만 보통은 입력해야 합니다.
  • 클라이언트가 인증할 자격 증명 정보를 명시합니다.
    단순 자격증명인 경우 앞서 DN에 명시한 사용자의 패스워드를 입력합니다.

Add

Add 요청을 하면 LDAP 서버의 계층적 트리 구조에 새 엔트리를 추가할 수 있습니다.

Add 요청을 하기 위해서는 DN과 해당 항목에 포함시킬 속성을 같이 넣어야 합니다.
우리는 테스트 할 때 위에서 말했던 LDIF 형식을 사용해야 합니다.

아래와 같은 파일이 있다고 가정합니다.

# people.ldif
dn: ou=people, dc=ecubelabs, dc=com
ou: people
description: All people in organisation
objectclass: organizationalunit

그리고 터미널에서 아래와 같이 요청을 할 수 있습니다.

$ ldapadd -h <ldap server host> -p 636 -D "cn=<Your RootDN>" -w <Your Password> -f ./people.ldifadding new entry "ou=people, dc=ecubelabs, dc=com"

Search

Search 요청은 LDAP 서버가 가지고 있는 데이터를 쿼리해서 가져올수 있게 해줍니다.

위에서 처음 들었던 예시가 바로 이것인데요, CLI를 사용하면 아래처럼 됩니다.

$ ldapsearch -h <ldap server host> -p 636 -s sub -D "cn=<Your RootDN>" -w <Your Password> -b "ou=people, dc=ecubelabs, dc=com" "objectclass=*"# extended LDIF
#
# LDAPv3
# base <ou=people,dc=ecubelabs,dc=com> with scope subtree
# filter: objectclass=*
# requesting: ALL
#
# people, ecubelabs.com
dn: ou=people, dc=ecubelabs, dc=com
description: All people in organization
objectclass: organizationalunit
ou: people
# tim, people, ecubelabs.com
dn: cn=tim, ou=people, dc=ecubelabs, dc=com
cn: tim
...
username: tim
userpassword: ...

“-s” 플래그의 옵션으로 “base”, “one”, “sub”라는 값이 존재하는데, 이 값들은 검색을 어떤 영역까지 할지에 대한 옵션입니다.
이 외에도 다양한 기능이 존재하니 LDAP Search Operation 페이지를 참고하세요 🙂

--

--