diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml new file mode 100644 index 0000000..64ac456 --- /dev/null +++ b/.github/workflows/CD.yml @@ -0,0 +1,135 @@ +name: CD + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + # 코드 체크아웃 + - name: Checkout + uses: actions/checkout@v4 + + # DockerHub 로그인 + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # Docker 이미지 빌드 & 푸시 + - name: Build and Push Docker image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} . + docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} + + # EC2로 yaml 파일 복사 + - name: Copy k8s manifests to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "k8s/*.yaml" + target: "/home/${{ secrets.EC2_USER }}/k8s-manifests" + + # SSH 접속 후 app.yaml의 IMAGE 치환 + - name: Replace IMAGE in app.yaml on EC2 + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # k8s 매니페스트 디렉토리 + MANIFEST_DIR="/home/${{ secrets.EC2_USER }}/k8s-manifests/k8s" + + # IMAGE를 GitHub Secret 값으로 치환 + sed -i "s|IMAGE|${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }}|g" "$MANIFEST_DIR/app.yaml" + + echo "✅ app.yaml의 USERNAME 치환 완료" + +# # Github Actions IP 가져오기 +# - name: Get Github Actions IP +# id: ip +# uses: haythem/public-ip@v1.2 +# +# # AWS 보안 그룹에 동적으로 IP 추가 +# - name: Add Github Actions IP to Security group +# run: | +# aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 +# env: +# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} +# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +# AWS_DEFAULT_REGION: ap-northeast-2 + + # EC2 SSH → k3s에 Secret 생성 & Deployment 업데이트 + - name: Deploy to K3s via SSH + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # 기존 db-secret 삭제 + kubectl delete secret db-secret + + # -- db-secret 생성 -- + cat < auth + .requestMatchers( + "/api/auth/**", + "/oauth2/**", + "/login/oauth2/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**", + "/favicon.ico" + ).permitAll() + .anyRequest().authenticated() + ) + // CSRF 비활성화 + .csrf(AbstractHttpConfigurer::disable) + // JWT 필터 적용 + .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) + // 기본 폼 로그인 및 HTTP Basic 인증 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + // OAuth2 로그인 설정 + .oauth2Login(oauth2 -> oauth2 + .successHandler(oAuth2SuccessHandler) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + ); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java b/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java new file mode 100644 index 0000000..89870db --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java @@ -0,0 +1,37 @@ +package com.example.skillboost.auth.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + Server localServer = new Server() + .url("http://localhost:8080") + .description("Local Server"); + + + return new OpenAPI() + .servers(List.of(localServer)) + .components(new Components() + .addSecuritySchemes("bearer-token", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) + .addSecurityItem(new SecurityRequirement().addList("bearer-token")) + .info(new Info() + .title("My Application API") + .description("API Documentation") + .version("1.0.0")); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/controller/AuthController.java b/src/main/java/com/example/skillboost/auth/controller/AuthController.java new file mode 100644 index 0000000..82dc33f --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/controller/AuthController.java @@ -0,0 +1,23 @@ +package com.example.skillboost.auth.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; + +@Tag(name = "깃허브 인증 (Authentication)", description = "소셜 로그인 API") +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + @Operation(summary = "GitHub 로그인 URL 반환", + description = "프론트엔드에서 이 주소로 GET 요청을 보내면, 사용자가 접속해야 할 GitHub 로그인 페이지 URL을 반환합니다.") + @GetMapping("/github-login-url") + public Map getGithubLoginUrl() { + String loginUrl = "/oauth2/authorization/github"; + + return Map.of("url", loginUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..b484cc8 --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,75 @@ +package com.example.skillboost.auth.handler; + +import com.example.skillboost.auth.JwtProvider; +import com.example.skillboost.domain.User; +import com.example.skillboost.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + log.info("OAuth2 인증 성공!"); + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String email = (String) oAuth2User.getAttributes().get("email"); + + // GitHub에서 이메일을 비공개로 설정한 경우 처리 + if (email == null || email.isEmpty()) { + String githubId = String.valueOf(oAuth2User.getAttributes().get("id")); + email = githubId + "@github.temp"; + log.warn("이메일 비공개 사용자 - 임시 이메일 사용: {}", email); + } + + // Lambda에서 사용하기 위한 final 변수 + final String finalEmail = email; + + // 사용자 조회 + User user = userRepository.findByEmail(finalEmail) + .orElseThrow(() -> { + log.error("사용자를 찾을 수 없습니다: {}", finalEmail); + return new RuntimeException("User not found: " + finalEmail); + }); + + // JWT 토큰 생성 + String token = jwtProvider.createToken(user.getEmail()); + log.info("JWT 토큰 생성 완료: {}", user.getEmail()); + + // JSON 응답 생성 + Map responseData = new HashMap<>(); + responseData.put("success", true); + responseData.put("token", token); + responseData.put("email", user.getEmail()); + responseData.put("username", user.getUsername()); + + // 클라이언트에 JWT 응답 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write(objectMapper.writeValueAsString(responseData)); + + // 프론트엔드로 리다이렉트하려면 아래 주석 해제 + // response.sendRedirect("http://localhost:3000/oauth2/redirect?token=" + token); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java b/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..d4cbf0f --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,75 @@ +package com.example.skillboost.auth.service; + +import com.example.skillboost.domain.User; +import com.example.skillboost.repository.UserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + // GitHub에서 사용자 정보 가져오기 + OAuth2User oAuth2User = super.loadUser(request); + Map attributes = oAuth2User.getAttributes(); + + log.info("GitHub OAuth2 사용자 정보: {}", attributes); + + // GitHub 사용자 정보 추출 + String email = (String) attributes.get("email"); + String githubId = String.valueOf(attributes.get("id")); + String username = (String) attributes.get("login"); + + // 이메일이 비공개인 경우 임시 이메일 생성 + if (email == null || email.isEmpty()) { + email = githubId + "@github.temp"; + log.warn("이메일 비공개 사용자 - 임시 이메일 생성: {}", email); + } + + // 사용자 저장 또는 업데이트 + final String finalEmail = email; + User user = userRepository.findByEmail(email) + .map(existing -> { + log.info("기존 사용자 업데이트: {}", finalEmail); + existing.setGithubId(githubId); + existing.setUsername(username); + return existing; + }) + .orElseGet(() -> { + log.info("새로운 사용자 생성: {}", finalEmail); + return User.builder() + .email(finalEmail) + .username(username) + .githubId(githubId) + .provider("github") + .build(); + }); + + userRepository.save(user); + log.info("사용자 정보 저장 완료: {} (GitHub ID: {})", user.getEmail(), user.getGithubId()); + + // OAuth2User 객체 반환 + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributes, + "id" + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/domain/User.java b/src/main/java/com/example/skillboost/domain/User.java new file mode 100644 index 0000000..fd4aac4 --- /dev/null +++ b/src/main/java/com/example/skillboost/domain/User.java @@ -0,0 +1,25 @@ +package com.example.skillboost.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + private String username; + private String githubId; + private String provider; // github, local +} diff --git a/src/main/java/com/example/skillboost/repository/UserRepository.java b/src/main/java/com/example/skillboost/repository/UserRepository.java new file mode 100644 index 0000000..7bb196b --- /dev/null +++ b/src/main/java/com/example/skillboost/repository/UserRepository.java @@ -0,0 +1,9 @@ +package com.example.skillboost.repository; + +import com.example.skillboost.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..ac03546 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,40 @@ +server: + port: 8080 + +spring: + application: + name: skill-boost + + jpa: + hibernate: + ddl-auto: update + show-sql: true + + security: + oauth2: + client: + registration: + github: + client-id: Ov23liXAPa0etQe0EisI + client-secret: a5dc74aff160176ad62591fbe3d2c0a839eb1ef6 + scope: + - read:user + - user:email + redirect-uri: http://localhost:8080/login/oauth2/code/github + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +jwt: + secret-key: TXlTdXBlclNlY3JldEtleUZvclNraWxsQm9vc3RQcm9qZWN0MjAyNUNoYWxsZW5nZSE= + expiration-ms: 86400000 + +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + path: /swagger-ui.html \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..f7044b2 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,34 @@ +spring: + application: + name: skill-boost + + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USER} + password: ${MYSQL_PASSWORD} + + jpa: + hibernate: + ddl-auto: none + + security: + oauth2: + client: + registration: + github: + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} + scope: + - read:user + - user:email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +jwt: + secret-key: ${JWT_SECRET_KEY} + expiration-ms: 86400000 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..d0b8828 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,32 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MYSQL + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + + security: + oauth2: + client: + registration: + github: + client-id: test + client-secret: test + scope: + - read:user + - user:email + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +jwt: + secret-key: TXlTdXBlclNlY3JldEtleUZvclNraWxsQm9vc3RQcm9qZWN0MjAyNUNoYWxsZW5nZSE= + expiration-ms: 100000 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 666da9c..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=skill-boost diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..82026c5 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + application: + name: skill-boost + profiles: + active: local \ No newline at end of file