diff --git a/src/main/java/com/ogjg/daitgym/config/security/SecurityConfig.java b/src/main/java/com/ogjg/daitgym/config/security/SecurityConfig.java index 285f3fb8..3d95192f 100644 --- a/src/main/java/com/ogjg/daitgym/config/security/SecurityConfig.java +++ b/src/main/java/com/ogjg/daitgym/config/security/SecurityConfig.java @@ -1,6 +1,8 @@ package com.ogjg.daitgym.config.security; import com.ogjg.daitgym.config.security.jwt.authentication.JwtAuthenticationProvider; +import com.ogjg.daitgym.config.security.jwt.filter.JwtAccessTokenAuthenticationFilter; +import com.ogjg.daitgym.config.security.jwt.filter.JwtRefreshTokenAuthenticationFilter; import com.ogjg.daitgym.config.security.jwt.handler.JwtAuthenticationEntryPoint; import com.ogjg.daitgym.config.security.oauth.CustomOAuth2UserService; import com.ogjg.daitgym.domain.Role; @@ -15,10 +17,12 @@ 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.CsrfConfigurer; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsUtils; @@ -45,16 +49,17 @@ public class SecurityConfig { private final List permitJwtUrlList = new ArrayList<>( List.of( + "/", "/favicon.ico", + "/login/oauth2/callback/kakao.*", "/login/oauth2/code/.*", - "/oauth2/authorization/.*", "/api/users/token", "/api/token/new", "/health", "/ws/.*", - "/chat/.*" - + "/chat/.*", + "/h2-console/.*" )); @Bean @@ -68,16 +73,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .frameOptions((frameOptionsConfig) -> frameOptionsConfig.sameOrigin()) ) ) -// .addFilterBefore(jwtAccessTokenAuthenticationFilter(), OAuth2AuthorizationRequestRedirectFilter.class) -// .addFilterAfter(jwtRefreshTokenAuthenticationFilter(), JwtAccessTokenAuthenticationFilter.class) + .addFilterBefore(jwtAccessTokenAuthenticationFilter(), OAuth2AuthorizationRequestRedirectFilter.class) + .addFilterAfter(jwtRefreshTokenAuthenticationFilter(), JwtAccessTokenAuthenticationFilter.class) .authorizeHttpRequests( authorize -> authorize .requestMatchers(CorsUtils::isPreFlightRequest) .permitAll() - .requestMatchers("/**").permitAll() - .requestMatchers("/api/admins/**").hasRole(Role.ADMIN.name()) - .requestMatchers("/api/trainers/**").hasRole(Role.TRAINER.name()) - .requestMatchers("/api/profiles/**").hasRole(Role.USER.name()) + .requestMatchers(new AntPathRequestMatcher("/api/admins/**")).hasRole(Role.ADMIN.name()) + .requestMatchers(new AntPathRequestMatcher("/api/trainers/**")).hasRole(Role.TRAINER.name()) + .requestMatchers(new AntPathRequestMatcher("/**")).permitAll() .anyRequest().authenticated() ).exceptionHandling((exceptionHandle) -> exceptionHandle .accessDeniedHandler(accessDeniedHandler) @@ -115,17 +119,17 @@ public CorsConfigurationSource corsConfigurationSource() { public AuthenticationManager authenticationManager() throws Exception { return authenticationConfiguration.getAuthenticationManager(); } -// -// @Bean -// public JwtAccessTokenAuthenticationFilter jwtAccessTokenAuthenticationFilter() throws Exception { -// authenticationManagerBuilder.authenticationProvider(jwtAuthenticationProvider()); -// return new JwtAccessTokenAuthenticationFilter(authenticationManager(), jwtAuthenticationEntryPoint(), permitJwtUrlList); -// } -// -// @Bean -// public JwtRefreshTokenAuthenticationFilter jwtRefreshTokenAuthenticationFilter() throws Exception { -// return new JwtRefreshTokenAuthenticationFilter(authenticationManager(), jwtAuthenticationEntryPoint()); -// } + + @Bean + public JwtAccessTokenAuthenticationFilter jwtAccessTokenAuthenticationFilter() throws Exception { + authenticationManagerBuilder.authenticationProvider(jwtAuthenticationProvider()); + return new JwtAccessTokenAuthenticationFilter(authenticationManager(), jwtAuthenticationEntryPoint(), permitJwtUrlList); + } + + @Bean + public JwtRefreshTokenAuthenticationFilter jwtRefreshTokenAuthenticationFilter() throws Exception { + return new JwtRefreshTokenAuthenticationFilter(authenticationManager(), jwtAuthenticationEntryPoint()); + } @Bean public AuthenticationProvider jwtAuthenticationProvider() { diff --git a/src/main/java/com/ogjg/daitgym/config/security/jwt/filter/JwtAccessTokenAuthenticationFilter.java b/src/main/java/com/ogjg/daitgym/config/security/jwt/filter/JwtAccessTokenAuthenticationFilter.java index 21cff65d..cc821618 100644 --- a/src/main/java/com/ogjg/daitgym/config/security/jwt/filter/JwtAccessTokenAuthenticationFilter.java +++ b/src/main/java/com/ogjg/daitgym/config/security/jwt/filter/JwtAccessTokenAuthenticationFilter.java @@ -30,11 +30,6 @@ public class JwtAccessTokenAuthenticationFilter extends OncePerRequestFilter { private final List permitUrlList; - /** - * AccessToken, RefreshToken에 사용하는 Provider의 기능이 완전히 같아서 1개의 Provider만 사용 - * Provider에서 발생한 예외가 AccessToken에서 발생한 예외인지, RefreshToken에서 발생한 예외인지 구분이 필요했습니다. - * 그래서 기존 메시지를 집어넣어 되던졌습니다. - */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (isPermitted(request.getRequestURI())) { diff --git a/src/main/java/com/ogjg/daitgym/config/security/oauth/handler/Oauth2AuthenticationSuccessHandler.java b/src/main/java/com/ogjg/daitgym/config/security/oauth/handler/Oauth2AuthenticationSuccessHandler.java index b793adaf..8768d578 100644 --- a/src/main/java/com/ogjg/daitgym/config/security/oauth/handler/Oauth2AuthenticationSuccessHandler.java +++ b/src/main/java/com/ogjg/daitgym/config/security/oauth/handler/Oauth2AuthenticationSuccessHandler.java @@ -1,11 +1,8 @@ package com.ogjg.daitgym.config.security.oauth.handler; import com.fasterxml.jackson.databind.ObjectMapper; -import com.ogjg.daitgym.common.exception.ErrorCode; -import com.ogjg.daitgym.common.response.ApiResponse; import com.ogjg.daitgym.config.security.details.OAuth2JwtUserDetails; import com.ogjg.daitgym.config.security.jwt.dto.JwtUserClaimsDto; -import com.ogjg.daitgym.config.security.oauth.dto.LoginResponseDto; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -49,13 +46,13 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String cachedUrl = getCachedUrlOrDefault(request, response); - objectMapper.writeValue( - response.getWriter(), - new ApiResponse<>( - ErrorCode.SUCCESS, - LoginResponseDto.of(OAuth2UserDetails, cachedUrl) - ) - ); +// objectMapper.writeValue( +// response.getWriter(), +// new ApiResponse<>( +// ErrorCode.SUCCESS, +// LoginResponseDto.of(OAuth2UserDetails, cachedUrl) +// ) +// ); } private void addTokensInHeader(HttpServletResponse response, JwtUserClaimsDto jwtUserClaimsDto) { diff --git a/src/main/java/com/ogjg/daitgym/domain/User.java b/src/main/java/com/ogjg/daitgym/domain/User.java index 25bbb08a..7438e651 100644 --- a/src/main/java/com/ogjg/daitgym/domain/User.java +++ b/src/main/java/com/ogjg/daitgym/domain/User.java @@ -96,4 +96,8 @@ public void changeHealthClub(HealthClub newHealthClub) { } this.healthClub = newHealthClub; } -} \ No newline at end of file + + public boolean isAdmin() { + return this.role == Role.ADMIN; + } +} diff --git a/src/main/java/com/ogjg/daitgym/user/controller/LoginController.java b/src/main/java/com/ogjg/daitgym/user/controller/LoginController.java new file mode 100644 index 00000000..07e3210d --- /dev/null +++ b/src/main/java/com/ogjg/daitgym/user/controller/LoginController.java @@ -0,0 +1,31 @@ +package com.ogjg.daitgym.user.controller; + +import com.ogjg.daitgym.common.exception.ErrorCode; +import com.ogjg.daitgym.common.response.ApiResponse; +import com.ogjg.daitgym.user.dto.LoginResponseDto; +import com.ogjg.daitgym.user.service.AuthService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class LoginController { + + private final AuthService authService; + + @GetMapping("/login/oauth2/callback/kakao") + public ApiResponse kakaoLogin( + @RequestParam("code") String code, + HttpServletResponse httpServletResponse + ) { + String kakaoAccessToken = authService.getKakaoAccessToken(code).getAccess_token(); + + return new ApiResponse<>( + ErrorCode.SUCCESS, + authService.kakaoLogin(kakaoAccessToken, httpServletResponse) + ); + } +} diff --git a/src/main/java/com/ogjg/daitgym/user/dto/KakaoAccountDto.java b/src/main/java/com/ogjg/daitgym/user/dto/KakaoAccountDto.java new file mode 100644 index 00000000..f856869f --- /dev/null +++ b/src/main/java/com/ogjg/daitgym/user/dto/KakaoAccountDto.java @@ -0,0 +1,23 @@ +package com.ogjg.daitgym.user.dto; + +import lombok.Data; +import lombok.Getter; + +@Getter +public class KakaoAccountDto { + + private Long id; + private KakaoAccount kakao_account; + @Data + public static class KakaoAccount { + + private String email; + private Profile profile; + + @Data + public static class Profile { + + private String nickname; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ogjg/daitgym/config/security/oauth/dto/LoginResponseDto.java b/src/main/java/com/ogjg/daitgym/user/dto/LoginResponseDto.java similarity index 51% rename from src/main/java/com/ogjg/daitgym/config/security/oauth/dto/LoginResponseDto.java rename to src/main/java/com/ogjg/daitgym/user/dto/LoginResponseDto.java index 04d239d7..8ac8a45d 100644 --- a/src/main/java/com/ogjg/daitgym/config/security/oauth/dto/LoginResponseDto.java +++ b/src/main/java/com/ogjg/daitgym/user/dto/LoginResponseDto.java @@ -1,38 +1,43 @@ -package com.ogjg.daitgym.config.security.oauth.dto; +package com.ogjg.daitgym.user.dto; import com.ogjg.daitgym.config.security.details.OAuth2JwtUserDetails; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.UUID; + @Getter @Builder @NoArgsConstructor public class LoginResponseDto { - private String email; private String nickname; private String userImg; - private String initialRequestUrl; + private String preferredSplit; private boolean isAlreadyJoined; - public LoginResponseDto(String email, String nickname, String userImg, String initialRequestUrl, boolean isAlreadyJoined) { - this.email = email; + private boolean isAdmin; + + private boolean isDeleted; + + @Builder + public LoginResponseDto(String nickname, String userImg, String preferredSplit, boolean isAlreadyJoined, boolean isAdmin, boolean isDeleted) { this.nickname = nickname; this.userImg = userImg; - this.initialRequestUrl = initialRequestUrl; + this.preferredSplit = preferredSplit; this.isAlreadyJoined = isAlreadyJoined; + this.isAdmin = isAdmin; + this.isDeleted = isDeleted; } - public static LoginResponseDto of(OAuth2JwtUserDetails oAuth2UserDetails, String initialRequestUrl) { + public static LoginResponseDto from(OAuth2JwtUserDetails oAuth2UserDetails) { return LoginResponseDto.builder() - .nickname(oAuth2UserDetails.getNickname()) - .email(oAuth2UserDetails.getEmail()) + .nickname(UUID.randomUUID().toString()) .userImg("default") - .initialRequestUrl(initialRequestUrl) .isAlreadyJoined(oAuth2UserDetails.isAlreadyJoined()) .build(); } diff --git a/src/main/java/com/ogjg/daitgym/user/repository/UserRepository.java b/src/main/java/com/ogjg/daitgym/user/repository/UserRepository.java index f248750f..abb4caed 100644 --- a/src/main/java/com/ogjg/daitgym/user/repository/UserRepository.java +++ b/src/main/java/com/ogjg/daitgym/user/repository/UserRepository.java @@ -2,6 +2,8 @@ import com.ogjg.daitgym.domain.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -9,4 +11,10 @@ public interface UserRepository extends JpaRepository { Optional findByNickname(String nickName); Optional findByEmail(String email); + + + @Query(""" + select u from User u where u.email = :email +""") + Optional findByEmailIncludingDeleted(@Param("email") String email); } diff --git a/src/main/java/com/ogjg/daitgym/user/service/AuthService.java b/src/main/java/com/ogjg/daitgym/user/service/AuthService.java new file mode 100644 index 00000000..ea487318 --- /dev/null +++ b/src/main/java/com/ogjg/daitgym/user/service/AuthService.java @@ -0,0 +1,236 @@ +package com.ogjg.daitgym.user.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.ogjg.daitgym.config.security.jwt.dto.JwtUserClaimsDto; +import com.ogjg.daitgym.user.dto.LoginResponseDto; +import com.ogjg.daitgym.domain.Role; +import com.ogjg.daitgym.domain.User; +import com.ogjg.daitgym.user.dto.KakaoAccountDto; +import com.ogjg.daitgym.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.UUID; + +import static com.ogjg.daitgym.config.security.jwt.util.JwtUtils.*; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + + @Value("${kakao.client-id}") + private String KAKAO_CLIENT_ID; + + @Value("${kakao.redirect-uri}") + private String KAKAO_REDIRECT_URI; + + @Value("${kakao.client-secret}") + private String KAKAO_CLIENT_SECRET; + + @Value("${kakao.token-uri}") + private String KAKAO_TOKEN_URI; + + @Value("${kakao.user-info-uri}") + private String KAKAO_USER_INFO_URI; + + // 토큰으로 사용자 정보 가져오기 -> 처음 로그인인지 체크하고 로그인 응답 생성 + @Transactional + public LoginResponseDto kakaoLogin(String kakaoAccessToken, HttpServletResponse servletResponse) { + return getKakaoInfo(kakaoAccessToken, servletResponse); + } + + // 어세스 토큰으로 사용자 정보 가져오기 -> 첫 로그인이라면 가입처리 + @Transactional + public LoginResponseDto getKakaoInfo(String kakaoAccessToken, HttpServletResponse servletResponse) { + RestTemplate rt = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + kakaoAccessToken); + headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + + HttpEntity> accountInfoRequest = new HttpEntity<>(headers); + + // POST 방식으로 API 서버에 요청 후 response 받아옴 + ResponseEntity accountInfoResponse = rt.exchange( + KAKAO_USER_INFO_URI, // "https://kapi.kakao.com/v2/user/me" + HttpMethod.POST, + accountInfoRequest, + String.class + ); + + // JSON Parsing (-> kakaoAccountDto) + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + KakaoAccountDto kakaoAccountDto = null; + + try { + kakaoAccountDto = objectMapper.readValue(accountInfoResponse.getBody(), KakaoAccountDto.class); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + + // todo 탈퇴해서 아이디가 남아있는 회원의 처리가 추가되어야 한다. + // 회원가입 처리 -> 존재하면 정보 가져오기, 존재하지 않으면 새로 저장 + String kakaoEmail = kakaoAccountDto.getKakao_account().getEmail(); + User existUser = userRepository.findByEmailIncludingDeleted(kakaoEmail) + .orElse(null); + + boolean isAlreadyJoined = false; + boolean isDeleted = false; + String tempNickname = UUID.randomUUID().toString(); + + // 첫 가입 + if (existUser == null) { + isAlreadyJoined = false; + isDeleted = false; + + // 가입 처리 + User user = User.builder() + .email(kakaoAccountDto.getKakao_account().getEmail()) + .nickname(tempNickname) + .role(Role.USER) + .build(); + + userRepository.save(user); + + JwtUserClaimsDto jwtUserClaimsDto = JwtUserClaimsDto.builder() + .email(kakaoEmail) + .role(Role.USER) + .build(); + addTokensInHeader(servletResponse, jwtUserClaimsDto); + + return LoginResponseDto.builder() + .isAlreadyJoined(isAlreadyJoined) + .isDeleted(isDeleted) + .isAdmin(false) + .userImg("defaultUrl") + .nickname(tempNickname) + .build(); + + // todo : 가입했다 탈퇴한 회원 처리 + } else if (existUser.isDeleted() == true){ + isAlreadyJoined = true; + isDeleted = true; + + return LoginResponseDto.builder() + .isAlreadyJoined(isAlreadyJoined) + .isDeleted(isDeleted) + .isAdmin(false) + .userImg("defaultUrl") + .nickname(tempNickname) + .build(); + + // 가입한 회원 -> 유저정보를 불러온다. + } else { + isAlreadyJoined = true; + isDeleted = false; + + JwtUserClaimsDto jwtUserClaimsDto = JwtUserClaimsDto.builder() + .email(kakaoEmail) + .role(Role.USER) + .build(); + addTokensInHeader(servletResponse, jwtUserClaimsDto); + + return LoginResponseDto.builder() + .isAlreadyJoined(isAlreadyJoined) + .isDeleted(isDeleted) + .isAdmin(existUser.isAdmin()) + .userImg(existUser.getImageUrl()) + .nickname(existUser.getNickname()) + .build(); + } + } + private void addTokensInHeader(HttpServletResponse response, JwtUserClaimsDto jwtUserClaimsDto) { + String accessToken = TokenGenerator.generateAccessToken(jwtUserClaimsDto); + String refreshToken = TokenGenerator.generateRefreshToken(jwtUserClaimsDto); + + response.addHeader(HEADER_AUTHORIZATION, TOKEN_PREFIX + accessToken); + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setHeader("Set-Cookie", createRefreshTokenCookie(refreshToken).toString()); + response.setCharacterEncoding("UTF-8"); + } + + private ResponseCookie createRefreshTokenCookie(String refreshToken) { + return ResponseCookie.from("refreshToken", refreshToken) + .maxAge(60 * 60 * 24 * 30) + .path("/") + .httpOnly(true) + .secure(true) + .sameSite("None") + .build(); + } + + // 인가코드 보내서 토큰 받아오기, KakaoDto에 바인딩을 직접해줘서 반환한다. + private void addTokenInHeader(HttpServletResponse response, String accessToken) { + response.addHeader(HEADER_AUTHORIZATION, TOKEN_PREFIX + accessToken); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(CHARSET_UTF_8); + } + + @Transactional + public KakaoTokenDto getKakaoAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + + // Http Response Body 객체 생성 + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); //카카오 공식문서 기준 authorization_code 로 고정 + params.add("client_id", KAKAO_CLIENT_ID); // 카카오 Dev 앱 REST API 키 + params.add("redirect_uri", KAKAO_REDIRECT_URI); // 카카오 Dev redirect uri + params.add("code", code); // 프론트에서 인가 코드 요청시 받은 인가 코드값 + params.add("client_secret", KAKAO_CLIENT_SECRET); // 카카오 Dev 카카오 로그인 Client Secret + + // 헤더와 바디 합치기 위해 Http Entity 객체 생성 + HttpEntity> kakaoTokenRequest = new HttpEntity<>(params, headers); + + // 카카오로부터 Access token 받아오기 + RestTemplate rt = new RestTemplate(); + ResponseEntity accessTokenResponse = rt.exchange( + KAKAO_TOKEN_URI, // "https://kauth.kakao.com/oauth/token" + HttpMethod.POST, + kakaoTokenRequest, + String.class + ); + + // JSON Parsing (-> KakaoTokenDto) + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + KakaoTokenDto kakaoTokenDto = null; + try { + kakaoTokenDto = objectMapper.readValue(accessTokenResponse.getBody(), KakaoTokenDto.class); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + + return kakaoTokenDto; + } + + @Data + public static class KakaoTokenDto { + + private String access_token; + private String token_type; + private String refresh_token; + private String id_token; + private int expires_in; + private int refresh_token_expires_in; + private String scope; + } +} +