🔒 보안

LDAP

Lightweight Directory Access Protocol

디렉토리 서비스에 접근하기 위한 프로토콜로, 사용자 인증, 조직 구조 관리, 권한 부여 등 기업 환경의 중앙 집중식 사용자 관리에 널리 사용됩니다.

📖 상세 설명

LDAP(Lightweight Directory Access Protocol)은 TCP/IP 기반의 디렉토리 서비스 접근 프로토콜입니다. X.500 표준을 간소화한 것으로, 읽기 연산에 최적화되어 있어 사용자 인증, 조직 정보 조회, 애플리케이션 설정 저장 등에 널리 사용됩니다. 기본 포트는 389(LDAP), 636(LDAPS-SSL/TLS)입니다.

LDAP 디렉토리는 트리 구조(DIT, Directory Information Tree)로 데이터를 저장합니다. 최상위부터 DC(Domain Component), OU(Organizational Unit), CN(Common Name) 등의 요소로 구성됩니다. 예를 들어 cn=홍길동,ou=engineering,dc=example,dc=com은 example.com 도메인의 engineering 부서에 속한 홍길동 사용자를 나타냅니다.

대표적인 LDAP 서버로는 Microsoft Active Directory(Windows 환경의 사실상 표준), OpenLDAP(오픈소스), 389 Directory Server(Red Hat), ApacheDS 등이 있습니다. 현대 환경에서는 AWS Directory Service, Azure AD 같은 클라우드 서비스도 LDAP 호환성을 제공합니다.

LDAP 인증의 일반적인 흐름은: 1) 사용자가 username/password 입력, 2) 애플리케이션이 LDAP 서버에 Bind 요청, 3) LDAP 서버가 인증 성공/실패 응답, 4) 필요 시 사용자 속성(그룹, 권한 등) 조회입니다. SSO(Single Sign-On) 환경에서는 Kerberos나 SAML과 함께 사용되기도 합니다.

💻 코드 예제

Python ldap3 라이브러리로 LDAP 연동

from ldap3 import Server, Connection, ALL, SUBTREE
from ldap3.core.exceptions import LDAPBindError, LDAPException
from typing import Optional, List, Dict

# LDAP 서버 설정
LDAP_SERVER = "ldap://ldap.example.com:389"
LDAP_BASE_DN = "dc=example,dc=com"
LDAP_BIND_DN = "cn=admin,dc=example,dc=com"
LDAP_BIND_PASSWORD = "admin_password"


class LDAPClient:
    """LDAP 연동 클라이언트"""

    def __init__(self, server_url: str, base_dn: str):
        self.server = Server(server_url, get_info=ALL)
        self.base_dn = base_dn
        self.conn = None

    def bind_admin(self, bind_dn: str, password: str) -> bool:
        """관리자 계정으로 바인딩"""
        try:
            self.conn = Connection(
                self.server,
                user=bind_dn,
                password=password,
                auto_bind=True
            )
            return True
        except LDAPBindError:
            return False

    def authenticate_user(self, username: str, password: str) -> Optional[dict]:
        """사용자 인증 (Bind 방식)"""
        # 1. 먼저 사용자 DN 검색
        user_dn = self._find_user_dn(username)
        if not user_dn:
            return None

        # 2. 사용자 계정으로 직접 바인딩 시도
        try:
            user_conn = Connection(
                self.server,
                user=user_dn,
                password=password,
                auto_bind=True
            )
            user_conn.unbind()

            # 3. 인증 성공 시 사용자 정보 반환
            return self.get_user_info(username)

        except LDAPBindError:
            return None  # 인증 실패

    def _find_user_dn(self, username: str) -> Optional[str]:
        """사용자 DN 검색"""
        search_filter = f"(sAMAccountName={username})"  # AD 환경
        # OpenLDAP인 경우: f"(uid={username})"

        self.conn.search(
            search_base=self.base_dn,
            search_filter=search_filter,
            search_scope=SUBTREE,
            attributes=['distinguishedName']
        )

        if self.conn.entries:
            return str(self.conn.entries[0].distinguishedName)
        return None

    def get_user_info(self, username: str) -> Optional[dict]:
        """사용자 상세 정보 조회"""
        search_filter = f"(sAMAccountName={username})"

        self.conn.search(
            search_base=self.base_dn,
            search_filter=search_filter,
            search_scope=SUBTREE,
            attributes=[
                'cn', 'mail', 'displayName', 'department',
                'memberOf', 'telephoneNumber', 'title'
            ]
        )

        if not self.conn.entries:
            return None

        entry = self.conn.entries[0]
        return {
            'username': username,
            'dn': str(entry.entry_dn),
            'name': str(entry.cn) if entry.cn else None,
            'email': str(entry.mail) if entry.mail else None,
            'display_name': str(entry.displayName) if entry.displayName else None,
            'department': str(entry.department) if entry.department else None,
            'title': str(entry.title) if entry.title else None,
            'groups': [str(g) for g in entry.memberOf] if entry.memberOf else []
        }

    def get_user_groups(self, username: str) -> List[str]:
        """사용자가 속한 그룹 목록 조회"""
        user_info = self.get_user_info(username)
        if not user_info:
            return []

        # memberOf에서 그룹 CN만 추출
        groups = []
        for group_dn in user_info.get('groups', []):
            # "CN=Developers,OU=Groups,DC=example,DC=com" -> "Developers"
            cn_part = group_dn.split(',')[0]
            if cn_part.startswith('CN='):
                groups.append(cn_part[3:])
        return groups

    def is_member_of(self, username: str, group_name: str) -> bool:
        """특정 그룹 멤버십 확인"""
        groups = self.get_user_groups(username)
        return group_name in groups

    def search_users(self, search_term: str, limit: int = 100) -> List[dict]:
        """사용자 검색"""
        search_filter = f"(&(objectClass=person)(|(cn=*{search_term}*)(mail=*{search_term}*)))"

        self.conn.search(
            search_base=self.base_dn,
            search_filter=search_filter,
            search_scope=SUBTREE,
            attributes=['cn', 'mail', 'sAMAccountName'],
            size_limit=limit
        )

        results = []
        for entry in self.conn.entries:
            results.append({
                'username': str(entry.sAMAccountName) if entry.sAMAccountName else None,
                'name': str(entry.cn) if entry.cn else None,
                'email': str(entry.mail) if entry.mail else None,
            })
        return results

    def close(self):
        """연결 종료"""
        if self.conn:
            self.conn.unbind()


# 사용 예시
if __name__ == "__main__":
    # LDAP 클라이언트 초기화
    ldap = LDAPClient(LDAP_SERVER, LDAP_BASE_DN)

    # 관리자로 바인딩
    if ldap.bind_admin(LDAP_BIND_DN, LDAP_BIND_PASSWORD):
        print("관리자 바인딩 성공")

        # 사용자 인증 테스트
        user = ldap.authenticate_user("hong.gildong", "user_password")
        if user:
            print(f"인증 성공: {user['display_name']} ({user['email']})")
            print(f"소속 그룹: {ldap.get_user_groups('hong.gildong')}")

            # 권한 확인
            if ldap.is_member_of("hong.gildong", "Administrators"):
                print("관리자 권한 있음")
        else:
            print("인증 실패")

        # 사용자 검색
        users = ldap.search_users("개발")
        for u in users:
            print(f"검색 결과: {u['name']} - {u['email']}")

        ldap.close()

🗣️ 실무에서 이렇게 말해요

인프라 엔지니어 "새 내부 시스템 인증을 AD 연동으로 해달라는 요청이 왔어요. LDAPS로 구성하면 될까요?"
보안팀 "네, 평문 LDAP은 안 되고 반드시 LDAPS(636 포트)나 StartTLS를 사용하세요. 서비스 계정은 최소 권한으로 만들고, 바인딩용 계정 비밀번호는 Vault에 저장해주세요."
백엔드 개발자 "그룹 기반 권한 체크도 필요해서 memberOf 속성 조회해야 하는데, AD는 nested group까지 고려해야 하죠?"
면접관 "LDAP을 사용한 인증 시스템을 구현해본 경험이 있나요?"
지원자 "네, 사내 시스템에서 Active Directory 연동 경험이 있습니다. 먼저 서비스 계정으로 바인딩해서 사용자 DN을 검색하고, 그 DN으로 사용자 비밀번호를 다시 바인딩해서 인증했어요. 그룹 멤버십으로 역할을 부여하고, LDAPS로 암호화 통신했습니다. Connection Pool도 구현해서 성능을 개선했어요."
리뷰어 "LDAP 검색 필터에 사용자 입력이 직접 들어가고 있어요. LDAP Injection 취약점이 있습니다."
작성자 "맞아요, escape_filter_chars() 같은 함수로 특수문자를 이스케이프 처리하겠습니다. 괄호, 별표, 백슬래시 같은 문자들이 위험하니까요."

⚠️ 주의사항

  • 암호화 필수: 평문 LDAP(389 포트)은 비밀번호가 네트워크에 노출됩니다. 반드시 LDAPS(636) 또는 StartTLS를 사용하세요.
  • LDAP Injection 방지: 검색 필터에 사용자 입력을 직접 넣으면 안 됩니다. 특수문자(*, (, ), \, NUL)를 이스케이프하여 인젝션 공격을 방지하세요.
  • 서비스 계정 권한: 바인딩용 서비스 계정은 읽기 권한만 부여하고, 비밀번호 변경이나 사용자 생성 권한은 제거하세요. 비밀번호는 안전하게 관리하세요.

📚 더 배우기