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