diff --git a/build.gradle b/build.gradle index 52e13fb5..f40ad6cd 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-quartz' implementation 'org.springframework:spring-context-support' + // OAuth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' diff --git a/src/main/java/com/ctrls/auto_enter_view/component/OAuth2GithubSuccessHandler.java b/src/main/java/com/ctrls/auto_enter_view/component/OAuth2GithubSuccessHandler.java new file mode 100644 index 00000000..4519a514 --- /dev/null +++ b/src/main/java/com/ctrls/auto_enter_view/component/OAuth2GithubSuccessHandler.java @@ -0,0 +1,64 @@ +package com.ctrls.auto_enter_view.component; + +import com.ctrls.auto_enter_view.entity.CandidateEntity; +import com.ctrls.auto_enter_view.enums.UserRole; +import com.ctrls.auto_enter_view.repository.CandidateRepository; +import com.ctrls.auto_enter_view.security.JwtTokenProvider; +import com.ctrls.auto_enter_view.util.RandomGenerator; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2GithubSuccessHandler implements AuthenticationSuccessHandler { + + private final CandidateRepository candidateRepository; + private final JwtTokenProvider jwtTokenProvider; + private final KeyGenerator keyGenerator; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String email = oAuth2User.getAttribute("email"); + String name = oAuth2User.getAttribute("name"); + + CandidateEntity candidate = candidateRepository.findByEmail(email) + .orElseGet(() -> createNewCandidate(email, name)); + + String token = jwtTokenProvider.generateToken(candidate.getEmail(), candidate.getRole()); + + response.setHeader("Authorization", "Bearer " + token); + response.sendRedirect("/common/job-postings?page=1"); + } + + private CandidateEntity createNewCandidate(String email, String name) { + + String randomPassword = RandomGenerator.generateTemporaryPassword(); + String encodedPassword = passwordEncoder.encode(randomPassword); + + CandidateEntity newCandidate = CandidateEntity.builder() + .candidateKey(keyGenerator.generateKey()) + .email(email) + .name(name) + .password(encodedPassword) + .phoneNumber("temp_number") + .role(UserRole.ROLE_CANDIDATE) + .build(); + + return candidateRepository.save(newCandidate); + } +} \ No newline at end of file diff --git a/src/main/java/com/ctrls/auto_enter_view/config/PasswordEncoderConfig.java b/src/main/java/com/ctrls/auto_enter_view/config/PasswordEncoderConfig.java new file mode 100644 index 00000000..9f266328 --- /dev/null +++ b/src/main/java/com/ctrls/auto_enter_view/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.ctrls.auto_enter_view.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ctrls/auto_enter_view/dto/auth/GithubOAuthDto.java b/src/main/java/com/ctrls/auto_enter_view/dto/auth/GithubOAuthDto.java new file mode 100644 index 00000000..70f291ad --- /dev/null +++ b/src/main/java/com/ctrls/auto_enter_view/dto/auth/GithubOAuthDto.java @@ -0,0 +1,19 @@ +package com.ctrls.auto_enter_view.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class GithubOAuthDto { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + private String candidateKey; + private String email; + private String name; + } +} diff --git a/src/main/java/com/ctrls/auto_enter_view/enums/ErrorCode.java b/src/main/java/com/ctrls/auto_enter_view/enums/ErrorCode.java index e02b284f..8c73613a 100644 --- a/src/main/java/com/ctrls/auto_enter_view/enums/ErrorCode.java +++ b/src/main/java/com/ctrls/auto_enter_view/enums/ErrorCode.java @@ -13,6 +13,7 @@ public enum ErrorCode { EMAIL_DUPLICATION(409, "이메일이 중복됩니다."), COMPANY_NUMBER_DUPLICATION(409, "회사 전화번호가 중복됩니다."), EMAIL_NOT_FOUND(404, "가입된 사용자 이메일이 없습니다."), + NAME_NOT_FOUND(404, "가입된 사용자 이름이 없습니다."), EMAIL_SEND_FAILURE(500, "이메일 전송에 실패했습니다."), INTERNAL_SERVER_ERROR(500, "내부 서버 오류입니다."), INVALID_TOKEN(401, "유효하지 않은 토큰입니다."), @@ -51,7 +52,8 @@ public enum ErrorCode { UNSCHEDULE_FAILED(500, "스케줄링 취소에 실패하였습니다."), FAILED_MAIL_SCHEDULING(500, "메일 예약 등록을 실패했습니다."), FAILED_MAIL_UNSCHEDULING(500, "메일 예약 취소를 실패했습니다."), - INVALID_CURRENT_STEP_ID(400, "잘못된 채용 공고 단계 입니다."); + INVALID_CURRENT_STEP_ID(400, "잘못된 채용 공고 단계 입니다."), + JSON_PROCESSING_ERROR(500, "JSON 처리 중 오류가 발생했습니다."); private final int status; private final String message; diff --git a/src/main/java/com/ctrls/auto_enter_view/security/SecurityConfig.java b/src/main/java/com/ctrls/auto_enter_view/security/SecurityConfig.java index 89c6e5e4..4779ddf5 100644 --- a/src/main/java/com/ctrls/auto_enter_view/security/SecurityConfig.java +++ b/src/main/java/com/ctrls/auto_enter_view/security/SecurityConfig.java @@ -1,6 +1,8 @@ package com.ctrls.auto_enter_view.security; +import com.ctrls.auto_enter_view.component.OAuth2GithubSuccessHandler; import com.ctrls.auto_enter_view.enums.UserRole; +import com.ctrls.auto_enter_view.service.GithubOAuthService; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -11,8 +13,6 @@ 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; @@ -26,12 +26,9 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final GithubOAuthService githubOAuthService; + private final OAuth2GithubSuccessHandler oAuth2GithubSuccessHandler; - @Bean - public PasswordEncoder passwordEncoder() { - - return new BCryptPasswordEncoder(); - } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -58,8 +55,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/candidates/find-email").permitAll() .requestMatchers("/common/**").permitAll() .requestMatchers(HttpMethod.GET, "/companies/{companyKey}/information").permitAll() - .requestMatchers("/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**") - .permitAll() + .requestMatchers("/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**").permitAll() + .requestMatchers( "/oauth2/**", "/login/**").permitAll() // 권한 필요 (candidate, company 둘 중 하나) .requestMatchers("/common/signout", "common/{key}/password").authenticated() @@ -76,7 +73,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/interview-schedule-participants/**") .hasRole(UserRole.ROLE_COMPANY.name().substring(5)) - .anyRequest().authenticated()) + .anyRequest().authenticated() + + ) + + // OAuth2 로그인 설정 추가 + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(githubOAuthService)) + .successHandler(oAuth2GithubSuccessHandler) + ) + // JWT 필터 추가 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/ctrls/auto_enter_view/service/GithubOAuthService.java b/src/main/java/com/ctrls/auto_enter_view/service/GithubOAuthService.java new file mode 100644 index 00000000..1948ace8 --- /dev/null +++ b/src/main/java/com/ctrls/auto_enter_view/service/GithubOAuthService.java @@ -0,0 +1,92 @@ +package com.ctrls.auto_enter_view.service; + +import com.ctrls.auto_enter_view.enums.ErrorCode; +import com.ctrls.auto_enter_view.enums.UserRole; +import com.ctrls.auto_enter_view.exception.CustomException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +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 org.springframework.web.client.RestTemplate; + +// GitHub OAuth 인증을 처리하는 서비스 클래스 +@Service +@RequiredArgsConstructor +public class GithubOAuthService extends DefaultOAuth2UserService { + + // OAuth2UserRequest를 기반으로 사용자 정보를 로드하는 메서드 + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // 부모 클래스의 loadUser 메서드를 호출하여 기본 OAuth2User 객체를 얻음 + OAuth2User oAuth2User = super.loadUser(userRequest); + + // GitHub API에 접근하기 위한 액세스 토큰 설정 + String token = userRequest.getAccessToken().getTokenValue(); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + HttpEntity entity = new HttpEntity<>(headers); + + RestTemplate restTemplate = new RestTemplate(); + + // GitHub API를 통해 사용자의 이메일 정보 가져오기 + ResponseEntity emailResponse = restTemplate.exchange("https://api.github.com/user/emails", HttpMethod.GET, entity, String.class); + String email = extractEmail(emailResponse); + + // GitHub API를 통해 사용자 정보 가져오기 + ResponseEntity userResponse = restTemplate.exchange("https://api.github.com/user", HttpMethod.GET, entity, String.class); + String name = extractName(userResponse); + + // 이메일이 없으면 예외 발생 + if (email == null) { + throw new CustomException(ErrorCode.EMAIL_NOT_FOUND); + } + + // 이름이 없으면 예외 발생 + if (name == null) { + throw new CustomException(ErrorCode.NAME_NOT_FOUND); + } + + // 사용자 정보를 OAuth2User 형태로 반환하기 위해 속성 맵 생성 + Map attributes = new HashMap<>(oAuth2User.getAttributes()); + attributes.put("email", email); + attributes.put("name", name); + + // DefaultOAuth2User 객체 생성 및 반환 + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority(UserRole.ROLE_CANDIDATE.name())), + attributes, + "id" + ); + } + + // GitHub API 응답에서 이메일 추출하는 메서드 + private String extractEmail(ResponseEntity response) { + try { + return new ObjectMapper().readTree(response.getBody()).get(0).get("email").asText(); + } catch (JsonProcessingException e) { + throw new CustomException(ErrorCode.JSON_PROCESSING_ERROR); + } + } + + // GitHub API 응답에서 이름 추출하는 메서드 + private String extractName(ResponseEntity response) { + try { + return new ObjectMapper().readTree(response.getBody()).get("name").asText(); + } catch (JsonProcessingException e) { + throw new CustomException(ErrorCode.JSON_PROCESSING_ERROR); + } + } +} \ No newline at end of file