diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0c63422..eaf3585 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,8 @@ jobs: DB_URL: ${{ secrets.DB_URL }} DB_USERNAME: ${{ secrets.DB_USERNAME }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} steps: - uses: actions/checkout@v4 @@ -66,6 +68,8 @@ jobs: echo "DB_URL=${{ secrets.DB_URL }}" > ~/.env echo "DB_USERNAME=${{ secrets.DB_USERNAME }}" >> ~/.env echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> ~/.env + echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> ~/.env + echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> ~/.env # 새 컨테이너 실행 및 환경 변수 전달 sudo docker run -d --log-driver=syslog --name docker-test -p 8080:8080 \ diff --git a/src/main/java/danpoong/soenter/base/config/SecurityConfig.java b/src/main/java/danpoong/soenter/base/config/SecurityConfig.java index 35f84fa..ae507e7 100644 --- a/src/main/java/danpoong/soenter/base/config/SecurityConfig.java +++ b/src/main/java/danpoong/soenter/base/config/SecurityConfig.java @@ -1,37 +1,95 @@ package danpoong.soenter.base.config; +import danpoong.soenter.base.jwt.CustomAccessDeniedHandler; +import danpoong.soenter.base.jwt.CustomJwtAuthenticationEntryPoint; +import danpoong.soenter.base.jwt.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration -@EnableWebSecurity @RequiredArgsConstructor +@EnableWebSecurity public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + // Swagger 관련 경로 추가 + private static final String[] AUTH_WHITELIST = { + "/static/**", + "/favicon.ico", + "/css/**", + "/js/**", + "/manifest.json", + "/", + "/health", + "http://localhost:3000", + "http://localhost:8080", + "https://api.ssoenter.store", + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + "/kakao/login", + "/kakao/callback", + "/kakao/callback/**", + "/users/**", + "/api/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(e -> { + e.authenticationEntryPoint(customJwtAuthenticationEntryPoint); + e.accessDeniedHandler(customAccessDeniedHandler); + }); + + // Swagger 경로를 허용 목록에 포함 + http.authorizeHttpRequests(auth -> { + auth.requestMatchers(AUTH_WHITELIST).permitAll(); + auth.anyRequest().authenticated(); + }) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { - httpSecurity - .csrf(csrf -> csrf.disable()) // CSRF 비활성화 - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() // Allow all requests without authentication -// .authorizeHttpRequests(auth -> auth -// .requestMatchers( -// new AntPathRequestMatcher("/"), -// new AntPathRequestMatcher("/api/v0/auth/**"), -// new AntPathRequestMatcher("/swagger-ui.html"), -// new AntPathRequestMatcher("/swagger-ui/**"), -// new AntPathRequestMatcher("/v3/api-docs/**"), -// new AntPathRequestMatcher("/api-docs/**"), -// new AntPathRequestMatcher("/health"), -// ).permitAll() // 이 경로들은 모두 인증 없이 접근 가능 -// .anyRequest().authenticated() // 그 외의 모든 경로는 인증 필요 - ) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); - return httpSecurity.build(); + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of( + "http://localhost:3000", + "http://localhost:8080", + "https://api.ssoenter.store" + )); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; } -} +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/base/config/WebConfig.java b/src/main/java/danpoong/soenter/base/config/WebConfig.java deleted file mode 100644 index 268a862..0000000 --- a/src/main/java/danpoong/soenter/base/config/WebConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package danpoong.soenter.base.config; - -import org.springframework.context.annotation.Bean; -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 { - - @Bean - public WebMvcConfigurer corsConfigurer() { - return new WebMvcConfigurer() { - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOrigins("http://localhost:8080", "http://localhost:3000", "http://localhost:5173", - "https://goorm.ssoenter.store", "https://ssoenter.store") - .allowedMethods("GET", "POST", "PATCH", "PUT", "DELETE") - .allowedHeaders("*") - .allowCredentials(true); - } - - }; - } -} diff --git a/src/main/java/danpoong/soenter/base/jwt/CustomAccessDeniedHandler.java b/src/main/java/danpoong/soenter/base/jwt/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..754083c --- /dev/null +++ b/src/main/java/danpoong/soenter/base/jwt/CustomAccessDeniedHandler.java @@ -0,0 +1,19 @@ +package danpoong.soenter.base.jwt; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/base/jwt/CustomJwtAuthenticationEntryPoint.java b/src/main/java/danpoong/soenter/base/jwt/CustomJwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..bc9c72c --- /dev/null +++ b/src/main/java/danpoong/soenter/base/jwt/CustomJwtAuthenticationEntryPoint.java @@ -0,0 +1,17 @@ +package danpoong.soenter.base.jwt; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/base/jwt/JwtAuthenticationFilter.java b/src/main/java/danpoong/soenter/base/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..e5f609b --- /dev/null +++ b/src/main/java/danpoong/soenter/base/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,58 @@ +package danpoong.soenter.base.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +//모든 요청에 대해 실행되며 요청의 Authorization 헤더에서 jwt 추출, 검증해 사용자 인증 +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtTokenProvider jwtTokenProvider; //jwt 토큰 생성, 검증 + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + final String token = getJwtFromRequest(request); + if (jwtTokenProvider.validateToken(token) == JwtValidationType.VALID_JWT) { + Long userId = jwtTokenProvider.getUserIdFromJwt(token); + String kakaoAccessTokenFromJwt = jwtTokenProvider.getKakaoAccessTokenFromJwt(token); + //userId를 principal(주체)로 설정 + UserAuthentication authentication = new UserAuthentication(userId.toString(), null, null, kakaoAccessTokenFromJwt); + //IP 주소, 세션 ID 같은 요청 관련 정보를 포함하는 객체를 생성해서 추가적인 인증 정보 설정 + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + //spring security의 인증 컨텍스트에 설정 + //현재 스레드의 보안 컨텍스트에 authentication을 설정해 이후의 요청 처리 과정에서 현재 사용자가 인증된 사용자로 간주됨. + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception exception) { + try { + throw new Exception(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + filterChain.doFilter(request, response); //다음 필터로 요청 전달 + } + + //요청의 Authorization 헤더에서 Bearer 토큰 찾아서 반환 + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring("Bearer ".length()); //Bearer 이후의 문자열을 잘라내서 반환, 실제 jwt + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/base/jwt/JwtTokenProvider.java b/src/main/java/danpoong/soenter/base/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..7f74b7b --- /dev/null +++ b/src/main/java/danpoong/soenter/base/jwt/JwtTokenProvider.java @@ -0,0 +1,94 @@ +package danpoong.soenter.base.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private static final String USER_ID = "userId"; + private static final String KAKAO_ACCESS_TOKEN = "kakaoAccessToken"; + private static final Long TOKEN_EXPIRATION_TIME = 60 * 60 * 1000L; //1시간 + + @Value("${jwt.secret}") + private String JWT_SECRET; //jwt 서명을 위한 비밀키 + + @PostConstruct + protected void init() { + //UTF-8 인코딩 된 바이트 배열 -> Base64 인코딩 된 문자열 + //서명 및 검증 과정에서 비밀 키를 안전하게 관리하기 위해 수행 + JWT_SECRET = Base64.getEncoder() + .encodeToString(JWT_SECRET.getBytes(StandardCharsets.UTF_8)); + } + + //토큰 생성 + public String generateToken(Authentication authentication) { + final Date now = new Date(); + final Claims claims = Jwts.claims() + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + TOKEN_EXPIRATION_TIME)); + + claims.put(USER_ID, authentication.getPrincipal()); + if (authentication instanceof UserAuthentication) { + claims.put(KAKAO_ACCESS_TOKEN, ((UserAuthentication) authentication).getKakaoAccessToken()); + } + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setClaims(claims) + .signWith(getSigningKey()) + .compact(); + } + + //서명에 사용할 키 생성 + private SecretKey getSigningKey() { + String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes()); + return Keys.hmacShaKeyFor(encodedKey.getBytes()); //인코딩된 키를 HMAC-SHA 키로 변환 + } + + public Long getUserIdFromJwt(String token) { + Claims claims = getBody(token); + return Long.valueOf(claims.get(USER_ID).toString()); //userId를 Long으로 변환해 반환 + } + + public String getKakaoAccessTokenFromJwt(String token) { + Claims claims = getBody(token); + return claims.get(KAKAO_ACCESS_TOKEN).toString(); + } + + //토큰 유효성 검사 + public JwtValidationType validateToken(String token) { + try { + final Claims claims = getBody(token); //클레임에서 바디 추출 + return JwtValidationType.VALID_JWT; + } catch (MalformedJwtException e) { + return JwtValidationType.INVALID_JWT_TOKEN; + } catch (ExpiredJwtException e) { + return JwtValidationType.EXPIRED_JWT_TOKEN; + } catch (UnsupportedJwtException e) { + return JwtValidationType.UNSUPPORTED_JWT_TOKEN; + } catch (IllegalArgumentException e) { + return JwtValidationType.EMPTY_JWT; + } + } + + //jwt 토큰에서 클레임 추출 + private Claims getBody(final String token) { + return Jwts.parserBuilder() //jwt 문자열을 파싱하기 위한 빌더 생성 + .setSigningKey(getSigningKey()) //서명 키 설정 + .build() + .parseClaimsJws(token) //토큰 파싱, 서명 검증 + .getBody(); //클레임 반환 + } +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/base/jwt/JwtValidationType.java b/src/main/java/danpoong/soenter/base/jwt/JwtValidationType.java new file mode 100644 index 0000000..cf7a8a7 --- /dev/null +++ b/src/main/java/danpoong/soenter/base/jwt/JwtValidationType.java @@ -0,0 +1,10 @@ +package danpoong.soenter.base.jwt; + +public enum JwtValidationType { + VALID_JWT, //유효한 jwt + INVALID_JWT_SIGNATURE, //유효하지 않은 서명 + INVALID_JWT_TOKEN, //유효하지 않은 토큰 + EXPIRED_JWT_TOKEN, //만료된 토큰 + UNSUPPORTED_JWT_TOKEN, //지원하지 않는 형식의 토큰 + EMPTY_JWT //빈 jwt +} diff --git a/src/main/java/danpoong/soenter/base/jwt/UserAuthentication.java b/src/main/java/danpoong/soenter/base/jwt/UserAuthentication.java new file mode 100644 index 0000000..4ca9f3d --- /dev/null +++ b/src/main/java/danpoong/soenter/base/jwt/UserAuthentication.java @@ -0,0 +1,20 @@ +package danpoong.soenter.base.jwt; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +//사용자 인증 객체를 나타내고, 인증된 사용자에 대한 정보 포함. 주로 사용자 인증 및 권한 부여에 사용 +public class UserAuthentication extends UsernamePasswordAuthenticationToken { + private final String kakaoAccessToken; + + public UserAuthentication(Object principal, Object credentials, Collection authorities, String kakaoAccessToken) { + super(principal, credentials, authorities); + this.kakaoAccessToken = kakaoAccessToken; + } + + public String getKakaoAccessToken() { + return kakaoAccessToken; + } +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/base/kakao/controller/KakaoController.java b/src/main/java/danpoong/soenter/base/kakao/controller/KakaoController.java new file mode 100644 index 0000000..957f138 --- /dev/null +++ b/src/main/java/danpoong/soenter/base/kakao/controller/KakaoController.java @@ -0,0 +1,53 @@ +package danpoong.soenter.base.kakao.controller; + +import danpoong.soenter.base.kakao.response.LoginSuccessResponse; +import danpoong.soenter.base.kakao.service.KakaoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Slf4j +@Controller +@RequiredArgsConstructor +@RequestMapping("/api") +@CrossOrigin(origins = "http://localhost:3000") +@Tag(name = "Kakao Login 컨트롤러", description = "카카오 로그인 관련 API") +public class KakaoController { + private final KakaoService kakaoService; + + //TODO : 테스트용 나중에 지워야 함 + @Value("${kakao.get_code_path}") + private String getCodePath; + @Value("${kakao.client_id}") + private String client_id; + @Value(("${kakao.redirect_uri}")) + private String redirect_uri; + + @Operation(summary = "카카오 로그인 페이지 URL 반환", description = "카카오 로그인 페이지로 리다이렉션하기 위한 URL을 반환합니다.") + @GetMapping("/kakao/login") + public String loginPage(Model model) { + String location = getCodePath + client_id + "&redirect_uri=" + redirect_uri; + model.addAttribute("location", location); + return "login"; + } + + @Operation(summary = "카카오 로그인 콜백", description = "카카오 로그인 인증 코드로 사용자 정보를 조회합니다.") + @GetMapping("/kakao/callback") + public ResponseEntity callback(@RequestParam("code") String code) { + try { + LoginSuccessResponse userResponse = kakaoService.kakaoLogin(code); + return ResponseEntity.ok().body(userResponse); + } catch (Exception e) { + return ResponseEntity.status(500).build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/base/kakao/response/KakaoTokenResponseDto.java b/src/main/java/danpoong/soenter/base/kakao/response/KakaoTokenResponseDto.java new file mode 100644 index 0000000..8c4f5ef --- /dev/null +++ b/src/main/java/danpoong/soenter/base/kakao/response/KakaoTokenResponseDto.java @@ -0,0 +1,22 @@ +package danpoong.soenter.base.kakao.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoTokenResponseDto { + @JsonProperty("token_type") + public String tokenType; + @JsonProperty("access_token") + public String accessToken; + @JsonProperty("expires_in") + public Integer expiresIn; + @JsonProperty("refresh_token") + public String refreshToken; + @JsonProperty("refresh_token_expires_in") + public Integer refreshTokenExpiresIn; +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/base/kakao/response/KakaoUserInfoResponseDto.java b/src/main/java/danpoong/soenter/base/kakao/response/KakaoUserInfoResponseDto.java new file mode 100644 index 0000000..92c6bf7 --- /dev/null +++ b/src/main/java/danpoong/soenter/base/kakao/response/KakaoUserInfoResponseDto.java @@ -0,0 +1,96 @@ +package danpoong.soenter.base.kakao.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Date; +import java.util.HashMap; + +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoUserInfoResponseDto { + + //회원 번호 + @JsonProperty("id") + public Long id; + + //자동 연결 설정을 비활성화한 경우만 존재. + //true : 연결 상태, false : 연결 대기 상태 + @JsonProperty("has_signed_up") + public Boolean hasSignedUp; + + //서비스에 연결 완료된 시각. UTC + @JsonProperty("connected_at") + public Date connectedAt; + + //카카오싱크 간편가입을 통해 로그인한 시각. UTC + @JsonProperty("synched_at") + public Date synchedAt; + + //사용자 프로퍼티 + @JsonProperty("properties") + public HashMap properties; + + //카카오 계정 정보 + @JsonProperty("kakao_account") + public KakaoAccount kakaoAccount; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public class KakaoAccount { + + //사용자 프로필 정보 + @JsonProperty("profile") + public Profile profile; + + //카카오계정 이름 + @JsonProperty("name") + public String name; + + //카카오계정 대표 이메일 + @JsonProperty("email") + public String email; + + // 생일 (MMDD 형식) + @JsonProperty("birthday") + public String birthday; + + // 출생연도 (yyyy 형식) + @JsonProperty("birthyear") + public String birthyear; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public class Profile { + + //닉네임 + @JsonProperty("nickname") + public String nickName; + + //프로필 미리보기 이미지 URL + @JsonProperty("thumbnail_image_url") + public String thumbnailImageUrl; + + //프로필 사진 URL + @JsonProperty("profile_image_url") + public String profileImageUrl; + + //프로필 사진 URL 기본 프로필인지 여부 + //true : 기본 프로필, false : 사용자 등록 + @JsonProperty("is_default_image") + public String isDefaultImage; + + //닉네임이 기본 닉네임인지 여부 + //true : 기본 닉네임, false : 사용자 등록 + @JsonProperty("is_default_nickname") + public Boolean isDefaultNickName; + + + } + } +} diff --git a/src/main/java/danpoong/soenter/base/kakao/response/LoginSuccessResponse.java b/src/main/java/danpoong/soenter/base/kakao/response/LoginSuccessResponse.java new file mode 100644 index 0000000..e2e3bd7 --- /dev/null +++ b/src/main/java/danpoong/soenter/base/kakao/response/LoginSuccessResponse.java @@ -0,0 +1,12 @@ +package danpoong.soenter.base.kakao.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class LoginSuccessResponse { + private Long userId; + private String accessToken; + private String kakaoAccessToken; +} diff --git a/src/main/java/danpoong/soenter/base/kakao/service/KakaoService.java b/src/main/java/danpoong/soenter/base/kakao/service/KakaoService.java new file mode 100644 index 0000000..d2b97a7 --- /dev/null +++ b/src/main/java/danpoong/soenter/base/kakao/service/KakaoService.java @@ -0,0 +1,122 @@ +package danpoong.soenter.base.kakao.service; + +import danpoong.soenter.base.jwt.JwtTokenProvider; +import danpoong.soenter.base.jwt.UserAuthentication; +import danpoong.soenter.base.kakao.response.KakaoTokenResponseDto; +import danpoong.soenter.base.kakao.response.KakaoUserInfoResponseDto; +import danpoong.soenter.base.kakao.response.LoginSuccessResponse; +import danpoong.soenter.domain.user.entity.User; +import danpoong.soenter.domain.user.repository.UserRepository; +import io.netty.handler.codec.http.HttpHeaderValues; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class KakaoService { + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${kakao.client_id}") + private String client_id; + + @Value("${kakao.redirect_uri}") + private String redirect_uri; + private final String KAUTH_TOKEN_URL_HOST = "https://kauth.kakao.com"; + private final String KAUTH_USER_URL_HOST = "https://kapi.kakao.com"; + + @Transactional + public LoginSuccessResponse kakaoLogin(String code) { + String accessToken = getAccessToken(code); + KakaoUserInfoResponseDto userInfo = getUserInfo(accessToken); + Long userId = getUserByEmail(userInfo.getKakaoAccount().email).getUserId(); + return getTokenByUserId(userId, accessToken); + } + + public String getAccessToken(String code) { + KakaoTokenResponseDto kakaoTokenResponseDto = WebClient.create(KAUTH_TOKEN_URL_HOST).post() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .path("/oauth/token") + .queryParam("grant_type", "authorization_code") + .queryParam("client_id", client_id) + .queryParam("redirect_uri", redirect_uri) + .queryParam("code", code) + .build(true)) + .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> { + //log.error("액세스 토큰 발급 시 4xx 에러 발생: {}", clientResponse.statusCode()); + return clientResponse.createException().flatMap(Mono::error); + }) + .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> { + //log.error("액세스 토큰 발급 시 5xx 에러 발생: {}", clientResponse.statusCode()); + return clientResponse.createException().flatMap(Mono::error); + }) + .bodyToMono(KakaoTokenResponseDto.class) + .block(); + + assert kakaoTokenResponseDto != null; + return kakaoTokenResponseDto.getAccessToken(); + } + + + + public KakaoUserInfoResponseDto getUserInfo(String accessToken) { + KakaoUserInfoResponseDto userInfo = WebClient.create(KAUTH_USER_URL_HOST).get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .path("/v2/user/me") + .build(true)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + .bodyToMono(KakaoUserInfoResponseDto.class) + .block(); + + if (!isDuplicateEmail(userInfo.getKakaoAccount().email)) { + User newUser = User.builder() + .email(userInfo.getKakaoAccount().email) + .name(userInfo.getKakaoAccount().getProfile().nickName) + .socialType("kakao") + .build(); + userRepository.save(newUser); + } + + return userInfo; + } + + + public User getUserByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("이메일이 없습니다.")); + } + + public LoginSuccessResponse getTokenByUserId(Long userId, String kakaoAccessToken) { + UserAuthentication userAuthentication = new UserAuthentication( + userId, + null, + null, + kakaoAccessToken); + + String accessToken = jwtTokenProvider.generateToken(userAuthentication); + + User users = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("Not Found User")); + + + return new LoginSuccessResponse(userId, accessToken, kakaoAccessToken); + } + + private boolean isDuplicateEmail(String email) { + return userRepository.findByEmail(email).isPresent(); + } +} \ No newline at end of file diff --git a/src/main/java/danpoong/soenter/domain/enterprise/EnterpriseController.java b/src/main/java/danpoong/soenter/domain/enterprise/EnterpriseController.java index 4a5ddd9..ca508a3 100644 --- a/src/main/java/danpoong/soenter/domain/enterprise/EnterpriseController.java +++ b/src/main/java/danpoong/soenter/domain/enterprise/EnterpriseController.java @@ -3,6 +3,8 @@ import danpoong.soenter.base.ApiResponse; import danpoong.soenter.domain.enterprise.entity.Enterprise; import danpoong.soenter.domain.enterprise.entity.Region; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -13,11 +15,13 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/enterprises") +@RequestMapping("/api/enterprises") +@Tag(name = "Enterprise 컨트롤러", description = "기업 정보 관련 API") public class EnterpriseController { private final EnterpriseService enterpriseService; + @Operation(summary = "지역별 기업 조회", description = "특정 지역에 속한 기업 목록을 반환합니다.") @GetMapping("/{region}") public ApiResponse> getEnterprisesByRegion(@PathVariable("region") Region region) { return enterpriseService.getEnterprisesByRegion(region); diff --git a/src/main/java/danpoong/soenter/domain/user/repository/UserRepository.java b/src/main/java/danpoong/soenter/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..f474651 --- /dev/null +++ b/src/main/java/danpoong/soenter/domain/user/repository/UserRepository.java @@ -0,0 +1,10 @@ +package danpoong.soenter.domain.user.repository; + +import danpoong.soenter.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 073ad6c..a1879b8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,9 @@ +# Kakao 설정 +kakao: + client_id: ${KAKAO_CLIENT_ID} + redirect_uri: https://api.ssoenter.store/api/kakao/callback + get_code_path: https://kauth.kakao.com/oauth/authorize?response_type=code&client_id= + # MySQL 연결 설정 spring: jpa: @@ -13,3 +19,8 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + +# JWT 설정 +jwt: + secret: ${JWT_SECRET} + diff --git a/src/main/resources/static/images/kakao.png b/src/main/resources/static/images/kakao.png new file mode 100644 index 0000000..09bb358 Binary files /dev/null and b/src/main/resources/static/images/kakao.png differ diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..cc506df --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,17 @@ + + + + Home + + +

Welcome

+

You are logged in.

+ +

User INFO

+

ID:

+

Email:

+

Nickname:

+

Access Token:

+ + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..ccd430a --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,15 @@ + + + + + KakaoLogin + + +
+

카카오 로그인

+ + + +
+ + \ No newline at end of file