This commit is contained in:
yabo0812 2025-10-25 17:14:30 +09:00
commit f277ec6ec9
11 changed files with 5627 additions and 4646 deletions

View File

@ -3,8 +3,54 @@
<ExternalSystemSettings> <ExternalSystemSettings>
<option name="env"> <option name="env">
<map> <map>
<!-- Database Configuration -->x
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_HOST" value="20.214.121.121" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="userdb" />
<entry key="DB_USERNAME" value="hgzerouser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" /> <entry key="DB_PASSWORD" value="Hi5Jessica!" />
<entry key="JWT_SECRET" value="my-super-secret-jwt-key-for-hgzero-meeting-service-2024" /> <entry key="JWT_SECRET" value="my-super-secret-jwt-key-for-hgzero-meeting-service-2024" />
<!-- JPA Configuration -->
<entry key="SHOW_SQL" value="true" />
<entry key="JPA_DDL_AUTO" value="update" />
<!-- Redis Configuration -->
<entry key="REDIS_HOST" value="20.249.177.114" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="1" />
<!-- Server Configuration -->
<entry key="SERVER_PORT" value="8081" />
<!-- Redis Configuration -->
<entry key="REDIS_HOST" value="20.249.177.114" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="1" />
<!-- Spring Profile -->
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
<!-- Azure EventHub Configuration -->
<entry key="EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=" />
<entry key="EVENTHUB_NAME" value="hgzero-eventhub-name" />
<entry key="EVENTHUB_CONSUMER_GROUP" value="$Default" />
<!-- Logging Configuration -->
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_WEB" value="INFO" />
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
<entry key="LOG_LEVEL_WEBSOCKET" value="DEBUG" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
<entry key="LOG_FILE" value="logs/user-service.log" />
<entry key="LOG_MAX_FILE_SIZE" value="10MB" />
<entry key="LOG_MAX_HISTORY" value="7" />
<entry key="LOG_TOTAL_SIZE_CAP" value="100MB" />
</map> </map>
</option> </option>
<option name="executionName" /> <option name="executionName" />

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,91 @@
package com.unicorn.hgzero.user.config;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ReadFrom;
import io.lettuce.core.SocketOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis 설정
* Lettuce 클라이언트를 사용하여 Redis 연결 템플릿 구성
*/
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host:localhost}")
private String host;
@Value("${spring.data.redis.port:6379}")
private int port;
@Value("${spring.data.redis.password:}")
private String password;
@Value("${spring.data.redis.database:0}")
private int database;
/**
* Redis 연결 팩토리 설정
* ReadFrom.MASTER로 설정하여 마스터에서만 읽기/쓰기 수행
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// Redis Standalone 설정
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setDatabase(database);
// 비밀번호가 있는 경우에만 설정
if (password != null && !password.isEmpty()) {
redisStandaloneConfiguration.setPassword(password);
}
// Lettuce 클라이언트 설정
SocketOptions socketOptions = SocketOptions.builder()
.connectTimeout(Duration.ofSeconds(10))
.build();
ClientOptions clientOptions = ClientOptions.builder()
.socketOptions(socketOptions)
.build();
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.clientOptions(clientOptions)
.commandTimeout(Duration.ofSeconds(5))
.readFrom(ReadFrom.MASTER) // 마스터에서만 읽기/쓰기
.build();
return new LettuceConnectionFactory(redisStandaloneConfiguration, clientConfig);
}
/**
* RedisTemplate 설정
* String 키와 값을 사용하는 템플릿 구성
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// String 직렬화 설정
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(stringSerializer);
template.afterPropertiesSet();
return template;
}
}

View File

@ -12,6 +12,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@ -69,7 +71,8 @@ public class SecurityConfig {
// 허용할 헤더 // 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList( configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept", "Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers" "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers",
"X-User-Id", "X-User-Name", "X-User-Email"
)); ));
// 자격 증명 허용 // 자격 증명 허용
@ -82,4 +85,24 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
/**
* HttpFirewall 설정
* 한글을 포함한 모든 문자를 헤더 값으로 허용
*/
@Bean
public HttpFirewall allowUrlEncodedSlashHttpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
// 한글을 포함한 모든 문자를 허용하도록 설정
firewall.setAllowedHeaderValues(header -> true);
// URL 인코딩된 슬래시 허용
firewall.setAllowUrlEncodedSlash(true);
// 세미콜론 허용
firewall.setAllowSemicolon(true);
return firewall;
}
} }

View File

@ -82,6 +82,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return path.startsWith("/actuator") || return path.startsWith("/actuator") ||
path.startsWith("/swagger-ui") || path.startsWith("/swagger-ui") ||
path.startsWith("/v3/api-docs") || path.startsWith("/v3/api-docs") ||
path.equals("/health"); path.equals("/health") ||
path.equals("/api/v1/auth/login");
} }
} }

View File

@ -0,0 +1,209 @@
package com.unicorn.hgzero.user.config.ldap;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.ldap.core.AttributesMapper;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.filter.EqualsFilter;
import org.springframework.ldap.filter.Filter;
import org.springframework.ldap.support.LdapUtils;
import org.springframework.stereotype.Component;
import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import java.util.List;
/**
* LDAP 인증 사용자 정보 조회 클래스
*
* LDAP 서버와의 인증 처리 사용자 속성 조회 기능 제공
* - LDAPS (포트 636) 사용
* - 타임아웃: 5초
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LdapAuthenticator {
private final LdapTemplate ldapTemplate;
@Value("${spring.ldap.user-dn-pattern:uid={0},ou=people}")
private String userDnPattern;
@Value("${spring.ldap.user-search-base:ou=people}")
private String userSearchBase;
@Value("${spring.profiles.active:dev}")
private String activeProfile;
/**
* LDAP 인증 사용자 정보 조회
*
* @param username 사용자 ID
* @param password 비밀번호
* @return LDAP 사용자 정보
* @throws BusinessException 인증 실패
*/
public LdapUserDetails validateCredentials(String username, String password) {
log.debug("LDAP 인증 시도: username={}, profile={}", username, activeProfile);
// 개발 환경에서는 LDAP 인증 건너뛰기
if ("dev".equals(activeProfile)) {
log.info("개발 환경 - LDAP 인증 건너뛰기: username={}", username);
return createDefaultUserDetails(username);
}
try {
// 1. LDAP bind (인증)
boolean authenticated = authenticate(username, password);
if (!authenticated) {
log.warn("LDAP 인증 실패: username={}", username);
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED);
}
log.info("LDAP 인증 성공: username={}", username);
// 2. 사용자 정보 조회
LdapUserDetails userDetails = searchUser(username);
log.debug("LDAP 사용자 정보 조회 완료: username={}, email={}", username, userDetails.getEmail());
return userDetails;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("LDAP 인증 중 오류 발생: username={}, error={}", username, e.getMessage(), e);
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED, "LDAP 인증 중 오류가 발생했습니다.");
}
}
/**
* LDAP 인증 수행
*
* @param username 사용자 ID
* @param password 비밀번호
* @return 인증 성공 여부
*/
private boolean authenticate(String username, String password) {
try {
// EqualsFilter를 사용하여 uid로 검색
Filter filter = new EqualsFilter("uid", username);
// LDAP 인증 수행
// Protocol: LDAPS (636), Timeout: 5s (LdapTemplate 설정에서 관리)
boolean authenticated = ldapTemplate.authenticate(
userSearchBase,
filter.encode(),
password
);
return authenticated;
} catch (Exception e) {
log.error("LDAP bind 실패: username={}, error={}", username, e.getMessage());
return false;
}
}
/**
* LDAP에서 사용자 정보 조회
*
* 조회 속성:
* - cn (Common Name): 사용자 이름
* - mail: 이메일
* - department: 부서
* - title: 직급
*
* @param username 사용자 ID
* @return LDAP 사용자 정보
*/
private LdapUserDetails searchUser(String username) {
log.debug("LDAP 사용자 정보 조회 시작: username={}", username);
try {
// uid로 사용자 검색
Filter filter = new EqualsFilter("uid", username);
// 사용자 속성 조회
List<LdapUserDetails> userList = ldapTemplate.search(
userSearchBase,
filter.encode(),
new UserAttributesMapper(username)
);
if (userList.isEmpty()) {
log.warn("LDAP에서 사용자를 찾을 수 없음: username={}", username);
// 사용자를 찾을 없어도 기본 정보로 생성
return createDefaultUserDetails(username);
}
return userList.get(0);
} catch (Exception e) {
log.error("LDAP 사용자 정보 조회 실패: username={}, error={}", username, e.getMessage(), e);
// 오류 발생 기본 정보로 생성
return createDefaultUserDetails(username);
}
}
/**
* 기본 사용자 정보 생성 (LDAP 조회 실패 )
*
* @param username 사용자 ID
* @return 기본 사용자 정보
*/
private LdapUserDetails createDefaultUserDetails(String username) {
return LdapUserDetails.builder()
.userId(username)
.username(username)
.email(username + "@example.com")
.department("미지정")
.title("미지정")
.build();
}
/**
* LDAP 속성을 LdapUserDetails로 매핑하는 Mapper
*/
private static class UserAttributesMapper implements AttributesMapper<LdapUserDetails> {
private final String userId;
public UserAttributesMapper(String userId) {
this.userId = userId;
}
@Override
public LdapUserDetails mapFromAttributes(Attributes attributes) throws NamingException {
return LdapUserDetails.builder()
.userId(userId)
.username(getAttributeValue(attributes, "cn", userId))
.email(getAttributeValue(attributes, "mail", userId + "@example.com"))
.department(getAttributeValue(attributes, "department", "미지정"))
.title(getAttributeValue(attributes, "title", "미지정"))
.build();
}
/**
* 속성 조회 (속성이 없으면 기본값 반환)
*
* @param attributes 속성 목록
* @param attributeName 속성 이름
* @param defaultValue 기본값
* @return 속성
*/
private String getAttributeValue(Attributes attributes, String attributeName, String defaultValue) {
try {
if (attributes.get(attributeName) != null) {
Object value = attributes.get(attributeName).get();
return value != null ? value.toString() : defaultValue;
}
} catch (NamingException e) {
// 속성이 없거나 오류 발생 기본값 반환
}
return defaultValue;
}
}
}

View File

@ -0,0 +1,43 @@
package com.unicorn.hgzero.user.config.ldap;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* LDAP에서 조회한 사용자 정보 DTO
*
* LDAP 인증 성공 사용자 속성 정보를 담는 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LdapUserDetails {
/**
* 사용자 ID (uid)
*/
private String userId;
/**
* 사용자 이름 (cn - Common Name)
*/
private String username;
/**
* 이메일 (mail)
*/
private String email;
/**
* 부서 (department)
*/
private String department;
/**
* 직급 (title)
*/
private String title;
}

View File

@ -138,4 +138,15 @@ public class UserEntity extends BaseTimeEntity {
this.failedLoginAttempts = 0; this.failedLoginAttempts = 0;
this.lastLoginAt = LocalDateTime.now(); this.lastLoginAt = LocalDateTime.now();
} }
/**
* LDAP 정보로 사용자 정보 업데이트
*
* @param username 사용자 이름
* @param email 이메일
*/
public void updateFromLdap(String username, String email) {
this.username = username;
this.email = email;
}
} }

View File

@ -3,6 +3,8 @@ package com.unicorn.hgzero.user.service;
import com.unicorn.hgzero.common.exception.BusinessException; import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode; import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.common.security.JwtTokenProvider; import com.unicorn.hgzero.common.security.JwtTokenProvider;
import com.unicorn.hgzero.user.config.ldap.LdapAuthenticator;
import com.unicorn.hgzero.user.config.ldap.LdapUserDetails;
import com.unicorn.hgzero.user.domain.User; import com.unicorn.hgzero.user.domain.User;
import com.unicorn.hgzero.user.dto.*; import com.unicorn.hgzero.user.dto.*;
import com.unicorn.hgzero.user.repository.entity.UserEntity; import com.unicorn.hgzero.user.repository.entity.UserEntity;
@ -37,7 +39,7 @@ import io.jsonwebtoken.security.Keys;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserServiceImpl implements UserService { public class UserServiceImpl implements UserService {
private final LdapTemplate ldapTemplate; private final LdapAuthenticator ldapAuthenticator;
private final UserRepository userRepository; private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
@ -64,7 +66,7 @@ public class UserServiceImpl implements UserService {
public LoginResponse login(LoginRequest request) { public LoginResponse login(LoginRequest request) {
log.info("로그인 시도: userId={}", request.getUserId()); log.info("로그인 시도: userId={}", request.getUserId());
// 사용자 조회 또는 생성 // 사용자 조회
UserEntity userEntity = userRepository.findById(request.getUserId()) UserEntity userEntity = userRepository.findById(request.getUserId())
.orElse(null); .orElse(null);
@ -81,10 +83,12 @@ public class UserServiceImpl implements UserService {
} }
} }
// LDAP 인증 // LDAP 인증 사용자 정보 조회
LdapUserDetails ldapUserDetails;
try { try {
authenticateWithLdap(request.getUserId(), request.getPassword()); ldapUserDetails = ldapAuthenticator.validateCredentials(request.getUserId(), request.getPassword());
log.info("LDAP 인증 성공: userId={}", request.getUserId()); log.info("LDAP 인증 성공: userId={}, username={}, email={}",
request.getUserId(), ldapUserDetails.getUsername(), ldapUserDetails.getEmail());
} catch (Exception e) { } catch (Exception e) {
log.error("LDAP 인증 실패: userId={}, error={}", request.getUserId(), e.getMessage()); log.error("LDAP 인증 실패: userId={}, error={}", request.getUserId(), e.getMessage());
@ -99,15 +103,26 @@ public class UserServiceImpl implements UserService {
// 사용자 정보 조회 또는 생성 // 사용자 정보 조회 또는 생성
if (userEntity == null) { if (userEntity == null) {
// LDAP에서 추가 정보 조회 (실제 환경에서는 LDAP에서 이메일 등을 가져와야 ) // LDAP에서 조회한 정보로 신규 사용자 등록
log.info("신규 사용자 등록: userId={}, username={}, email={}, department={}, title={}",
ldapUserDetails.getUserId(), ldapUserDetails.getUsername(),
ldapUserDetails.getEmail(), ldapUserDetails.getDepartment(), ldapUserDetails.getTitle());
userEntity = UserEntity.builder() userEntity = UserEntity.builder()
.userId(request.getUserId()) .userId(ldapUserDetails.getUserId())
.username(request.getUserId()) // LDAP에서 가져온 실제 이름 사용 .username(ldapUserDetails.getUsername())
.email(request.getUserId() + "@example.com") // LDAP에서 가져온 실제 이메일 사용 .email(ldapUserDetails.getEmail())
.authority("USER") .authority("USER")
.locked(false) .locked(false)
.failedLoginAttempts(0) .failedLoginAttempts(0)
.build(); .build();
} else {
// 기존 사용자의 경우 LDAP 정보로 업데이트 (이메일, 이름 등이 변경될 있음)
log.debug("기존 사용자 정보 업데이트: userId={}", request.getUserId());
userEntity.updateFromLdap(
ldapUserDetails.getUsername(),
ldapUserDetails.getEmail()
);
} }
// 로그인 성공 기록 // 로그인 성공 기록
@ -124,7 +139,7 @@ public class UserServiceImpl implements UserService {
saveRefreshToken(user.getUserId(), refreshToken); saveRefreshToken(user.getUserId(), refreshToken);
log.info("로그인 성공: userId={}", request.getUserId()); log.info("로그인 성공: userId={}", request.getUserId());
// 로그인 이벤트 발행 // 로그인 이벤트 발행
eventPublishService.publishLoginEvent(user.getUserId(), user.getUsername(), System.currentTimeMillis()); eventPublishService.publishLoginEvent(user.getUserId(), user.getUsername(), System.currentTimeMillis());
@ -251,22 +266,6 @@ public class UserServiceImpl implements UserService {
} }
} }
/**
* LDAP 인증 수행
*/
private void authenticateWithLdap(String userId, String password) {
// LDAP DN 생성
String userDn = userDnPattern.replace("{0}", userId);
// LDAP 인증
Filter filter = new EqualsFilter("uid", userId);
boolean authenticated = ldapTemplate.authenticate("", filter.encode(), password);
if (!authenticated) {
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED);
}
}
/** /**
* Access Token 생성 * Access Token 생성
*/ */

View File

@ -8,7 +8,7 @@ spring:
datasource: datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:userdb} url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:userdb}
username: ${DB_USERNAME:hgzerouser} username: ${DB_USERNAME:hgzerouser}
password: ${DB_PASSWORD:Hi5Jessica!} password: ${DB_PASSWORD:}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari: hikari:
maximum-pool-size: 20 maximum-pool-size: 20
@ -27,7 +27,7 @@ spring:
use_sql_comments: true use_sql_comments: true
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc: jdbc:
time_zone: UTC time_zone: Asia/Seoul
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${JPA_DDL_AUTO:update}
database: postgresql database: postgresql