Back
Feb 1, 2021
LDAP 인증
아르테미스
LDAP
사내 LDAP 관련 프로젝트를 진행하기 위해 스터디한 내용을 공유하고자 이 글을 작성합니다.
LDAP은 Lightweight Directory Access Protocol의 약자로, 분산 디렉터리 서비스에서 사용자, 시스템, 네트워크, 서비스, 앱 등의 정보를 공유하기 위한 오픈 프로토콜입니다.
디렉터리는 계층 구조로 구성된 레코드의 집합으로 위 정보로 구성되었습니다.
Client, Server 구조를 기반으로 하고 있습니다.
기존 Directory Protocol인 X.500은 OSI Layer 기반이기 때문에 구조가 복잡하다는 단점이 있습니다.
LDAP은 TCP/IP Layer 기반이기 때문에 X.500보다 경량화된 구조를 가졌습니다.
이로 인해 Lightweight라는 수식어가 붙습니다.주로 사용자 정보를 중앙 집중적으로 관리하고 공유하는 데 사용합니다.
Directory Structure
dn: cn=John Doe,dc=example,dc=com
cn: John Doe
givenName: John
sn: Doe
telephoneNumber: +1 888 555 6789
telephoneNumber: +1 888 555 1232
mail: john@example.com
manager: cn=Barbara Doe,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: organizational
PersonobjectClass: person
objectClass: top
Entry
디렉터리에 보관된 정보의 기본 단위로, Entry가 나타내는 객체(사용자)에 대한 정보를 가지고 있는 속성 집합으로 구성됩니다.
Entry의 집합은 DN(Distinguished Name)을 기반으로 DIT(Directory Information Tree)라는 계층적인 트리 구조로 구성됩니다.
Attribute
속성은 타입, 값으로 구성되며 값은 여러 속성으로도 구성 가능합니다.
값은 타입의 정의된 규칙에 따라 정의됩니다.
예를 들면 givenName 타입의 경우 둘 이상의 값을 가질 수 있으며, 반드시 문자열이고 대소문자 구분을 하지 않는 규칙을 가지고 있습니다.
또한, givenName 타입은 동등 일치 규칙에 따라 “John”, “JOHN”과 같은 동일한 문자를 포함할 수 없습니다.
이 밖에도 LDAP 구현체에 따라 다양한 속성이 있습니다.
Operation
LDAP 기본 명령어는 다음과 같습니다.
종류
LDAP은 오픈 프로토콜이기 때문에 다음과 같은 여러 구현체가 존재합니다. 이 밖에도 다양한 구현체가 존재합니다.
프로젝트
개요
Spring Boot와 OpenLDAP을 활용하여 그룹 관리, 사용자 관리를 권한에 따라 사용할 수 있는 토큰 기반 REST API 서버 예제 프로젝트입니다.
개발 환경
※ 본 예제에서 LDAP은 OpenLDAP을 사용합니다.
LDAP DIT 구조
DIT 구조는 Blocko 회사(dn: dc=blocko, dc=io)를 기반으로 관리자 조직(dn: ou=admin,dc=blocko,dc=io), 사용자 조직(dn: ou=user,dc=blocko,dc=io)으로 나누어 구성하였습니다.
※ 파선으로 되어 있는 관리자(dn: cn=admin,dc=blocko,dc=io)는 위의 관리자 조직에 속하지 않는 LDAP 관리자입니다.
기능
환경 설정
LDAP 예제 프로젝트 Github를 다운로드하여 ldap 폴더에 있는 run.sh를 실행하여 OpenLDAP을 구동합니다.
OpenLDAP 구동 시 ldap/environment 폴더의 초기 설정 파일(init.ldif)로 위에서 설명한 DIT가 OpenLDAP에 저장됩니다.
dn: ou=user,dc=blocko,dc=io
objectClass: organizationalUnit
ou: user
dn: uid=tester@blocko.io,ou=user,dc=blocko,dc=io
objectClass: person
objectClass: uidObject
objectClass: simpleSecurityObject
cn: tester
sn: tester
uid: tester@blocko.io
userPassword: $2a$10$/9Y6QWruoI2NgsJANYaTVu.4HNfw2kEOPvzF0lQ/1W37kjeL6jx8.
dn: ou=admin,dc=blocko,dc=io
objectClass: organizationalUnit
ou: admin
dn: uid=8story8@blocko.io,ou=admin,dc=blocko,dc=io
objectClass: person
objectClass: uidObject
objectClass: simpleSecurityObject
cn: 8story8
sn: 8story8
uid: 8story8@blocko.io
userPassword: $2a$10$ED5ZCFU75CaDTqf9pPBQoOGUomIcuwowucM.CgJMq1McYFVv/ew.O
DIT가 OpenLDAP에 저장되었는지 확인하기 위해 LDAP Tool인 Apache Directory Studio를 다운로드합니다.
OpenLDAP과 Apache Directory Studio를 연동하기 위해 다음과 같이 설정한 뒤
Check Network Parameter 클릭 → Next > 버튼을 클릭합니다.
OpenLDAP 관리자의 ID(cn=admin,dc=blocko,dc=io), PW(admin)로 다음과 같이 설정한 뒤
Check Authentication 클릭 → Finish 버튼을 클릭합니다.
OpenLDAP과 Apache Directory Studio가 정상적으로 연동이 되면 OpenLDAP에 저장된 DIT를 다음과 같이 확인할 수 있습니다.
IntelliJ로 LDAP 예제 프로젝트 Github의 LdapExampleApplication을 구동한 뒤
http://localhost:8080/swagger-ui.html에 접속하면 그룹 관리, 사용자 관리 API를 테스트할 수 있습니다.
로그인을 제외한 모든 API는 Header의 JWT를 통한 인증을 해야 사용할 수 있기 때문에 로그인 API를 통해 다음과 같이 Access Token을 발급받습니다.
발급받은 Access Token을 다음과 같이 설정하여 그룹, 사용자 API를 사용할 때 해당 Access Token을 통해 API에 접근할 수 있도록 합니다.
의존성
해당 프로젝트에서 사용한 의존성 목록은 다음과 같습니다.
dependencies {
implementation "org.springframework.boot:spring-boot-starter-web:${springBootVersion}"
implementation "org.springframework.boot:spring-boot-starter-security:${springBootVersion}"
implementation "org.springframework.security:spring-security-ldap:${springSecurityVersion}"
implementation "io.jsonwebtoken:jjwt:${jwtVersion}"
implementation "io.springfox:springfox-swagger2:${swaggerVersion}"
implementation "io.springfox:springfox-swagger-ui:${swaggerVersion}"
compileOnly "org.projectlombok:lombok:${lombokVersion}"
annotationProcessor "org.projectlombok:lombok:${lombokVersion}"}
주요 코드
로그인
Login 엔드포인트에서 LoginForm(Email, Password)을 입력받아 LdapAuthenticationProvider로 전달하여 OpenLDAP에 인증을 한 뒤 그룹, 사용자 API를 사용하기 위한 Access Token을 발급받습니다.
인증 절차는 다음과 같습니다.
LdapTemplate을 통해 Email에 따른 사용자의 Email과 암호화된 Password를 OpenLDAP에서 반환받습니다.
List로 반환받는 이유는 LDAP에서 일종의 PK인 DN으로 검색하여 반환받는 것이 아니기 때문입니다.
이 부분은 추후 스터디를 통해 확인해봐야 할 것 같습니다.
반환받은 암호화되어 있는 Password와 사용자가 입력한 Password를 비교하여 인증 여부를 확인합니다.
인증에 성공하면 사용자 정보(권한 포함)를 OpenLDAP에서 반환받습니다.
사용자 정보를 바탕으로 Access Token을 사용자에게 발급합니다.
@PostMapping("/login")
@ApiOperation(value = "로그인", notes = "ALL")
public ResponseEntity<ResultForm> login(@RequestBody LoginForm loginForm) {
String email = loginForm.getEmail();
String password = loginForm.getPassword();
Authentication authentication =
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(email, password));
LdapUser user = (LdapUser) authentication.getPrincipal();
String token = ldapTokenUtil.create(user.getEmail());
return ResponseEntity.ok(new ResultForm(token));}
package io.blocko.auth;
import io.blocko.exception.UnauthenticatedUserException;
import io.blocko.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class LdapAuthenticationProvider implements AuthenticationProvider {
private final UserService userService;
/**
* 사용자 인증.
*
* @param authentication
* @return
* @throws AuthenticationException
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String email = authentication.getName();
String password = (String) authentication.getCredentials();
boolean isAuthenticated = userService.authenticate(email, password);
if (!isAuthenticated) {
throw new UnauthenticatedUserException();
} else {
LdapUser user = (LdapUser) userService.loadUserByEmail(email);
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
}
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class);
}
}
public boolean authenticate(String email, String rawPassword) {
LdapQuery query = LdapQueryBuilder.query().where("uid").is(email);
List<LoginForm> user =
template.search(
query,
new AbstractContextMapper<LoginForm>() {
@Override
protected LoginForm doMapFromContext(DirContextOperations ctx) {
String email = ctx.getStringAttribute("uid");
byte[] bytes = (byte[]) ctx.getObjectAttribute("userPassword");
String password = new String(bytes);
return LoginForm.builder().email(email).password(password).build();
}
});
if (user.size() != 1) {
throw new UserNotFoundException();
}
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
if (passwordEncoder.matches(rawPassword, user.get(0).getPassword())) {
return true;
} else {
return false;
}
}
그룹 등록
그룹명(group)의 존재 여부를 확인한 뒤 그룹을 OpenLDAP에 등록합니다.
이때 LdapTemplate의 bind 메서드를 사용하여 등록합니다.
(이 bind 메서드는 LDAP Bind가 아니라 LDAP Add입니다.)
등록되는 그룹 DIT는 다음과 같습니다.
public String register(String group) {
if (existsByGroup(group)) {
throw new GroupAlreadyExistsException();
}
Name ldapName = LdapNameBuilder.newInstance().add("ou", group.toLowerCase()).build();
DirContextAdapter
context = new DirContextAdapter(ldapName);
context.setAttributeValue("objectClass", "organizationalUnit");
context.setAttributeValue("ou", group.toLowerCase());
template.bind(context);
return group;
}
그룹 수정
그룹명(group), 수정될 그룹명(toBeUpdatedGroup)의 존재 여부 확인을 한 뒤 그룹명을 수정합니다.
이때 LdapTemplate의 rename 메서드를 사용하여 수정합니다.
(이 rename 메서드는 DN을 수정하므로 LDAP Modify DN입니다.)
public String update(GroupUpdate groupUpdate) {
String group = groupUpdate.getGroup();
String toBeUpdatedGroup = groupUpdate.getToBeUpdatedGroup();
if (!existsByGroup(group)) {
throw new GroupNotFoundException();
}
if (existsByGroup(toBeUpdatedGroup)) {
throw new GroupAlreadyExistsException();
}
Name name = LdapNameBuilder.newInstance().add("ou", group.toLowerCase()).build();
Name updatedName =
LdapNameBuilder.newInstance().add("ou", toBeUpdatedGroup.toLowerCase()).build(); DirContextOperations context = template.lookupContext(name); template.rename(context.getDn(), updatedName);
return toBeUpdatedGroup;
}
그룹 삭제
그룹 삭제 시 그룹에 속한 사용자가 없어야 삭제할 수 있으므로 해당 여부를 확인한 뒤 그룹을 삭제합니다.
이때 LdapTemplate의 unbind 메서드를 사용하여 삭제합니다.
(이 unbind 메서드는 LDAP Unbind가 아니라 LDAP Delete입니다.)
public String delete(String group) {
if (!existsByGroup(group)) {
throw new GroupNotFoundException();
}
List<UserInfo> userInfoList = findDetailByGroup(group);
if (userInfoList.size() > 0) {
throw new GroupInvalidDeleteException();
}
LdapName name = LdapNameBuilder.newInstance().add("ou", group.toLowerCase()).build();
template.unbind(name);
return group;
}
그룹 목록 조회
OpenLDAP에서 그룹명에 해당하는 organizationUnit을 검색하여 그룹 목록을 조회합니다.
public List<String> findAll() {
Filter filter = new EqualsFilter("objectClass", "organizationalUnit");
try {
List<String> groupList =
template.search(
LdapUtils.emptyLdapName(),
filter.encode(),
new AbstractContextMapper<String>() {
@Override
protected String doMapFromContext(DirContextOperations ctx) {
return ctx.getStringAttribute("ou").toUpperCase();
}
});
return groupList;
} catch (NameNotFoundException e) {
return new ArrayList<>();
}
}
그룹 상세 조회
그룹명(group)의 존재 여부를 확인한 뒤 그룹에 대한 상세 정보(그룹에 속한 사용자 목록)를 조회합니다.
if (!existsByGroup(group)) {
throw new GroupNotFoundException();
}
Name name = LdapNameBuilder.newInstance().add("ou", group.toLowerCase()).build();
Filter filter = new EqualsFilter("objectClass", "person");
try {
List<UserInfo> userInfoList =
template.search(
name,
filter.encode(),
new AbstractContextMapper<UserInfo>() {
@Override
protected UserInfo doMapFromContext(DirContextOperations ctx) {
String email = ctx.getStringAttribute("uid");
String name = ctx.getStringAttribute("cn");
String group = LdapUtils.getStringValue(ctx.getDn(), "ou").toUpperCase();
return UserInfo.builder().email(email).name(name).group(group).build();
}
});
return userInfoList;
} catch (NameNotFoundException e) {
return new ArrayList<>();
}
}
사용자 목록 조회
OpenLDAP에서 사용자에 해당하는 person을 검색하여 사용자 목록을 조회합니다.
public List<UserInfo> findAll() {
LdapQuery query = LdapQueryBuilder.query().where("objectClass").is("person");
try {
List<UserInfo> userList =
template.search(
query,
new AbstractContextMapper<UserInfo>() {
@Override
protected UserInfo doMapFromContext(DirContextOperations ctx) {
String email = ctx.getStringAttribute("uid");
String name = ctx.getStringAttribute("cn");
String group = LdapUtils.getStringValue(ctx.getDn(), "ou").toUpperCase();
return UserInfo.builder().email(email).name(name).group(group).build();
}
});
return userList;
} catch (NameNotFoundException e) {
return new ArrayList<>();
}
}
사용자 조회
사용자 Email의 존재 여부를 확인한 뒤 사용자에 대한 상세 정보(Email, 이름)를 조회합니다.
public UserInfo findByEmail(String email) {
LdapQuery query = LdapQueryBuilder.query().where("uid").is(email);
List<UserInfo> user =
template.search(
query,
new AbstractContextMapper<UserInfo>() {
@Override
protected UserInfo doMapFromContext(DirContextOperations ctx) {
String email = ctx.getStringAttribute("uid");
String name = ctx.getStringAttribute("cn");
String group = LdapUtils.getStringValue(ctx.getDn(), "ou").toUpperCase();
return UserInfo.builder().email(email).name(name).group(group).build();
}
});
if (user.size() != 0) {
throw new UserNotFoundException();
}
return user.get(0);
}
사용자 등록
사용자 Email, 그룹명(group)의 존재 여부를 확인한 뒤 해당 그룹의 사용자를 OpenLDAP에 등록합니다.
이때 등록되는 DIT는 다음과 같습니다.
public UserInfo register(UserRegistration userRegistration) {
String group = userRegistration.getGroup();
String email = userRegistration.getEmail();
String name = userRegistration.getName();
String password = userRegistration.getPassword();
if (!groupService.existsByGroup(group)) {
throw new GroupNotFoundException();
}
UserInfo user = findByGroupAndEmail(group, email).orElse(null);
if (user != null) {
throw new UserAlreadyExistsException();
}
Name ldapName =
LdapNameBuilder.newInstance().add("ou", group.toLowerCase()).add("uid", email).build();
DirContextAdapter context = new DirContextAdapter(ldapName);
context.setAttributeValues(
"objectClass", new String[] {"person", "uidObject", "simpleSecurityObject"});
context.setAttributeValue("cn", name);
context.setAttributeValue("sn", name);
context.setAttributeValue("uid", email);
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
context.setAttributeValue("userPassword", passwordEncoder.encode(password));
template.bind(context);
return UserInfo.builder().email(email).name(name).group(group.toUpperCase()).build();
}
사용자 수정
그룹명(group), 사용자 Email의 존재 여부 확인을 한 뒤 이름, 비밀번호를 수정합니다.
사용자의 속성을 수정하는 것이기 때문에 LdapTemplate의 modifyAttributes 메서드를 사용하여 수정합니다.
(이 modifyAttributes 메서드는 LDAP Modify입니다.)
public UserInfo update(UserUpdate userUpdate) {
String group = userUpdate.getGroup();
String email = userUpdate.getEmail();
if (!groupService.existsByGroup(group)) {
throw new GroupNotFoundException();
}
UserInfo userInfo = findByGroupAndEmail(group, email).orElse(null);
if (userInfo == null) {
throw new UserNotFoundException();
}
Name name =
LdapNameBuilder.newInstance().add("ou", group.toLowerCase()).add("uid", email).build();
DirContextOperations context = template.lookupContext(name);
context.setAttributeValue("cn", userUpdate.getToBeUpdatedName());
context.setAttributeValue("sn", userUpdate.getToBeUpdatedName());
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
context.setAttributeValue(
"userPassword", passwordEncoder.encode(userUpdate.getToBeUpdatedPassword()));
template.modifyAttributes(context);
return UserInfo.builder()
.email(email)
.name(userUpdate.getToBeUpdatedName())
.group(group)
.build();
}
사용자 삭제
사용자 삭제 시 그룹, 사용자 Email 존재 여부를 확인한 뒤 그룹에서 사용자를 삭제합니다.
이때 LdapTemplate의 unbind 메서드를 사용하여 삭제합니다.
(이 unbind 메서드는 LDAP Unbind가 아니라 LDAP Delete입니다.)
public UserInfo delete(UserDelete userDelete) {
UserInfo user = findByGroupAndEmail(userDelete.getGroup(), userDelete.getEmail()).orElse(null);
if (user == null) {
throw new UserNotFoundException();
}
LdapName name =
LdapNameBuilder.newInstance()
.add("ou", user.getGroup().toLowerCase())
.add("uid", user.getEmail())
.build();
template.unbind(name);
return user;
}
참고 자료
Spring Boot + LDAP Example Github