diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/mybatis/typehandler/StringListTypeHandler.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/mybatis/typehandler/StringListTypeHandler.java new file mode 100644 index 00000000..4363124c --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/mybatis/typehandler/StringListTypeHandler.java @@ -0,0 +1,50 @@ +package com.gltkorea.icebang.config.mybatis.typehandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; + +@MappedTypes(List.class) +@MappedJdbcTypes(JdbcType.VARCHAR) +public class StringListTypeHandler extends BaseTypeHandler> { + + @Override + public void setNonNullParameter( + PreparedStatement ps, int i, List parameter, JdbcType jdbcType) throws SQLException { + ps.setString(i, String.join(",", parameter)); + } + + @Override + public List getNullableResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return convertToList(value); + } + + @Override + public List getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return convertToList(value); + } + + @Override + public List getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return convertToList(value); + } + + private List convertToList(String value) { + if (value == null || value.trim().isEmpty()) { + return new ArrayList<>(); + } + return Arrays.asList(value.split(",")); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java index 4a2fff36..69ee1bf0 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java @@ -5,14 +5,22 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; import com.gltkorea.icebang.config.security.endpoints.SecurityEndpoints; +import com.gltkorea.icebang.domain.auth.service.AuthCredentialAdapter; import lombok.RequiredArgsConstructor; @@ -20,6 +28,21 @@ @RequiredArgsConstructor public class SecurityConfig { private final Environment environment; + private final AuthCredentialAdapter userDetailsService; + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(bCryptPasswordEncoder()); + return provider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) + throws Exception { + return config.getAuthenticationManager(); + } @Bean public SecureRandom secureRandom() { @@ -34,31 +57,46 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/auth/login", "/auth/logout") .permitAll() + .requestMatchers("/v0/auth/check-session") + .authenticated() .requestMatchers(SecurityEndpoints.DATA_ADMIN.getMatchers()) - .hasAuthority("SUPER_ADMIN") + .hasRole("SUPER_ADMIN") // hasAuthority -> hasRole .requestMatchers(SecurityEndpoints.DATA_ENGINEER.getMatchers()) - .hasAnyAuthority( - "SUPER_ADMIN", "ADMIN", "SENIOR_DATA_ENGINEER", "DATA_ENGINEER") + .hasAnyRole( + "SUPER_ADMIN", + "SYSTEM_ADMIN", + "AI_ENGINEER", + "DATA_SCIENTIST", + "CRAWLING_ENGINEER", + "TECH_LEAD", + "DEVOPS") .requestMatchers(SecurityEndpoints.ANALYST.getMatchers()) - .hasAnyAuthority( + .hasAnyRole( "SUPER_ADMIN", - "ADMIN", - "SENIOR_DATA_ENGINEER", - "DATA_ENGINEER", - "SENIOR_DATA_ANALYST", - "DATA_ANALYST", - "VIEWER") + "SYSTEM_ADMIN", + "ORG_ADMIN", + "DATA_SCIENTIST", + "MARKETING_ANALYST", + "QA_ENGINEER", + "PROJECT_MANAGER", + "PRODUCT_OWNER", + "USER") .requestMatchers(SecurityEndpoints.OPS.getMatchers()) - .hasAnyAuthority( - "SUPER_ADMIN", "ADMIN", "SENIOR_DATA_ENGINEER", "DATA_ENGINEER") + .hasAnyRole( + "SUPER_ADMIN", + "SYSTEM_ADMIN", + "WORKFLOW_ADMIN", + "OPERATIONS_MANAGER", + "DEVOPS", + "TECH_LEAD") .requestMatchers(SecurityEndpoints.USER.getMatchers()) - .authenticated() + .hasAnyRole("SUPER_ADMIN", "SYSTEM_ADMIN", "ORG_ADMIN", "USER") .anyRequest() .authenticated()) .formLogin(AbstractHttpConfigurer::disable) .logout( logout -> logout.logoutUrl("/auth/logout").logoutSuccessUrl("/auth/login").permitAll()) - .csrf(AbstractHttpConfigurer::disable) // API 사용을 위해 CSRF 비활성화 + .csrf(AbstractHttpConfigurer::disable) .build(); } @@ -67,10 +105,25 @@ public PasswordEncoder bCryptPasswordEncoder() { String[] activeProfiles = environment.getActiveProfiles(); for (String profile : activeProfiles) { - if ("dev".equals(profile) || "test".equals(profile)) { + if ("develop".equals(profile) || "test".equals(profile)) { return NoOpPasswordEncoder.getInstance(); } } return new BCryptPasswordEncoder(); } + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOrigin("http://localhost:3000"); // 프론트 주소 + config.addAllowedOrigin("https://admin.icebang.site"); // 프론트 주소 + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setAllowCredentials(true); // 세션 쿠키 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } } diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/endpoints/SecurityEndpoints.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/endpoints/SecurityEndpoints.java index c73f462d..bc6eafe2 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/endpoints/SecurityEndpoints.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/endpoints/SecurityEndpoints.java @@ -1,16 +1,7 @@ package com.gltkorea.icebang.config.security.endpoints; public enum SecurityEndpoints { - PUBLIC( - "/", - "/login", - "/register", - "/api/public/**", - "/health", - "/css/**", - "/js/**", - "/images/**", - "/v0/**"), + PUBLIC("/", "/v0/auth/login", "/api/public/**", "/health", "/css/**", "/js/**", "/images/**"), // 데이터 관리 관련 엔드포인트 DATA_ADMIN("/admin/**", "/api/admin/**", "/management/**", "/actuator/**"), @@ -25,7 +16,7 @@ public enum SecurityEndpoints { OPS("/api/scheduler/**", "/api/monitoring/**"), // 일반 사용자 엔드포인트 - USER("/user/**", "/profile/**"); + USER("/user/**", "/profile/**", "/v0/auth/check-session"); private final String[] patterns; diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/controller/AuthController.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/controller/AuthController.java index 5da466f6..39fba398 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/controller/AuthController.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/controller/AuthController.java @@ -1,12 +1,22 @@ package com.gltkorea.icebang.domain.auth.controller; import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.*; import com.gltkorea.icebang.common.dto.ApiResponse; +import com.gltkorea.icebang.domain.auth.dto.LoginRequestDto; import com.gltkorea.icebang.domain.auth.dto.RegisterDto; +import com.gltkorea.icebang.domain.auth.model.AuthCredential; import com.gltkorea.icebang.domain.auth.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -15,6 +25,7 @@ @RequiredArgsConstructor public class AuthController { private final AuthService authService; + private final AuthenticationManager authenticationManager; @PostMapping("/register") @ResponseStatus(HttpStatus.CREATED) @@ -22,4 +33,32 @@ public ApiResponse register(@Valid @RequestBody RegisterDto registerDto) { authService.registerUser(registerDto); return ApiResponse.success(null); } + + @PostMapping("/login") + public ApiResponse login( + @RequestBody LoginRequestDto request, HttpServletRequest httpRequest) { + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()); + + Authentication auth = authenticationManager.authenticate(token); + + SecurityContextHolder.getContext().setAuthentication(auth); + + HttpSession session = httpRequest.getSession(true); + session.setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + SecurityContextHolder.getContext()); + + return ApiResponse.success(auth); + } + + @GetMapping("/check-session") + public ApiResponse checkSession(@AuthenticationPrincipal AuthCredential user) { + return ApiResponse.success(user != null); + } + + @GetMapping("/permissions") + public ApiResponse getPermissions(@AuthenticationPrincipal AuthCredential user) { + return ApiResponse.success(user); + } } diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/dto/LoginRequestDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/dto/LoginRequestDto.java new file mode 100644 index 00000000..081d2016 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/dto/LoginRequestDto.java @@ -0,0 +1,15 @@ +package com.gltkorea.icebang.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class LoginRequestDto { + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; + + @NotBlank(message = "비밃번호는 필수입니다") + private String password; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/model/AuthCredential.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/model/AuthCredential.java new file mode 100644 index 00000000..ab4acc2e --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/model/AuthCredential.java @@ -0,0 +1,81 @@ +package com.gltkorea.icebang.domain.auth.model; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AuthCredential implements UserDetails { + + private BigInteger id; + private String email; + private String password; + private String status; + + // roles -> Spring Security authority로 변환 + private List roles; + + // MyBatis GROUP_CONCAT 결과를 List으로 변환하는 setter + public void setRoles(String rolesString) { + if (rolesString != null && !rolesString.trim().isEmpty()) { + this.roles = Arrays.asList(rolesString.split(",")); + } else { + this.roles = new ArrayList<>(); + } + } + + public void setRoles(List roles) { + this.roles = roles; + } + + public List getRoles() { + return roles != null ? roles : new ArrayList<>(); + } + + @Override + public Collection getAuthorities() { + return getRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.trim())) // ROLE_ prefix 추가 + 공백 제거 + .collect(Collectors.toList()); + } + + @Override + public String getUsername() { + return email; // 로그인 ID는 email + } + + @Override + public boolean isAccountNonExpired() { + return true; // 필요 시 status 기반으로 변경 가능 + } + + @Override + public boolean isAccountNonLocked() { + return !"LOCKED".equalsIgnoreCase(status); + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return !"DISABLED".equalsIgnoreCase(status); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/service/AuthCredentialAdapter.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/service/AuthCredentialAdapter.java new file mode 100644 index 00000000..e3268314 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/service/AuthCredentialAdapter.java @@ -0,0 +1,28 @@ +package com.gltkorea.icebang.domain.auth.service; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.gltkorea.icebang.domain.auth.model.AuthCredential; +import com.gltkorea.icebang.mapper.AuthMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuthCredentialAdapter implements UserDetailsService { + private final AuthMapper authMapper; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + AuthCredential user = authMapper.findUserByEmail(email); + + if (user == null) { + throw new UsernameNotFoundException("User not found with email: " + email); + } + + return user; + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/controller/UserController.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/controller/UserController.java index e6b07bce..534e9ba6 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/controller/UserController.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/controller/UserController.java @@ -1,13 +1,13 @@ package com.gltkorea.icebang.domain.user.controller; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; import com.gltkorea.icebang.common.dto.ApiResponse; +import com.gltkorea.icebang.domain.auth.model.AuthCredential; import com.gltkorea.icebang.domain.user.dto.CheckEmailRequest; import com.gltkorea.icebang.domain.user.dto.CheckEmailResponse; +import com.gltkorea.icebang.domain.user.dto.UserProfileResponseDto; import com.gltkorea.icebang.domain.user.service.UserService; import jakarta.validation.Valid; @@ -27,4 +27,10 @@ public ApiResponse checkEmailAvailable( return ApiResponse.success(CheckEmailResponse.builder().available(available).build(), message); } + + @GetMapping("/me") + public ApiResponse getUserProfile( + @AuthenticationPrincipal AuthCredential user) { + return ApiResponse.success(UserProfileResponseDto.from(user)); + } } diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/UserProfileResponseDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/UserProfileResponseDto.java new file mode 100644 index 00000000..9254ace7 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/UserProfileResponseDto.java @@ -0,0 +1,30 @@ +package com.gltkorea.icebang.domain.user.dto; + +import java.math.BigInteger; +import java.util.List; + +import com.gltkorea.icebang.domain.auth.model.AuthCredential; + +import lombok.Getter; + +@Getter +public class UserProfileResponseDto { + + private final BigInteger id; + private final String email; + private final String name; + private final List roles; + private final String status; + + public UserProfileResponseDto(AuthCredential authCredential) { + this.id = authCredential.getId(); + this.email = authCredential.getEmail(); + this.name = authCredential.getEmail(); // name 필드가 없으면 email 사용 + this.roles = authCredential.getRoles(); + this.status = authCredential.getStatus(); + } + + public static UserProfileResponseDto from(AuthCredential authCredential) { + return new UserProfileResponseDto(authCredential); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/AuthMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/AuthMapper.java index 09033730..4480daf0 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/AuthMapper.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/AuthMapper.java @@ -3,9 +3,12 @@ import org.apache.ibatis.annotations.Mapper; import com.gltkorea.icebang.domain.auth.dto.RegisterDto; +import com.gltkorea.icebang.domain.auth.model.AuthCredential; @Mapper public interface AuthMapper { + AuthCredential findUserByEmail(String email); + boolean existsByEmail(String email); int insertUser(RegisterDto dto); // users insert diff --git a/apps/user-service/src/main/resources/application.yml b/apps/user-service/src/main/resources/application.yml index 278dfb11..e852951b 100644 --- a/apps/user-service/src/main/resources/application.yml +++ b/apps/user-service/src/main/resources/application.yml @@ -5,4 +5,5 @@ spring: active: develop mybatis: # Mapper XML 파일 위치 - mapper-locations: classpath:mapper/**/*.xml \ No newline at end of file + mapper-locations: classpath:mapper/**/*.xml + type-handlers-package: com.gltkorea.icebang.config.mybatis.typehandler \ No newline at end of file diff --git a/apps/user-service/src/main/resources/log4j2-develop.yml b/apps/user-service/src/main/resources/log4j2-develop.yml index d1afc02b..1b5c6e35 100644 --- a/apps/user-service/src/main/resources/log4j2-develop.yml +++ b/apps/user-service/src/main/resources/log4j2-develop.yml @@ -119,6 +119,13 @@ Configuration: # 6. 트랜잭션 로그 - DB 작업 추적 - name: org.springframework.transaction + level: DEBUG + additivity: "false" + AppenderRef: + - ref: console-appender + - ref: file-info-appender + + - name: com.gltkorea.icebang.domain.auth.mapper level: DEBUG additivity: "false" AppenderRef: diff --git a/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml index c503e76e..0023c224 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml @@ -12,6 +12,24 @@ ) + + + INSERT INTO user (name, email, password) VALUES (#{name}, #{email}, #{password}); diff --git a/apps/user-service/src/main/resources/sql/01-insert-internal-users.sql b/apps/user-service/src/main/resources/sql/01-insert-internal-users.sql index 3a8529c8..1a69076e 100644 --- a/apps/user-service/src/main/resources/sql/01-insert-internal-users.sql +++ b/apps/user-service/src/main/resources/sql/01-insert-internal-users.sql @@ -147,7 +147,7 @@ INSERT INTO `user` (`name`, `email`, `password`, `status`) VALUES ('홍크롤러', 'crawler.hong@icebang.site', '$2a$10$encrypted_password_hash6', 'ACTIVE'), ('서데이터', 'data.seo@icebang.site', '$2a$10$encrypted_password_hash7', 'ACTIVE'), ('윤워크플로', 'workflow.yoon@icebang.site', '$2a$10$encrypted_password_hash8', 'ACTIVE'), - ('시스템관리자', 'admin@icebang.site', '$2a$10$encrypted_password_hash0', 'ACTIVE'); + ('시스템관리자', 'admin@icebang.site', 'qwer1234!A', 'ACTIVE'); -- 8. icebang 직원-조직 연결 INSERT INTO `user_organization` (`user_id`, `organization_id`, `position_id`, `department_id`, `employee_number`, `status`) VALUES