Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<List<String>> {

@Override
public void setNonNullParameter(
PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, String.join(",", parameter));
}

@Override
public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
return convertToList(value);
}

@Override
public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String value = rs.getString(columnIndex);
return convertToList(value);
}

@Override
public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String value = cs.getString(columnIndex);
return convertToList(value);
}

private List<String> convertToList(String value) {
if (value == null || value.trim().isEmpty()) {
return new ArrayList<>();
}
return Arrays.asList(value.split(","));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,44 @@
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;

@Configuration
@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() {
Expand All @@ -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();
}

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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/**"),
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,11 +25,40 @@
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final AuthenticationManager authenticationManager;

@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<Void> 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<Boolean> checkSession(@AuthenticationPrincipal AuthCredential user) {
return ApiResponse.success(user != null);
}

@GetMapping("/permissions")
public ApiResponse<AuthCredential> getPermissions(@AuthenticationPrincipal AuthCredential user) {
return ApiResponse.success(user);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<String> roles;

// MyBatis GROUP_CONCAT 결과를 List<String>으로 변환하는 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<String> roles) {
this.roles = roles;
}

public List<String> getRoles() {
return roles != null ? roles : new ArrayList<>();
}

@Override
public Collection<? extends GrantedAuthority> 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);
}
}
Loading
Loading