From aa3f93d94913af4d1ecbab4aed2733da0bb9205d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B2=9C=20=EC=A7=84=EA=B0=95?= <jjjjjk12@ajou.ac.kr> Date: Mon, 3 Mar 2025 21:22:07 +0900 Subject: [PATCH] =?UTF-8?q?Feat/auth=20:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=B0=9C=ED=96=89=20=EB=B0=8F=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../java/com/aolda/itda/config/WebConfig.java | 19 +++ .../aolda/itda/controller/AuthController.java | 27 +++ .../aolda/itda/dto/auth/LoginRequestDTO.java | 11 ++ .../aolda/itda/dto/auth/LoginResponseDTO.java | 17 ++ .../itda/dto/auth/ProjectIdAndNameDTO.java | 16 ++ .../aolda/itda/dto/auth/ProjectRoleDTO.java | 17 ++ .../itda/exception/ApiExceptionHandler.java | 17 ++ .../aolda/itda/exception/CustomException.java | 19 +++ .../com/aolda/itda/exception/ErrorCode.java | 16 ++ .../aolda/itda/exception/ErrorResponse.java | 29 ++++ .../com/aolda/itda/service/AuthService.java | 159 ++++++++++++++++++ 12 files changed, 349 insertions(+) create mode 100644 src/main/java/com/aolda/itda/config/WebConfig.java create mode 100644 src/main/java/com/aolda/itda/controller/AuthController.java create mode 100644 src/main/java/com/aolda/itda/dto/auth/LoginRequestDTO.java create mode 100644 src/main/java/com/aolda/itda/dto/auth/LoginResponseDTO.java create mode 100644 src/main/java/com/aolda/itda/dto/auth/ProjectIdAndNameDTO.java create mode 100644 src/main/java/com/aolda/itda/dto/auth/ProjectRoleDTO.java create mode 100644 src/main/java/com/aolda/itda/exception/ApiExceptionHandler.java create mode 100644 src/main/java/com/aolda/itda/exception/CustomException.java create mode 100644 src/main/java/com/aolda/itda/exception/ErrorCode.java create mode 100644 src/main/java/com/aolda/itda/exception/ErrorResponse.java create mode 100644 src/main/java/com/aolda/itda/service/AuthService.java diff --git a/build.gradle b/build.gradle index 9305e70..5cef9f6 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.pacesys:openstack4j:3.1.0' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/aolda/itda/config/WebConfig.java b/src/main/java/com/aolda/itda/config/WebConfig.java new file mode 100644 index 0000000..9eb992b --- /dev/null +++ b/src/main/java/com/aolda/itda/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.aolda.itda.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { // 스프링단에서 cors 설정 + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "FETCH") + .allowedHeaders("*") + .allowCredentials(true) + .exposedHeaders("Authorization", "X-Refresh-Token", "Access-Control-Allow-Origin") + ; + } +} diff --git a/src/main/java/com/aolda/itda/controller/AuthController.java b/src/main/java/com/aolda/itda/controller/AuthController.java new file mode 100644 index 0000000..274ad8f --- /dev/null +++ b/src/main/java/com/aolda/itda/controller/AuthController.java @@ -0,0 +1,27 @@ +package com.aolda.itda.controller; + +import com.aolda.itda.dto.auth.LoginRequestDTO; +import com.aolda.itda.service.AuthService; +import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity<Object> login(HttpServletResponse response, + @RequestBody LoginRequestDTO loginRequestDTO) throws JsonProcessingException { + + return ResponseEntity.ok(authService.userLogin(response, loginRequestDTO)); + } +} diff --git a/src/main/java/com/aolda/itda/dto/auth/LoginRequestDTO.java b/src/main/java/com/aolda/itda/dto/auth/LoginRequestDTO.java new file mode 100644 index 0000000..b91b3af --- /dev/null +++ b/src/main/java/com/aolda/itda/dto/auth/LoginRequestDTO.java @@ -0,0 +1,11 @@ +package com.aolda.itda.dto.auth; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class LoginRequestDTO { + private String id; + private String password; +} diff --git a/src/main/java/com/aolda/itda/dto/auth/LoginResponseDTO.java b/src/main/java/com/aolda/itda/dto/auth/LoginResponseDTO.java new file mode 100644 index 0000000..a5aa14a --- /dev/null +++ b/src/main/java/com/aolda/itda/dto/auth/LoginResponseDTO.java @@ -0,0 +1,17 @@ +package com.aolda.itda.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LoginResponseDTO { + private Boolean isAdmin; + private List<ProjectIdAndNameDTO> lists; +} diff --git a/src/main/java/com/aolda/itda/dto/auth/ProjectIdAndNameDTO.java b/src/main/java/com/aolda/itda/dto/auth/ProjectIdAndNameDTO.java new file mode 100644 index 0000000..1ca894d --- /dev/null +++ b/src/main/java/com/aolda/itda/dto/auth/ProjectIdAndNameDTO.java @@ -0,0 +1,16 @@ +package com.aolda.itda.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProjectIdAndNameDTO { + + private String id; + private String name; +} diff --git a/src/main/java/com/aolda/itda/dto/auth/ProjectRoleDTO.java b/src/main/java/com/aolda/itda/dto/auth/ProjectRoleDTO.java new file mode 100644 index 0000000..d3b9b8e --- /dev/null +++ b/src/main/java/com/aolda/itda/dto/auth/ProjectRoleDTO.java @@ -0,0 +1,17 @@ +package com.aolda.itda.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProjectRoleDTO { + private String projectName; + private String roleName; +} diff --git a/src/main/java/com/aolda/itda/exception/ApiExceptionHandler.java b/src/main/java/com/aolda/itda/exception/ApiExceptionHandler.java new file mode 100644 index 0000000..4d1f360 --- /dev/null +++ b/src/main/java/com/aolda/itda/exception/ApiExceptionHandler.java @@ -0,0 +1,17 @@ +package com.aolda.itda.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(value = CustomException.class) + public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) { + log.error("[handleCustomException] {} : {}", e.getErrorCode().name(), e.getErrorCode().getMessage()); + return ErrorResponse.fromException(e); + } +} diff --git a/src/main/java/com/aolda/itda/exception/CustomException.java b/src/main/java/com/aolda/itda/exception/CustomException.java new file mode 100644 index 0000000..d110063 --- /dev/null +++ b/src/main/java/com/aolda/itda/exception/CustomException.java @@ -0,0 +1,19 @@ +package com.aolda.itda.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException{ + private ErrorCode errorCode; + + private String info; + + public CustomException(ErrorCode errorCode) { + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String info){ + this.errorCode = errorCode; + this.info = info; + } +} diff --git a/src/main/java/com/aolda/itda/exception/ErrorCode.java b/src/main/java/com/aolda/itda/exception/ErrorCode.java new file mode 100644 index 0000000..6bcb643 --- /dev/null +++ b/src/main/java/com/aolda/itda/exception/ErrorCode.java @@ -0,0 +1,16 @@ +package com.aolda.itda.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // User + INVALID_USER_INFO(HttpStatus.BAD_REQUEST, "잘못된 회원 정보입니다"); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/aolda/itda/exception/ErrorResponse.java b/src/main/java/com/aolda/itda/exception/ErrorResponse.java new file mode 100644 index 0000000..1bec060 --- /dev/null +++ b/src/main/java/com/aolda/itda/exception/ErrorResponse.java @@ -0,0 +1,29 @@ +package com.aolda.itda.exception; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@Getter +@Builder +public class ErrorResponse { + + private final HttpStatus status; // HTTP 상태 코드 + private final String code; // 에러 코드 + private final String message; // 에러 메시지 + + public static ResponseEntity<ErrorResponse> fromException(CustomException e) { + String message = e.getErrorCode().getMessage(); + if (e.getInfo() != null) { + message += " " + e.getInfo(); // 추가 정보가 있는 경우 결합 + } + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ErrorResponse.builder() + .status(e.getErrorCode().getStatus()) + .code(e.getErrorCode().name()) + .message(message) + .build()); + } +} diff --git a/src/main/java/com/aolda/itda/service/AuthService.java b/src/main/java/com/aolda/itda/service/AuthService.java new file mode 100644 index 0000000..5f071ee --- /dev/null +++ b/src/main/java/com/aolda/itda/service/AuthService.java @@ -0,0 +1,159 @@ +package com.aolda.itda.service; + +import com.aolda.itda.dto.auth.LoginRequestDTO; +import com.aolda.itda.dto.auth.LoginResponseDTO; +import com.aolda.itda.dto.auth.ProjectIdAndNameDTO; +import com.aolda.itda.dto.auth.ProjectRoleDTO; +import com.aolda.itda.exception.CustomException; +import com.aolda.itda.exception.ErrorCode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.*; + +@Service +@RequiredArgsConstructor +public class AuthService { + + @Value("${spring.server.keystone}") + private String keystone; + @Value("${spring.server.admin-id}") + private String adminId; + @Value("${spring.server.admin-password}") + private String adminPassword; + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + // 사용자 로그인 후 토큰 발행 및 Role 반환 + public LoginResponseDTO userLogin(HttpServletResponse response, LoginRequestDTO loginRequestDTO) throws JsonProcessingException { + Map<String, String> user = getToken(loginRequestDTO.getId(), loginRequestDTO.getPassword()); + + String userId = user.get("id"); + String token = user.get("token"); + + if (userId == null || token == null) { + throw new CustomException(ErrorCode.INVALID_USER_INFO); + } + + response.addHeader("X-Subject-Token", token); + return LoginResponseDTO.builder() + .isAdmin(false) + .lists(getProjectsWithUser(user)) + .build(); + } + + // 특정 사용자의 토큰 발행 + private Map<String, String> getToken(String id, String password) { + + String url = keystone + "/auth/tokens"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String requestBody = "{\n" + + " \"auth\": {\n" + + " \"identity\": {\n" + + " \"methods\": [\n" + + " \"password\"\n" + + " ],\n" + + " \"password\": {\n" + + " \"user\": {\n" + + " \"name\": \""+ id + "\",\n" + + " \"domain\": {\n" + + " \"name\": \"Default\"\n" + + " },\n" + + " \"password\": \"" + password + "\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + HttpEntity<String> requestEntity = new HttpEntity<>(requestBody, headers); + ResponseEntity<Map> res = restTemplate.postForEntity(url, requestEntity, Map.class); + + Map<String, Object> resToken = (Map<String, Object>) res.getBody().get("token"); + Map<String, Object> resUser = (Map<String, Object>) resToken.get("user"); + String userId = (String) resUser.get("id"); + String token = res.getHeaders().getFirst("X-Subject-Token"); + + return Map.of("id", userId, + "token", token); + } + + // 특정 사용자의 프로젝트별 Role 반환 + private List<ProjectRoleDTO> getRolesWithProjects(Map<String, String> user) throws JsonProcessingException { + String userId = user.get("id"); + String token = user.get("token"); + + if (userId == null || token == null) { + throw new CustomException(ErrorCode.INVALID_USER_INFO); + } + + String url = keystone + "/role_assignments?user.id=" + userId + "&effective&include_names=true"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Auth-Token", getAdminToken()); + + HttpEntity<String> requestEntity = new HttpEntity<>(headers); + ResponseEntity<String> res = restTemplate.exchange(url, HttpMethod.GET, requestEntity, String.class); + + JsonNode node = objectMapper.readTree(res.getBody()); + ArrayNode arrayNode = (ArrayNode) node.get("role_assignments"); + + List<ProjectRoleDTO> lists = new ArrayList<>(); + + for (JsonNode assignment : arrayNode) { + + String projectName = assignment.path("scope").path("project").path("name").asText(); + String roleName = assignment.path("role").path("name").asText(); + + ProjectRoleDTO projectRoleDTO = new ProjectRoleDTO(projectName, roleName); + lists.add(projectRoleDTO); + + } + + return lists; + } + + // 관리자용 토큰 발행 + public String getAdminToken() { + Map<String, String> user = getToken(adminId, adminPassword); + return user.get("token"); + } + + private List<ProjectIdAndNameDTO> getProjectsWithUser(Map<String, String> user) throws JsonProcessingException { + String userId = user.get("id"); + String token = user.get("token"); + if (userId == null || token == null) { + throw new CustomException(ErrorCode.INVALID_USER_INFO); + } + + String url = keystone + "/users/" + userId + "/projects"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Auth-Token", getAdminToken()); + + HttpEntity<String> requestEntity = new HttpEntity<>(headers); + ResponseEntity<String> res = restTemplate.exchange(url, HttpMethod.GET, requestEntity, String.class); + + JsonNode node = objectMapper.readTree(res.getBody()); + ArrayNode arrayNode = (ArrayNode) node.get("projects"); + + List<ProjectIdAndNameDTO> lists = new ArrayList<>(); + + for (JsonNode assignment : arrayNode) { + String projectId = assignment.path("id").asText(); + String projectName = assignment.path("name").asText(); + lists.add(new ProjectIdAndNameDTO(projectId, projectName)); + } + return lists; + } +} -- GitLab