From 212ff2c61f87b7e5fc229c5ae2aabfe4bf484ab8 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sat, 13 Jan 2024 01:36:45 +0900 Subject: [PATCH 01/26] =?UTF-8?q?[#3]=20chore:=20Security,=20OAuth=20Depen?= =?UTF-8?q?dency=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 6e393e57..56eef645 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,9 @@ dependencies { // Swagger UI - spring doc implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + // Spring Security + OAuth + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { From e6a2b3c99ed1391556c3f20427a8e1224a2bc7b4 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sat, 13 Jan 2024 01:37:09 +0900 Subject: [PATCH 02/26] =?UTF-8?q?[#3]=20chore:=20FilterChain=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/config/SecurityConfig.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/com/gongjakso/server/global/config/SecurityConfig.java diff --git a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java new file mode 100644 index 00000000..e19f3082 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java @@ -0,0 +1,21 @@ +package com.gongjakso.server.global.config; + +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.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + //http.cors().configurationSource(); + + return http.build(); + } +} From 16ee46a80d62988a5486b8f7cebb451a482400e8 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sun, 14 Jan 2024 01:47:50 +0900 Subject: [PATCH 03/26] =?UTF-8?q?[#3]=20chore:=20Security=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/config/SecurityConfig.java | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java index e19f3082..29cca624 100644 --- a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java +++ b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java @@ -3,19 +3,85 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; 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.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + /** + * FilterChain 설정 + * @param http - 시큐리티 설정을 담당하는 객체 + * @return - FilterChain 진행 값 반환 + * @throws Exception - 시큐리티 설정 관련 모든 예외 (JWT 관련 포함) + */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - //http.cors().configurationSource(); + // CORS 허용, CSRF 비활성화 + http.cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable); + + // Session 미사용 + http.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + // httpBasic, httpFormLogin 비활성화 + http.httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable); + + // 요청 URI별 권한 설정 + http.authorizeHttpRequests((authorize) -> + // Swagger UI 외부 접속 허용 + authorize.requestMatchers( "/api-docs/**", "/swagger-ui/**").permitAll() + // 로그인 로직 접속 허용 + .requestMatchers(HttpMethod.POST, "/api/v1/auth/").permitAll() + // 메인 페이지, 공고 페이지 등에 한해 인증 정보 없이 접근 가능 (추후 추가) + // 이외의 모든 요청은 인증 정보 필요 + .anyRequest().authenticated()); return http.build(); } + + /** + * CORS 허용하도록 커스터마이징 진행 + * @return - 변경된 CORS 정책 정보 반환 + */ + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // 인증정보 주고받도록 허용 + config.setAllowCredentials(true); + // 허용할 주소 + config.setAllowedOrigins(List.of("http://localhost:3000")); + // 허용할 HTTP Method + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + // 허용할 헤더 정보 + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return source; + } + + @Bean + public PasswordEncoder passwordEncoder(){ + // 비밀번호 암호화 + return new BCryptPasswordEncoder(); + } } From e3cc0fafb80dcc8121c06ed03900a780f88edb07 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Tue, 16 Jan 2024 00:15:50 +0900 Subject: [PATCH 04/26] =?UTF-8?q?[#3]=20chore:=20Redis=20Dependency=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 56eef645..90149999 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,9 @@ dependencies { // MySQL runtimeOnly 'com.mysql:mysql-connector-j' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Swagger UI - spring doc implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' From cbb2d73bc62f0eca113c1750d2f31dfd0b41686f Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sat, 20 Jan 2024 16:17:43 +0900 Subject: [PATCH 05/26] =?UTF-8?q?[#3]=20chore:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=A7=81=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/exception/ApplicationException.java | 4 ++++ .../server/global/exception/GlobalExceptionHandler.java | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 src/main/java/com/gongjakso/server/global/exception/ApplicationException.java create mode 100644 src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java diff --git a/src/main/java/com/gongjakso/server/global/exception/ApplicationException.java b/src/main/java/com/gongjakso/server/global/exception/ApplicationException.java new file mode 100644 index 00000000..703cbe43 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/exception/ApplicationException.java @@ -0,0 +1,4 @@ +package com.gongjakso.server.global.exception; + +public class ApplicationException { +} diff --git a/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java b/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..20f0697c --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,4 @@ +package com.gongjakso.server.global.exception; + +public class GlobalExceptionHandler { +} From a260c19d77a5e61155c126d5908e083c2dfa4d70 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sat, 20 Jan 2024 16:18:17 +0900 Subject: [PATCH 06/26] =?UTF-8?q?[#3]=20chore:=20Redis=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=84=A4=EC=A0=95=20=EB=B0=8F=20Component=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/config/RedisConfig.java | 47 +++++++++++++++++++ .../server/global/redis/RedisClient.java | 13 +++++ 2 files changed, 60 insertions(+) create mode 100644 src/main/java/com/gongjakso/server/global/config/RedisConfig.java create mode 100644 src/main/java/com/gongjakso/server/global/redis/RedisClient.java diff --git a/src/main/java/com/gongjakso/server/global/config/RedisConfig.java b/src/main/java/com/gongjakso/server/global/config/RedisConfig.java new file mode 100644 index 00000000..f6a75aab --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/config/RedisConfig.java @@ -0,0 +1,47 @@ +package com.gongjakso.server.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.redis.host}") + private String host; + + @Value("${spring.redis.port}") + private int port; + + /** + * Lettuce 기반으로 Redis 연결 + * @return - RedisConnection을 생성하기 위해 사용 + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + // 일반적인 key:value의 경우 시리얼라이저 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + // Hash를 사용할 경우 시리얼라이저 + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + + // 모든 경우 + redisTemplate.setDefaultSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/src/main/java/com/gongjakso/server/global/redis/RedisClient.java b/src/main/java/com/gongjakso/server/global/redis/RedisClient.java new file mode 100644 index 00000000..ec7c08dc --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/redis/RedisClient.java @@ -0,0 +1,13 @@ +package com.gongjakso.server.global.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisClient { + + private final RedisTemplate redisTemplate; + +} From dbb6b3d2b5af715122f86756150ebdcbcc4b08cb Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sat, 20 Jan 2024 16:18:32 +0900 Subject: [PATCH 07/26] =?UTF-8?q?[#3]=20chore:=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B0=9D=EC=B2=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gongjakso/server/global/common/ApplicationResponse.java | 4 ++++ .../com/gongjakso/server/global/exception/ErrorResponse.java | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 src/main/java/com/gongjakso/server/global/common/ApplicationResponse.java create mode 100644 src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java diff --git a/src/main/java/com/gongjakso/server/global/common/ApplicationResponse.java b/src/main/java/com/gongjakso/server/global/common/ApplicationResponse.java new file mode 100644 index 00000000..31e8ab60 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/common/ApplicationResponse.java @@ -0,0 +1,4 @@ +package com.gongjakso.server.global.common; + +public class ApplicationResponse { +} diff --git a/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java b/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java new file mode 100644 index 00000000..fb2b1fc6 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java @@ -0,0 +1,4 @@ +package com.gongjakso.server.global.exception; + +public class ErrorResponse { +} From 87483decd1499ab314d794f1e2bf98f56b8c31ec Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sat, 20 Jan 2024 16:18:54 +0900 Subject: [PATCH 08/26] =?UTF-8?q?[#3]=20chore:=20Security=20+=20JWT=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/CustomUserDetailsService.java | 20 +++++++++ .../security/jwt/JwtAccessDeniedHandler.java | 22 ++++++++++ .../jwt/JwtAuthenticationEntryPoint.java | 42 +++++++++++++++++++ .../{ => security}/jwt/TokenProvider.java | 2 +- 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gongjakso/server/global/security/CustomUserDetailsService.java create mode 100644 src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java rename src/main/java/com/gongjakso/server/global/{ => security}/jwt/TokenProvider.java (65%) diff --git a/src/main/java/com/gongjakso/server/global/security/CustomUserDetailsService.java b/src/main/java/com/gongjakso/server/global/security/CustomUserDetailsService.java new file mode 100644 index 00000000..35de0540 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/CustomUserDetailsService.java @@ -0,0 +1,20 @@ +package com.gongjakso.server.global.security; + +import com.gongjakso.server.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return null; + } +} diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..c097d1d3 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,22 @@ +package com.gongjakso.server.global.security.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 JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + accessDeniedException.getCause().printStackTrace(); + + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(accessDeniedException.getMessage()); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..0e731ae5 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,42 @@ +package com.gongjakso.server.global.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +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; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + // 인증 관련 에러 처리, 401 + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + + Object exception = request.getAttribute("exception"); + +// if (exception instanceof ErrorCode) { +// ErrorCode errorCode = (ErrorCode) exception; +// setResponse(response,errorCode); +// +// return; +// } + + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + } + +// private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException{ +// response.setContentType("application/json;charset=UTF-8"); +// response.setStatus(errorCode.getHttpStatus().value()); +// +// ApplicationErrorResponse errorResponse = new ApplicationErrorResponse(errorCode); +// ObjectMapper objectMapper = new ObjectMapper(); +// String errorJson = objectMapper.writeValueAsString(errorResponse); +// +// response.getWriter().write(errorJson); +// } +} diff --git a/src/main/java/com/gongjakso/server/global/jwt/TokenProvider.java b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java similarity index 65% rename from src/main/java/com/gongjakso/server/global/jwt/TokenProvider.java rename to src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java index 043f5a2f..05afcd32 100644 --- a/src/main/java/com/gongjakso/server/global/jwt/TokenProvider.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java @@ -1,4 +1,4 @@ -package com.gongjakso.server.global.jwt; +package com.gongjakso.server.global.security.jwt; import org.springframework.stereotype.Component; From e7a82572350fe5530ff17468f27fa3a19fa9ffe8 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sat, 20 Jan 2024 16:19:21 +0900 Subject: [PATCH 09/26] =?UTF-8?q?[#3]=20chore:=20Member=20Entity,=20DTO,?= =?UTF-8?q?=20Enumerate=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/member/dto/MemberReq.java | 5 ++- .../server/domain/member/dto/MemberRes.java | 15 ++++++++- .../server/domain/member/entity/Member.java | 33 +++++++++++++++++++ .../domain/member/enumerate/LoginType.java | 5 +++ .../domain/member/enumerate/MemberType.java | 1 + 5 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/gongjakso/server/domain/member/enumerate/LoginType.java diff --git a/src/main/java/com/gongjakso/server/domain/member/dto/MemberReq.java b/src/main/java/com/gongjakso/server/domain/member/dto/MemberReq.java index a2a68359..d7be5d18 100644 --- a/src/main/java/com/gongjakso/server/domain/member/dto/MemberReq.java +++ b/src/main/java/com/gongjakso/server/domain/member/dto/MemberReq.java @@ -1,4 +1,7 @@ package com.gongjakso.server.domain.member.dto; -public record MemberReq() { +public record MemberReq(String name, + String status, + String major, + String job) { } diff --git a/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java b/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java index 8dd933a9..89e09bd6 100644 --- a/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java +++ b/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java @@ -1,4 +1,17 @@ package com.gongjakso.server.domain.member.dto; -public record MemberRes() { +import com.gongjakso.server.domain.member.entity.Member; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record MemberRes(@NotNull Long memberId, + @NotNull String email) { + + public static MemberRes of(Member member) { + return MemberRes.builder() + .memberId(member.getMemberId()) + .email(member.getEmail()) + .build(); + } } diff --git a/src/main/java/com/gongjakso/server/domain/member/entity/Member.java b/src/main/java/com/gongjakso/server/domain/member/entity/Member.java index 5b07c222..e479b2fd 100644 --- a/src/main/java/com/gongjakso/server/domain/member/entity/Member.java +++ b/src/main/java/com/gongjakso/server/domain/member/entity/Member.java @@ -1,5 +1,8 @@ package com.gongjakso.server.domain.member.entity; +import com.gongjakso.server.domain.member.dto.MemberReq; +import com.gongjakso.server.domain.member.enumerate.LoginType; +import com.gongjakso.server.domain.member.enumerate.MemberType; import com.gongjakso.server.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.AccessLevel; @@ -22,6 +25,36 @@ public class Member extends BaseTimeEntity { @Column(name = "email", nullable = false, columnDefinition = "varchar(320)") private String email; + @Column(name = "password", columnDefinition = "varchar(255)") + private String password; + + @Column(name = "name", nullable = false, columnDefinition = "varchar(50)") + private String name; + + @Column(name = "profile_url", columnDefinition = "text") + private String profileUrl; + + @Column(name = "member_type", nullable = false, columnDefinition = "varchar(255)") + @Enumerated(EnumType.STRING) + private MemberType memberType; + + @Column(name = "login_type", nullable = false, columnDefinition = "varchar(255)") + @Enumerated(EnumType.STRING) + private LoginType loginType; + + @Column(name = "status", columnDefinition = "varchar(255)") + private String status; + + @Column(name = "major", columnDefinition = "varchar(255)") + private String major; + + @Column(name = "job", columnDefinition = "varchar(255)") + private String job; + + public void update(MemberReq memberReq) { + this.name = memberReq.name(); + } + @Builder public Member(Long memberId, String email) { this.memberId = memberId; diff --git a/src/main/java/com/gongjakso/server/domain/member/enumerate/LoginType.java b/src/main/java/com/gongjakso/server/domain/member/enumerate/LoginType.java new file mode 100644 index 00000000..83361275 --- /dev/null +++ b/src/main/java/com/gongjakso/server/domain/member/enumerate/LoginType.java @@ -0,0 +1,5 @@ +package com.gongjakso.server.domain.member.enumerate; + +public enum LoginType { + GENERAL, KAKAO +} diff --git a/src/main/java/com/gongjakso/server/domain/member/enumerate/MemberType.java b/src/main/java/com/gongjakso/server/domain/member/enumerate/MemberType.java index 29922ffb..18a8d9f9 100644 --- a/src/main/java/com/gongjakso/server/domain/member/enumerate/MemberType.java +++ b/src/main/java/com/gongjakso/server/domain/member/enumerate/MemberType.java @@ -1,4 +1,5 @@ package com.gongjakso.server.domain.member.enumerate; public enum MemberType { + GENERAL, ADMIN } From 0332714d232e8d914af11a5063e2ac5f534d1f5e Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sat, 20 Jan 2024 16:19:50 +0900 Subject: [PATCH 10/26] =?UTF-8?q?[#3]=20chore:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=B3=B8=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/AuthController.java | 32 +++++++++++++++++-- .../member/controller/MemberController.java | 13 ++++++++ .../domain/member/service/AuthService.java | 11 +++++++ .../domain/member/service/MemberService.java | 15 +++++++++ .../global/security/jwt/dto/TokenDto.java | 4 +++ .../global/security/kakao/KakaoClient.java | 7 ++++ 6 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/gongjakso/server/domain/member/service/AuthService.java create mode 100644 src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java create mode 100644 src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java diff --git a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java index 6d00089f..88ce8632 100644 --- a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java +++ b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java @@ -1,9 +1,13 @@ package com.gongjakso.server.domain.member.controller; -import com.gongjakso.server.domain.member.service.MemberService; +import com.gongjakso.server.domain.member.dto.MemberRes; +import com.gongjakso.server.domain.member.service.AuthService; +import com.gongjakso.server.global.security.jwt.dto.TokenDto; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -13,10 +17,32 @@ @Tag(name = "Auth", description = "인증 관련 API") public class AuthController { - private final MemberService memberService; + private final AuthService authService; @GetMapping("/test") - private String testAPI() { + public String testAPI() { return "API TEST"; } + + @PostMapping("/sign-in") + public ResponseEntity signIn() { + return null; + } + + @PostMapping("/sign-out") + public ResponseEntity signOut() { + return null; + } + + @PostMapping("/withdrawal") + public ResponseEntity withdrawal() { + return null; + } + + @GetMapping("/reissue") + public ResponseEntity reissue() { + return null; + } } + +// https://yeees.tistory.com/231 \ No newline at end of file diff --git a/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java b/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java index 4bb687c1..1f719d1c 100644 --- a/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java +++ b/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java @@ -1,8 +1,16 @@ package com.gongjakso.server.domain.member.controller; +import com.gongjakso.server.domain.member.dto.MemberReq; +import com.gongjakso.server.domain.member.dto.MemberRes; +import com.gongjakso.server.domain.member.entity.Member; import com.gongjakso.server.domain.member.service.MemberService; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -13,4 +21,9 @@ public class MemberController { private final MemberService memberService; + + @PutMapping("") + public ResponseEntity update(@AuthenticationPrincipal Member member, @Valid @RequestBody MemberReq memberReq) { + return ResponseEntity.ok(memberService.update(member, memberReq)); + } } diff --git a/src/main/java/com/gongjakso/server/domain/member/service/AuthService.java b/src/main/java/com/gongjakso/server/domain/member/service/AuthService.java new file mode 100644 index 00000000..863d0f53 --- /dev/null +++ b/src/main/java/com/gongjakso/server/domain/member/service/AuthService.java @@ -0,0 +1,11 @@ +package com.gongjakso.server.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AuthService { +} diff --git a/src/main/java/com/gongjakso/server/domain/member/service/MemberService.java b/src/main/java/com/gongjakso/server/domain/member/service/MemberService.java index 6e16bb4d..2998b18b 100644 --- a/src/main/java/com/gongjakso/server/domain/member/service/MemberService.java +++ b/src/main/java/com/gongjakso/server/domain/member/service/MemberService.java @@ -1,5 +1,8 @@ package com.gongjakso.server.domain.member.service; +import com.gongjakso.server.domain.member.dto.MemberReq; +import com.gongjakso.server.domain.member.dto.MemberRes; +import com.gongjakso.server.domain.member.entity.Member; import com.gongjakso.server.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -11,4 +14,16 @@ public class MemberService { private final MemberRepository memberRepository; + + @Transactional + public MemberRes update(Member member, MemberReq memberReq) { + // Validation + + // Business Logic + member.update(memberReq); + Member saveMember = memberRepository.save(member); + + // Response + return MemberRes.of(saveMember); + } } diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java b/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java new file mode 100644 index 00000000..7473c7c8 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java @@ -0,0 +1,4 @@ +package com.gongjakso.server.global.security.jwt.dto; + +public record TokenDto(String atk, String rtk) { +} diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java new file mode 100644 index 00000000..bad0fe55 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java @@ -0,0 +1,7 @@ +package com.gongjakso.server.global.security.kakao; + +import org.springframework.stereotype.Component; + +@Component +public class KakaoClient { +} From 841b9b110404fe81be2c1becd402016d6c71a160 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Mon, 22 Jan 2024 20:27:46 +0900 Subject: [PATCH 11/26] =?UTF-8?q?[#3]=20feat:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/AuthController.java | 17 +- .../member/repository/MemberRepository.java | 1 + .../domain/member/service/AuthService.java | 11 - .../domain/member/service/OauthService.java | 20 ++ .../server/global/redis/RedisClient.java | 13 -- .../global/security/jwt/TokenProvider.java | 188 ++++++++++++++++++ .../global/security/jwt/dto/TokenDto.java | 3 + .../global/security/kakao/KakaoClient.java | 12 ++ 8 files changed, 232 insertions(+), 33 deletions(-) delete mode 100644 src/main/java/com/gongjakso/server/domain/member/service/AuthService.java create mode 100644 src/main/java/com/gongjakso/server/domain/member/service/OauthService.java delete mode 100644 src/main/java/com/gongjakso/server/global/redis/RedisClient.java diff --git a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java index 88ce8632..1fc6136a 100644 --- a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java +++ b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java @@ -1,8 +1,9 @@ package com.gongjakso.server.domain.member.controller; import com.gongjakso.server.domain.member.dto.MemberRes; -import com.gongjakso.server.domain.member.service.AuthService; +import com.gongjakso.server.domain.member.service.OauthService; import com.gongjakso.server.global.security.jwt.dto.TokenDto; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -11,22 +12,20 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.io.IOException; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/auth") @Tag(name = "Auth", description = "인증 관련 API") public class AuthController { - private final AuthService authService; - - @GetMapping("/test") - public String testAPI() { - return "API TEST"; - } + private final OauthService oauthService; + @Operation(summary = "로그인 API", description = "KAKAO 로그인 페이지로 리다이렉트되어 카카오 로그인을 수행할 수 있도록 안내") @PostMapping("/sign-in") - public ResponseEntity signIn() { - return null; + public ResponseEntity signIn() throws IOException { + return oauthService.signIn(); } @PostMapping("/sign-out") diff --git a/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java b/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java index 1b35b783..b2418523 100644 --- a/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java @@ -4,4 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository { + } diff --git a/src/main/java/com/gongjakso/server/domain/member/service/AuthService.java b/src/main/java/com/gongjakso/server/domain/member/service/AuthService.java deleted file mode 100644 index 863d0f53..00000000 --- a/src/main/java/com/gongjakso/server/domain/member/service/AuthService.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.gongjakso.server.domain.member.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class AuthService { -} diff --git a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java b/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java new file mode 100644 index 00000000..d2f10382 --- /dev/null +++ b/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java @@ -0,0 +1,20 @@ +package com.gongjakso.server.domain.member.service; + +import com.gongjakso.server.domain.member.dto.MemberRes; +import com.gongjakso.server.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class OauthService { + + private KakaoOau + private MemberRepository memberRepository; + + public MemberRes signIn() { + String redirectUrl = + } +} diff --git a/src/main/java/com/gongjakso/server/global/redis/RedisClient.java b/src/main/java/com/gongjakso/server/global/redis/RedisClient.java deleted file mode 100644 index ec7c08dc..00000000 --- a/src/main/java/com/gongjakso/server/global/redis/RedisClient.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.gongjakso.server.global.redis; - -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class RedisClient { - - private final RedisTemplate redisTemplate; - -} diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java index 05afcd32..71e2efd6 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java @@ -1,7 +1,195 @@ package com.gongjakso.server.global.security.jwt; +import com.gongjakso.server.domain.member.entity.Member; +import com.gongjakso.server.domain.member.repository.MemberRepository; +import com.gongjakso.server.global.security.jwt.dto.TokenDto; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + @Component +@RequiredArgsConstructor public class TokenProvider { + + @Value("${jwt.secret") + private String secretKey; + private Key key; + private final MemberRepository memberRepository; + private final RedisTemplate redisTemplate; + + // ATK 만료시간: 1일 + private static final long accessTokenExpirationTime = 24 * 60 * 60 * 1000L; + + // RTK 만료시간: 30일 + private static final long refreshTokenExpirationTime = 30 * 24 * 60 * 60 * 1000L; + + /** + * 의존성 주입 후 초기화를 수행하는 메소드 + */ + @PostConstruct + protected void init() { + byte[] secretKeyBytes = Decoders.BASE64.decode(secretKey); + key = Keys.hmacShaKeyFor(secretKeyBytes); + } + + /** + * ATK 생성 + * @param user - 사용자 정보를 추출하여 액세스 토큰 생성 + * @return 생성된 액세스 토큰 정보 반환 + */ + private String createAccessToken(Member member) { + Claims claims = getClaims(user); + + Date now = new Date(); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + accessTokenExpirationTime)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + + /** + * RTK 생성 + * @param user - 사용자 정보를 추출하여 리프레쉬 토큰 생성 + * @return 생성된 리프레쉬 토큰 정보 반환 + */ + private String createRefreshToken(Member member) { + Claims claims = getClaims(user); + + Date now = new Date(); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + refreshTokenExpirationTime)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * 로그인 시, 액세스 토큰과 리프레쉬 토큰 발급 + * @param user - 로그인한 사용자 정보 + * @return 액세스 토큰과 리프레쉬 토큰이 담긴 TokenDto 반환 + */ + public TokenDto createToken(Member member) { + return TokenDto.builder() + .accessToken(createAccessToken(user)) + .refreshToken(createRefreshToken(user)) + .build(); + } + + /** + * 토큰 유효성 검사 + * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 + * @return 유효하면 true, 유효하지 않으면 false 반환 + */ + public boolean validateToken(String token) { + try { + Jws claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return claims.getBody().getExpiration().after(new Date()); + } catch (Exception e) { + return false; + } + } + + /** + * 리프레쉬 토큰 기반으로 액세스 토큰 재발급 + * @param token - 리프레쉬 토큰 + * @return 재발급된 액세스 토큰을 담은 TokenDto 객체 반환 + */ + public TokenDto accessTokenReissue(String token) { + String email = getEmail(token); + UserRole role = getRole(token); + + Member member = memberRepository.findByEmailAndRole(email, role).orElseThrow(RuntimeException::new); // Exception은 실제 개발에서는 커스텀 필요 + String storedRefreshToken = redisTemplate.opsForValue().get(email + role.toString()); // Key는 email + role로 저장되어 있으며, value가 해당 정보에 대한 refreshToken임. + if(storedRefreshToken == null || !storedRefreshToken.equals(token)) { + throw new RuntimeException(); + } + + String accessToken = createAccessToken(member); + + // 해당 부분에 refreshToken의 만료기간이 얼마 남지 않았을 때, 자동 재발급하는 로직을 추가할 수 있음. + + return TokenDto.builder() + .atk(accessToken) + .rtk(token) + .build(); + } + + /** + * 토큰에서 정보를 추출해서 Authentication 객체를 반환 + * @param token - 액세스 토큰으로, 해당 토큰에서 정보를 추출해서 사용 + * @return 토큰 정보와 일치하는 Authentication 객체 반환 + */ + public Authentication getAuthentication(String token) { + String email = getEmail(token); + UserRole role = getRole(token); + + User user = userRepository.findByEmailAndRole(email, role).orElseThrow(RuntimeException::new); // Exception은 실제 개발에서는 커스텀 필요 + Collection authorities = + Arrays.stream(user.getRole().toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + UserDetails details = new org.springframework.security.core.userdetails.User(email, "", authorities); + + return new UsernamePasswordAuthenticationToken(details, "", authorities); + } + + /** + * 토큰의 만료기한 반환 + * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 + * @return 해당 토큰의 만료정보를 반환 + */ + public Date getExpiration(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration(); + } + + /** + * Claims 정보 생성 + * @param user - 사용자 정보 중 사용자를 구분할 수 있는 정보 두 개를 활용함 + * @return 사용자 구분 정보인 이메일과 역할을 저장한 Claims 객체 반환 + */ + private Claims getClaims(User user) { + Claims claims = Jwts.claims().setSubject(user.getEmail()); + claims.put("role", user.getRole()); + + return claims; + } + + /** + * 토큰에서 email 정보 반환 + * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 + * @return 사용자의 email 반환 + */ + private String getEmail(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject(); + } + + /** + * 토큰에서 사용자의 역할 반환 + * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 + * @return 사용자의 역할 반환 (UserRole) + */ + private UserRole getRole(String token) { + return UserRole.valueOf((String) Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("role")); + } + } diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java b/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java index 7473c7c8..7e14a711 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java @@ -1,4 +1,7 @@ package com.gongjakso.server.global.security.jwt.dto; +import lombok.Builder; + +@Builder public record TokenDto(String atk, String rtk) { } diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java index bad0fe55..a3e75176 100644 --- a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java +++ b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java @@ -1,7 +1,19 @@ package com.gongjakso.server.global.security.kakao; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +@Slf4j @Component +@RequiredArgsConstructor public class KakaoClient { + + @Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}") + private String kakaoAuthorizationUri; + + public String getMemberInfo() { + + } } From 1fbd152ccfd9539a7f5fdae85cc68530c791de31 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Wed, 24 Jan 2024 00:31:33 +0900 Subject: [PATCH 12/26] =?UTF-8?q?[#3]=20fix:=20KakaoUserService=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/CustomUserDetailsService.java | 20 ------------------- .../security/kakao/KakaoUserService.java | 18 +++++++++++++++++ 2 files changed, 18 insertions(+), 20 deletions(-) delete mode 100644 src/main/java/com/gongjakso/server/global/security/CustomUserDetailsService.java create mode 100644 src/main/java/com/gongjakso/server/global/security/kakao/KakaoUserService.java diff --git a/src/main/java/com/gongjakso/server/global/security/CustomUserDetailsService.java b/src/main/java/com/gongjakso/server/global/security/CustomUserDetailsService.java deleted file mode 100644 index 35de0540..00000000 --- a/src/main/java/com/gongjakso/server/global/security/CustomUserDetailsService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.gongjakso.server.global.security; - -import com.gongjakso.server.domain.member.repository.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CustomUserDetailsService implements UserDetailsService { - - private final MemberRepository memberRepository; - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - return null; - } -} diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoUserService.java b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoUserService.java new file mode 100644 index 00000000..ec790197 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoUserService.java @@ -0,0 +1,18 @@ +package com.gongjakso.server.global.security.kakao; + +import lombok.RequiredArgsConstructor; +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.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoUserService extends DefaultOAuth2UserService { + + @Override + public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException { + return null; + } +} From a641c1a0097c0152002bfce7fd17bcefd2f3f977 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Wed, 24 Jan 2024 00:31:50 +0900 Subject: [PATCH 13/26] =?UTF-8?q?[#3]=20chore:=20WebClient=20Dependency=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 90149999..b1e151c2 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,7 @@ dependencies { // Spring Security + OAuth implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-webflux' } tasks.named('test') { From 7b9e74b972cb1b08d3163e5c91de985f2d2d8cc6 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Wed, 24 Jan 2024 00:32:15 +0900 Subject: [PATCH 14/26] =?UTF-8?q?[#3]=20chore:=20=EA=B3=B5=ED=86=B5=20Exce?= =?UTF-8?q?ption=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/ApplicationException.java | 8 ++++- .../server/global/exception/ErrorCode.java | 34 +++++++++++++++++++ .../exception/GlobalExceptionHandler.java | 14 ++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gongjakso/server/global/exception/ErrorCode.java diff --git a/src/main/java/com/gongjakso/server/global/exception/ApplicationException.java b/src/main/java/com/gongjakso/server/global/exception/ApplicationException.java index 703cbe43..a16357ff 100644 --- a/src/main/java/com/gongjakso/server/global/exception/ApplicationException.java +++ b/src/main/java/com/gongjakso/server/global/exception/ApplicationException.java @@ -1,4 +1,10 @@ package com.gongjakso.server.global.exception; -public class ApplicationException { +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApplicationException extends RuntimeException { + public ErrorCode errorCode; } diff --git a/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java b/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java new file mode 100644 index 00000000..b684cc94 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java @@ -0,0 +1,34 @@ +package com.gongjakso.server.global.exception; + +import lombok.Getter; +import lombok.ToString; +import org.springframework.http.HttpStatus; + +@Getter +@ToString +public enum ErrorCode { + + // 1000: Success Case + SUCCESS(HttpStatus.OK, 1000, "정상적인 요청입니다."), + CREATED(HttpStatus.CREATED, 1001, "정상적으로 생성되었습니다."), + + // 2000: Common Error + INTERNAL_SERVER_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 2000, "예기치 못한 오류가 발생했습니다."), + NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, 2001, "존재하지 않는 리소스입니다."), + INVALID_VALUE_EXCEPTION(HttpStatus.BAD_REQUEST, 2002, "올바르지 않은 요청 값입니다."), + UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED, 2003, "권한이 없는 요청입니다."), + ALREADY_DELETE_EXCEPTION(HttpStatus.BAD_REQUEST, 2004, "이미 삭제된 리소스입니다."), + + // 3000: Auth Error + KAKAO_TOKEN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3000, "인가 코드 발급에서 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + private final Integer code; + private final String message; + + ErrorCode(HttpStatus httpStatus, Integer code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java b/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java index 20f0697c..88afa369 100644 --- a/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java @@ -1,4 +1,18 @@ package com.gongjakso.server.global.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 GlobalExceptionHandler { + + @ExceptionHandler(ApplicationException.class) + protected ResponseEntity handleCustomException(ApplicationException e){ + + return ResponseEntity.status(e.getErrorCode().getHttpStatus()) + .body(new ErrorResponse(e.getErrorCode())); + } } From d97292c7bd1175eee9210c4cd3f7af5d3af7caf6 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Wed, 24 Jan 2024 00:32:38 +0900 Subject: [PATCH 15/26] =?UTF-8?q?[#3]=20feat:=20kakao=20oauth=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/kakao/KakaoClient.java | 55 ++++++++++++++++++- .../security/kakao/dto/KakaoMemberInfo.java | 4 ++ .../global/security/kakao/dto/KakaoToken.java | 7 +++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoMemberInfo.java create mode 100644 src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java index a3e75176..9f121078 100644 --- a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java +++ b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java @@ -1,19 +1,72 @@ package com.gongjakso.server.global.security.kakao; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gongjakso.server.global.exception.ApplicationException; +import com.gongjakso.server.global.security.kakao.dto.KakaoToken; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; @Slf4j @Component @RequiredArgsConstructor public class KakaoClient { + @Value("${spring.security.oauth2.client.provider.kakao.client-id}") + private String kakaoClientId; + + @Value("${spring.security.oauth2.client.provider.kakao.client-secret}") + private String kakaoClientSecret; + + @Value("${spring.security.oauth2.client.registration.authorization-grant-type") + private String kakaoGrantType; + + @Value("${spring.security.oauth2.client.registration.clien}") + @Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}") private String kakaoAuthorizationUri; - public String getMemberInfo() { + @Value("${spring.security.oauth2.client.registration.redirect-uri}") + private String redirectUri; + + public String getKakaoAccessToken() { + String accessToken = ""; + WebClient webClient = WebClient.builder() + .baseUrl(kakaoAuthorizationUri) // 요청 할 API Url + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) // 헤더 설정 + .build(); + + String response = webClient.post() + .uri(uriBuilder -> uriBuilder + .queryParam("grant_type", kakaoGrantType) + .queryParam("client_id", kakaoClientId) + .queryParam("client_secret", kakaoClientSecret) + .queryParam("redirect_uri", redirectUri) + .queryParam("code", "code") + .build()) + .retrieve() // 데이터 받는 방식, 스프링에서는 exchange는 메모리 누수 가능성 때문에 retrieve 권장 + .bodyToMono(String.class) // Mono 객체로 데이터를 받음 , Mono는 단일 데이터, Flux는 복수 데이터 + .block();// 비동기 방식으로 데이터를 받아옴 + + ObjectMapper objectMapper = new ObjectMapper(); + + try { + KakaoToken kakaoToken = objectMapper.readValue(response, KakaoToken.class); + } catch (Exception e) { + throw new ApplicationException(E) + } + + return null; + } + public String getMemberInfo() { + return null; } } +//https://velog.io/@dab2in/Spring-boot-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8 diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoMemberInfo.java b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoMemberInfo.java new file mode 100644 index 00000000..963f7f56 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoMemberInfo.java @@ -0,0 +1,4 @@ +package com.gongjakso.server.global.security.kakao.dto; + +public class KakaoMemberInfo { +} diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java new file mode 100644 index 00000000..bd757101 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java @@ -0,0 +1,7 @@ +package com.gongjakso.server.global.security.kakao.dto; + +import lombok.Builder; + +@Builder +public record KakaoToken() { +} From 62d032df441717d53dabd3a30d8708dea9da04b4 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Wed, 24 Jan 2024 00:32:46 +0900 Subject: [PATCH 16/26] =?UTF-8?q?[#3]=20feat:=20kakao=20oauth=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/AuthController.java | 2 +- .../member/repository/MemberRepository.java | 3 +++ .../domain/member/service/OauthService.java | 17 ++++++++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java index 1fc6136a..fecc7885 100644 --- a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java +++ b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java @@ -25,7 +25,7 @@ public class AuthController { @Operation(summary = "로그인 API", description = "KAKAO 로그인 페이지로 리다이렉트되어 카카오 로그인을 수행할 수 있도록 안내") @PostMapping("/sign-in") public ResponseEntity signIn() throws IOException { - return oauthService.signIn(); + return ResponseEntity.ok(oauthService.signIn()); } @PostMapping("/sign-out") diff --git a/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java b/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java index b2418523..36cbad3f 100644 --- a/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java @@ -3,6 +3,9 @@ import com.gongjakso.server.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberRepository extends JpaRepository { + Optional findMemberByEmail(String email); } diff --git a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java b/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java index d2f10382..7a7c7527 100644 --- a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java +++ b/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java @@ -2,6 +2,7 @@ import com.gongjakso.server.domain.member.dto.MemberRes; import com.gongjakso.server.domain.member.repository.MemberRepository; +import com.gongjakso.server.global.security.kakao.KakaoClient; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,10 +12,20 @@ @RequiredArgsConstructor public class OauthService { - private KakaoOau - private MemberRepository memberRepository; + private final KakaoClient kakaoClient; + private final MemberRepository memberRepository; public MemberRes signIn() { - String redirectUrl = + // Business Logic + // 카카오로 액세스 토큰 요청하기 + String kakaoAccessToken = kakaoClient.getKakaoAccessToken(); + + // 카카오톡에 있는 사용자 정보 반환 + + + // 반환된 정보 기반으로 로그인 또는 회원가입 진행 + + // Response + return null; } } From bf29d8492e11b11120786374cfdfb2eb2a6ca4b4 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Wed, 24 Jan 2024 23:05:27 +0900 Subject: [PATCH 17/26] =?UTF-8?q?[#3]=20chore:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B0=9D=EC=B2=B4,=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EA=B0=9D=EC=B2=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/common/ApplicationResponse.java | 41 ++++++++++++++++++- .../global/exception/ErrorResponse.java | 12 +++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/gongjakso/server/global/common/ApplicationResponse.java b/src/main/java/com/gongjakso/server/global/common/ApplicationResponse.java index 31e8ab60..35c6ad99 100644 --- a/src/main/java/com/gongjakso/server/global/common/ApplicationResponse.java +++ b/src/main/java/com/gongjakso/server/global/common/ApplicationResponse.java @@ -1,4 +1,43 @@ package com.gongjakso.server.global.common; -public class ApplicationResponse { +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gongjakso.server.global.exception.ErrorCode; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApplicationResponse( + LocalDateTime timestamp, + Integer code, + String message, + T data +) { + + public static ApplicationResponse ok(T data) { + return ApplicationResponse.builder() + .timestamp(LocalDateTime.now()) + .code(ErrorCode.SUCCESS.getCode()) + .message(ErrorCode.SUCCESS.getMessage()) + .data(data) + .build(); + } + + public static ApplicationResponse ok() { + return ApplicationResponse.builder() + .timestamp(LocalDateTime.now()) + .code(ErrorCode.SUCCESS.getCode()) + .message(ErrorCode.SUCCESS.getMessage()) + .build(); + } + + public static ApplicationResponse created(T data) { + return ApplicationResponse.builder() + .timestamp(LocalDateTime.now()) + .code(ErrorCode.SUCCESS.getCode()) + .message(ErrorCode.SUCCESS.getMessage()) + .data(data) + .build(); + } } diff --git a/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java b/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java index fb2b1fc6..22322078 100644 --- a/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java +++ b/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java @@ -1,4 +1,14 @@ package com.gongjakso.server.global.exception; -public class ErrorResponse { +import java.time.LocalDateTime; + + +public record ErrorResponse( + LocalDateTime timestamp, + Integer code, + String message) { + + public ErrorResponse(ErrorCode errorcode) { + this(LocalDateTime.now().withNano(0), errorcode.getCode(), errorcode.getMessage()); + } } From c57359965e57b6335b34d8bd1b85efd7c596ac02 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Wed, 24 Jan 2024 23:08:25 +0900 Subject: [PATCH 18/26] =?UTF-8?q?[#3]=20chore:=20RuntimeException=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8C=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/exception/ErrorResponse.java | 6 +++++- .../global/exception/GlobalExceptionHandler.java | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java b/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java index 22322078..a9a7fa7d 100644 --- a/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java +++ b/src/main/java/com/gongjakso/server/global/exception/ErrorResponse.java @@ -9,6 +9,10 @@ public record ErrorResponse( String message) { public ErrorResponse(ErrorCode errorcode) { - this(LocalDateTime.now().withNano(0), errorcode.getCode(), errorcode.getMessage()); + this(LocalDateTime.now(), errorcode.getCode(), errorcode.getMessage()); + } + + public ErrorResponse(String message) { + this(LocalDateTime.now(), ErrorCode.INTERNAL_SERVER_EXCEPTION.getCode(), message); } } diff --git a/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java b/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java index 88afa369..8bc73d32 100644 --- a/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.gongjakso.server.global.exception; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -10,9 +11,14 @@ public class GlobalExceptionHandler { @ExceptionHandler(ApplicationException.class) - protected ResponseEntity handleCustomException(ApplicationException e){ - + protected ResponseEntity handleApplicationException(ApplicationException e){ return ResponseEntity.status(e.getErrorCode().getHttpStatus()) .body(new ErrorResponse(e.getErrorCode())); } + + @ExceptionHandler(RuntimeException.class) + protected ResponseEntity handleRuntimeException(RuntimeException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(e.getMessage())); + } } From d6273ae6fdbc798620fb1346193e2206e2d7e05a Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Thu, 25 Jan 2024 18:57:38 +0900 Subject: [PATCH 19/26] =?UTF-8?q?[#3]=20chore:=20JWT=20dependency=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index b1e151c2..837d7a97 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,11 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Swagger UI - spring doc implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' @@ -44,6 +49,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // MAC OS + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' } tasks.named('test') { From f4ef4e069d718dcd7c17d82f06905a04e7116432 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Thu, 25 Jan 2024 18:58:16 +0900 Subject: [PATCH 20/26] =?UTF-8?q?[#3]=20chore:=20Security=20Filter=20Chain?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gongjakso/server/global/config/SecurityConfig.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java index 29cca624..a3257dbe 100644 --- a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java +++ b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -45,12 +44,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 요청 URI별 권한 설정 http.authorizeHttpRequests((authorize) -> // Swagger UI 외부 접속 허용 - authorize.requestMatchers( "/api-docs/**", "/swagger-ui/**").permitAll() + authorize.requestMatchers( "/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() // 로그인 로직 접속 허용 - .requestMatchers(HttpMethod.POST, "/api/v1/auth/").permitAll() + .requestMatchers("/api/v1/auth/**").permitAll() // 메인 페이지, 공고 페이지 등에 한해 인증 정보 없이 접근 가능 (추후 추가) // 이외의 모든 요청은 인증 정보 필요 - .anyRequest().authenticated()); + .anyRequest().permitAll()); return http.build(); } From 3fb9afe8fc9f21f107c66f5be0f34cc7615b6afb Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Thu, 25 Jan 2024 18:59:26 +0900 Subject: [PATCH 21/26] =?UTF-8?q?[#3]=20feat:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=ED=86=A0=ED=81=B0=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/kakao/KakaoClient.java | 39 +++++++++---------- .../global/security/kakao/dto/KakaoToken.java | 8 +++- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java index 9f121078..49fd26f1 100644 --- a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java +++ b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java @@ -2,15 +2,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.gongjakso.server.global.exception.ApplicationException; +import com.gongjakso.server.global.exception.ErrorCode; +import com.gongjakso.server.global.security.kakao.dto.KakaoMemberInfo; import com.gongjakso.server.global.security.kakao.dto.KakaoToken; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; @Slf4j @@ -18,54 +18,53 @@ @RequiredArgsConstructor public class KakaoClient { - @Value("${spring.security.oauth2.client.provider.kakao.client-id}") + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") private String kakaoClientId; - @Value("${spring.security.oauth2.client.provider.kakao.client-secret}") + @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") private String kakaoClientSecret; - @Value("${spring.security.oauth2.client.registration.authorization-grant-type") + @Value("${spring.security.oauth2.client.registration.kakao.authorization-grant-type}") private String kakaoGrantType; - @Value("${spring.security.oauth2.client.registration.clien}") - @Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}") private String kakaoAuthorizationUri; - @Value("${spring.security.oauth2.client.registration.redirect-uri}") + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") private String redirectUri; - public String getKakaoAccessToken() { - String accessToken = ""; - + public KakaoToken getKakaoAccessToken(String code) { + // 요청 보낼 객체 기본 생성 WebClient webClient = WebClient.builder() - .baseUrl(kakaoAuthorizationUri) // 요청 할 API Url + .baseUrl(kakaoAuthorizationUri) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) // 헤더 설정 .build(); + // 요청 보내기 및 응답 수신 String response = webClient.post() .uri(uriBuilder -> uriBuilder .queryParam("grant_type", kakaoGrantType) .queryParam("client_id", kakaoClientId) .queryParam("client_secret", kakaoClientSecret) .queryParam("redirect_uri", redirectUri) - .queryParam("code", "code") + .queryParam("code", code) .build()) .retrieve() // 데이터 받는 방식, 스프링에서는 exchange는 메모리 누수 가능성 때문에 retrieve 권장 - .bodyToMono(String.class) // Mono 객체로 데이터를 받음 , Mono는 단일 데이터, Flux는 복수 데이터 - .block();// 비동기 방식으로 데이터를 받아옴 + .bodyToMono(String.class) // (Mono는 단일 데이터, Flux는 복수 데이터) + .block();// 비동기 방식의 데이터 수신 + // 수신된 응답 Mapping ObjectMapper objectMapper = new ObjectMapper(); - + KakaoToken kakaoToken; try { - KakaoToken kakaoToken = objectMapper.readValue(response, KakaoToken.class); + kakaoToken = objectMapper.readValue(response, KakaoToken.class); } catch (Exception e) { - throw new ApplicationException(E) + throw new ApplicationException(ErrorCode.KAKAO_TOKEN_EXCEPTION); } - return null; + return kakaoToken; } - public String getMemberInfo() { + public KakaoMemberInfo getMemberInfo(KakaoToken kakaoToken) { return null; } } diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java index bd757101..01db2920 100644 --- a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java +++ b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java @@ -3,5 +3,11 @@ import lombok.Builder; @Builder -public record KakaoToken() { +public record KakaoToken( + String access_token, + String refresh_token, + String token_type, + Integer expires_in, + Integer refresh_token_expires_in +) { } From 61c2137414421e6c2afeb057ccbe35533265ff6f Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sun, 28 Jan 2024 00:45:58 +0900 Subject: [PATCH 22/26] =?UTF-8?q?[#3]=20feat:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/AuthController.java | 30 +++++--- .../member/controller/MemberController.java | 5 +- .../server/domain/member/dto/LoginRes.java | 41 ++++++++++ .../server/domain/member/dto/MemberRes.java | 17 +++- .../server/domain/member/entity/Member.java | 10 ++- .../member/repository/MemberRepository.java | 2 +- .../domain/member/service/OauthService.java | 48 +++++++++++- .../server/global/exception/ErrorCode.java | 3 +- .../exception/GlobalExceptionHandler.java | 2 + .../global/security/jwt/TokenProvider.java | 51 ++++++------ .../global/security/jwt/dto/TokenDto.java | 5 +- .../global/security/kakao/KakaoClient.java | 77 +++++++++++++------ .../security/kakao/dto/KakaoMemberInfo.java | 4 - .../security/kakao/dto/KakaoProfile.java | 39 ++++++++++ .../global/security/kakao/dto/KakaoToken.java | 3 +- 15 files changed, 263 insertions(+), 74 deletions(-) create mode 100644 src/main/java/com/gongjakso/server/domain/member/dto/LoginRes.java delete mode 100644 src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoMemberInfo.java create mode 100644 src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoProfile.java diff --git a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java index fecc7885..76cc1f5f 100644 --- a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java +++ b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java @@ -1,18 +1,16 @@ package com.gongjakso.server.domain.member.controller; -import com.gongjakso.server.domain.member.dto.MemberRes; +import com.gongjakso.server.domain.member.dto.LoginRes; import com.gongjakso.server.domain.member.service.OauthService; +import com.gongjakso.server.global.common.ApplicationResponse; import com.gongjakso.server.global.security.jwt.dto.TokenDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; -import java.io.IOException; @RestController @RequiredArgsConstructor @@ -22,22 +20,32 @@ public class AuthController { private final OauthService oauthService; - @Operation(summary = "로그인 API", description = "KAKAO 로그인 페이지로 리다이렉트되어 카카오 로그인을 수행할 수 있도록 안내") + @GetMapping("/test") + public String test() { + return "test"; + } + + @Operation(summary = "로그인 API", description = "카카오 로그인 페이지로 리다이렉트되어 카카오 로그인을 수행할 수 있도록 안내") @PostMapping("/sign-in") - public ResponseEntity signIn() throws IOException { - return ResponseEntity.ok(oauthService.signIn()); + // A0N84umtbNvrN7llZLciPDB8F4j4X9NTmC8HzbIkamDVmz9XSGbUzzj-L6sKPXVcAAABjUsXrDansOtctwzlGQ + public ApplicationResponse signIn(@RequestParam(name = "code") String code) { + return ApplicationResponse.ok(oauthService.signIn(code)); } + @Operation(summary = "로그아웃 API", description = "로그아웃된 JWT 블랙리스트 등록") @PostMapping("/sign-out") - public ResponseEntity signOut() { - return null; + public ApplicationResponse signOut(HttpServletRequest request) { + oauthService.signOut(request); + return ApplicationResponse.ok(); } + @Operation(summary = "회원탈퇴 API", description = "회원탈퇴 등록") @PostMapping("/withdrawal") public ResponseEntity withdrawal() { return null; } + @Operation(summary = "토큰재발급 API", description = "RefreshToken 정보로 요청 시, ") @GetMapping("/reissue") public ResponseEntity reissue() { return null; diff --git a/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java b/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java index 1f719d1c..1923effa 100644 --- a/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java +++ b/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java @@ -4,6 +4,7 @@ import com.gongjakso.server.domain.member.dto.MemberRes; import com.gongjakso.server.domain.member.entity.Member; import com.gongjakso.server.domain.member.service.MemberService; +import com.gongjakso.server.global.common.ApplicationResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -23,7 +24,7 @@ public class MemberController { private final MemberService memberService; @PutMapping("") - public ResponseEntity update(@AuthenticationPrincipal Member member, @Valid @RequestBody MemberReq memberReq) { - return ResponseEntity.ok(memberService.update(member, memberReq)); + public ApplicationResponse update(@AuthenticationPrincipal Member member, @Valid @RequestBody MemberReq memberReq) { + return ApplicationResponse.ok(memberService.update(member, memberReq)); } } diff --git a/src/main/java/com/gongjakso/server/domain/member/dto/LoginRes.java b/src/main/java/com/gongjakso/server/domain/member/dto/LoginRes.java new file mode 100644 index 00000000..c3a1ea6e --- /dev/null +++ b/src/main/java/com/gongjakso/server/domain/member/dto/LoginRes.java @@ -0,0 +1,41 @@ +package com.gongjakso.server.domain.member.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gongjakso.server.domain.member.entity.Member; +import com.gongjakso.server.domain.member.enumerate.LoginType; +import com.gongjakso.server.domain.member.enumerate.MemberType; +import com.gongjakso.server.global.security.jwt.dto.TokenDto; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record LoginRes( + @NotNull Long memberId, + @NotNull String email, + @NotNull String name, + String profileUrl, + MemberType memberType, + LoginType loginType, + String status, + String major, + String job, + @NotNull String accessToken, + @NotNull String refreshToken +) { + public static LoginRes of(Member member, TokenDto tokenDto) { + return LoginRes.builder() + .memberId(member.getMemberId()) + .email(member.getEmail()) + .name(member.getName()) + .profileUrl(member.getProfileUrl()) + .memberType(member.getMemberType()) + .loginType(member.getLoginType()) + .status(member.getStatus()) + .major(member.getMajor()) + .job(member.getJob()) + .accessToken(tokenDto.accessToken()) + .refreshToken(tokenDto.refreshToken()) + .build(); + } +} diff --git a/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java b/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java index 89e09bd6..74167ed5 100644 --- a/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java +++ b/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java @@ -1,12 +1,25 @@ package com.gongjakso.server.domain.member.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import com.gongjakso.server.domain.member.entity.Member; +import com.gongjakso.server.domain.member.enumerate.LoginType; +import com.gongjakso.server.domain.member.enumerate.MemberType; import jakarta.validation.constraints.NotNull; import lombok.Builder; @Builder -public record MemberRes(@NotNull Long memberId, - @NotNull String email) { +@JsonInclude(JsonInclude.Include.NON_NULL) +public record MemberRes( + @NotNull Long memberId, + @NotNull String email, + @NotNull String name, + String profileUrl, + MemberType memberType, + LoginType loginType, + String status, + String major, + String job +) { public static MemberRes of(Member member) { return MemberRes.builder() diff --git a/src/main/java/com/gongjakso/server/domain/member/entity/Member.java b/src/main/java/com/gongjakso/server/domain/member/entity/Member.java index e479b2fd..77953f2c 100644 --- a/src/main/java/com/gongjakso/server/domain/member/entity/Member.java +++ b/src/main/java/com/gongjakso/server/domain/member/entity/Member.java @@ -56,8 +56,16 @@ public void update(MemberReq memberReq) { } @Builder - public Member(Long memberId, String email) { + public Member(Long memberId, String email, String password, String name, String profileUrl, String memberType, String loginType, String status, String major, String job) { this.memberId = memberId; this.email = email; + this.password = password; + this.name = name; + this.profileUrl = profileUrl; + this.memberType = MemberType.valueOf(memberType); + this.loginType = LoginType.valueOf(loginType); + this.status = status; + this.major = major; + this.job = job; } } diff --git a/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java b/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java index 36cbad3f..d806c39e 100644 --- a/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java @@ -7,5 +7,5 @@ public interface MemberRepository extends JpaRepository { - Optional findMemberByEmail(String email); + Optional findMemberByEmailAndDeletedAtIsNull(String email); } diff --git a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java b/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java index 7a7c7527..6ac5a00d 100644 --- a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java +++ b/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java @@ -1,8 +1,14 @@ package com.gongjakso.server.domain.member.service; -import com.gongjakso.server.domain.member.dto.MemberRes; +import com.gongjakso.server.domain.member.dto.LoginRes; +import com.gongjakso.server.domain.member.entity.Member; import com.gongjakso.server.domain.member.repository.MemberRepository; +import com.gongjakso.server.global.security.jwt.TokenProvider; +import com.gongjakso.server.global.security.jwt.dto.TokenDto; import com.gongjakso.server.global.security.kakao.KakaoClient; +import com.gongjakso.server.global.security.kakao.dto.KakaoProfile; +import com.gongjakso.server.global.security.kakao.dto.KakaoToken; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,17 +19,51 @@ public class OauthService { private final KakaoClient kakaoClient; + private final TokenProvider tokenProvider; private final MemberRepository memberRepository; - public MemberRes signIn() { + @Transactional + public LoginRes signIn(String code) { // Business Logic // 카카오로 액세스 토큰 요청하기 - String kakaoAccessToken = kakaoClient.getKakaoAccessToken(); + KakaoToken kakaoAccessToken = kakaoClient.getKakaoAccessToken(code); // 카카오톡에 있는 사용자 정보 반환 + KakaoProfile kakaoProfile = kakaoClient.getMemberInfo(kakaoAccessToken); + // 반환된 정보의 이메일 기반으로 사용자 테이블에서 계정 정보 조회 진행 + // 이메일 존재 시 로그인 , 존재하지 않을 경우 회원가입 진행 + Member member = memberRepository.findMemberByEmailAndDeletedAtIsNull(kakaoProfile.kakao_account().email()).orElse(null); - // 반환된 정보 기반으로 로그인 또는 회원가입 진행 + if(member == null) { + Member newMember = Member.builder() + .email(kakaoProfile.kakao_account().email()) + .name(kakaoProfile.kakao_account().profile().nickname()) + .memberType("GENERAL") + .loginType("KAKAO") + .build(); + + member = memberRepository.save(newMember); + } + + TokenDto tokenDto = tokenProvider.createToken(member); + + // Response + return LoginRes.of(member, tokenDto); + } + + public void signOut(HttpServletRequest httpServletRequest) { + // Validation + + // Business Logic + + // Response + } + + public TokenDto reissue() { + // Validation + + // Business Logic // Response return null; diff --git a/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java b/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java index b684cc94..620fa1e1 100644 --- a/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java +++ b/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java @@ -20,7 +20,8 @@ public enum ErrorCode { ALREADY_DELETE_EXCEPTION(HttpStatus.BAD_REQUEST, 2004, "이미 삭제된 리소스입니다."), // 3000: Auth Error - KAKAO_TOKEN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3000, "인가 코드 발급에서 오류가 발생했습니다."); + KAKAO_TOKEN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3000, "토큰 발급에서 오류가 발생했습니다."), + KAKAO_USER_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3001, "카카오 프로필 정보를 가져오는 과정에서 오류가 발생했습니디."); private final HttpStatus httpStatus; private final Integer code; diff --git a/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java b/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java index 8bc73d32..d58e52d8 100644 --- a/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gongjakso/server/global/exception/GlobalExceptionHandler.java @@ -12,12 +12,14 @@ public class GlobalExceptionHandler { @ExceptionHandler(ApplicationException.class) protected ResponseEntity handleApplicationException(ApplicationException e){ + log.error(e + " " + e.getErrorCode().toString()); return ResponseEntity.status(e.getErrorCode().getHttpStatus()) .body(new ErrorResponse(e.getErrorCode())); } @ExceptionHandler(RuntimeException.class) protected ResponseEntity handleRuntimeException(RuntimeException e) { + log.error(e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse(e.getMessage())); } diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java index 71e2efd6..f2b98a44 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java @@ -1,8 +1,15 @@ package com.gongjakso.server.global.security.jwt; import com.gongjakso.server.domain.member.entity.Member; +import com.gongjakso.server.domain.member.enumerate.MemberType; import com.gongjakso.server.domain.member.repository.MemberRepository; import com.gongjakso.server.global.security.jwt.dto.TokenDto; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -24,7 +31,7 @@ @RequiredArgsConstructor public class TokenProvider { - @Value("${jwt.secret") + @Value("${jwt.secret}") private String secretKey; private Key key; private final MemberRepository memberRepository; @@ -47,11 +54,11 @@ protected void init() { /** * ATK 생성 - * @param user - 사용자 정보를 추출하여 액세스 토큰 생성 + * @param member - 사용자 정보를 추출하여 액세스 토큰 생성 * @return 생성된 액세스 토큰 정보 반환 */ private String createAccessToken(Member member) { - Claims claims = getClaims(user); + Claims claims = getClaims(member); Date now = new Date(); @@ -66,11 +73,11 @@ private String createAccessToken(Member member) { /** * RTK 생성 - * @param user - 사용자 정보를 추출하여 리프레쉬 토큰 생성 + * @param member - 사용자 정보를 추출하여 리프레쉬 토큰 생성 * @return 생성된 리프레쉬 토큰 정보 반환 */ private String createRefreshToken(Member member) { - Claims claims = getClaims(user); + Claims claims = getClaims(member); Date now = new Date(); @@ -84,13 +91,13 @@ private String createRefreshToken(Member member) { /** * 로그인 시, 액세스 토큰과 리프레쉬 토큰 발급 - * @param user - 로그인한 사용자 정보 + * @param member - 로그인한 사용자 정보 * @return 액세스 토큰과 리프레쉬 토큰이 담긴 TokenDto 반환 */ public TokenDto createToken(Member member) { return TokenDto.builder() - .accessToken(createAccessToken(user)) - .refreshToken(createRefreshToken(user)) + .accessToken(createAccessToken(member)) + .refreshToken(createRefreshToken(member)) .build(); } @@ -115,10 +122,10 @@ public boolean validateToken(String token) { */ public TokenDto accessTokenReissue(String token) { String email = getEmail(token); - UserRole role = getRole(token); + MemberType type = getType(token); - Member member = memberRepository.findByEmailAndRole(email, role).orElseThrow(RuntimeException::new); // Exception은 실제 개발에서는 커스텀 필요 - String storedRefreshToken = redisTemplate.opsForValue().get(email + role.toString()); // Key는 email + role로 저장되어 있으며, value가 해당 정보에 대한 refreshToken임. + Member member = memberRepository.findMemberByEmailAndDeletedAtIsNull(email).orElseThrow(RuntimeException::new); // Exception은 실제 개발에서는 커스텀 필요 + String storedRefreshToken = (String) redisTemplate.opsForValue().get(email + type.toString()); // Key는 email + role로 저장되어 있으며, value가 해당 정보에 대한 refreshToken임. if(storedRefreshToken == null || !storedRefreshToken.equals(token)) { throw new RuntimeException(); } @@ -128,8 +135,8 @@ public TokenDto accessTokenReissue(String token) { // 해당 부분에 refreshToken의 만료기간이 얼마 남지 않았을 때, 자동 재발급하는 로직을 추가할 수 있음. return TokenDto.builder() - .atk(accessToken) - .rtk(token) + .accessToken(accessToken) + .refreshToken(token) .build(); } @@ -140,11 +147,11 @@ public TokenDto accessTokenReissue(String token) { */ public Authentication getAuthentication(String token) { String email = getEmail(token); - UserRole role = getRole(token); + MemberType type = getType(token); - User user = userRepository.findByEmailAndRole(email, role).orElseThrow(RuntimeException::new); // Exception은 실제 개발에서는 커스텀 필요 + Member member = memberRepository.findMemberByEmailAndDeletedAtIsNull(email).orElseThrow(RuntimeException::new); // Exception은 실제 개발에서는 커스텀 필요 Collection authorities = - Arrays.stream(user.getRole().toString().split(",")) + Arrays.stream(member.getMemberType().toString().split(",")) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); @@ -164,12 +171,12 @@ public Date getExpiration(String token) { /** * Claims 정보 생성 - * @param user - 사용자 정보 중 사용자를 구분할 수 있는 정보 두 개를 활용함 + * @param member - 사용자 정보 중 사용자를 구분할 수 있는 정보 두 개를 활용함 * @return 사용자 구분 정보인 이메일과 역할을 저장한 Claims 객체 반환 */ - private Claims getClaims(User user) { - Claims claims = Jwts.claims().setSubject(user.getEmail()); - claims.put("role", user.getRole()); + private Claims getClaims(Member member) { + Claims claims = Jwts.claims().setSubject(member.getEmail()); + claims.put("role", member.getMemberType()); return claims; } @@ -188,8 +195,8 @@ private String getEmail(String token) { * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 * @return 사용자의 역할 반환 (UserRole) */ - private UserRole getRole(String token) { - return UserRole.valueOf((String) Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("role")); + private MemberType getType(String token) { + return MemberType.valueOf((String) Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("role")); } } diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java b/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java index 7e14a711..6fb1b163 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/dto/TokenDto.java @@ -3,5 +3,8 @@ import lombok.Builder; @Builder -public record TokenDto(String atk, String rtk) { +public record TokenDto( + String accessToken, + String refreshToken +) { } diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java index 49fd26f1..9bc7b3a7 100644 --- a/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java +++ b/src/main/java/com/gongjakso/server/global/security/kakao/KakaoClient.java @@ -1,16 +1,15 @@ package com.gongjakso.server.global.security.kakao; import com.fasterxml.jackson.databind.ObjectMapper; -import com.gongjakso.server.global.exception.ApplicationException; -import com.gongjakso.server.global.exception.ErrorCode; -import com.gongjakso.server.global.security.kakao.dto.KakaoMemberInfo; +import com.gongjakso.server.global.security.kakao.dto.KakaoProfile; import com.gongjakso.server.global.security.kakao.dto.KakaoToken; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; @Slf4j @@ -27,28 +26,37 @@ public class KakaoClient { @Value("${spring.security.oauth2.client.registration.kakao.authorization-grant-type}") private String kakaoGrantType; - @Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}") - private String kakaoAuthorizationUri; - @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") - private String redirectUri; + private String kakaoRedirectUri; + + @Value("${spring.security.oauth2.client.provider.kakao.token-uri}") + private String kakaoTokenUri; + @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}") + private String kakaoUserInfoUri; + + /** + * 카카오 서버에 인가코드 기반으로 사용자의 토큰 정보를 조회하는 메소드 + * @param code - 카카오에서 발급해준 인가 코드 + * @return - 카카오에서 반환한 응답 토큰 객체 + */ public KakaoToken getKakaoAccessToken(String code) { // 요청 보낼 객체 기본 생성 - WebClient webClient = WebClient.builder() - .baseUrl(kakaoAuthorizationUri) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) // 헤더 설정 - .build(); + WebClient webClient = WebClient.create(kakaoTokenUri); + + //요청 본문 + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", kakaoGrantType); + params.add("client_id", kakaoClientId); + params.add("redirect_uri", kakaoRedirectUri); + params.add("code", code); + params.add("client_secret", kakaoClientSecret); // 요청 보내기 및 응답 수신 String response = webClient.post() - .uri(uriBuilder -> uriBuilder - .queryParam("grant_type", kakaoGrantType) - .queryParam("client_id", kakaoClientId) - .queryParam("client_secret", kakaoClientSecret) - .queryParam("redirect_uri", redirectUri) - .queryParam("code", code) - .build()) + .uri(kakaoTokenUri) + .header("Content-type", "application/x-www-form-urlencoded") + .body(BodyInserters.fromFormData(params)) .retrieve() // 데이터 받는 방식, 스프링에서는 exchange는 메모리 누수 가능성 때문에 retrieve 권장 .bodyToMono(String.class) // (Mono는 단일 데이터, Flux는 복수 데이터) .block();// 비동기 방식의 데이터 수신 @@ -59,13 +67,34 @@ public KakaoToken getKakaoAccessToken(String code) { try { kakaoToken = objectMapper.readValue(response, KakaoToken.class); } catch (Exception e) { - throw new ApplicationException(ErrorCode.KAKAO_TOKEN_EXCEPTION); + throw new RuntimeException(e); } return kakaoToken; } - public KakaoMemberInfo getMemberInfo(KakaoToken kakaoToken) { - return null; + public KakaoProfile getMemberInfo(KakaoToken kakaoToken) { + // 요청 기본 객체 생성 + WebClient webClient = WebClient.create(kakaoUserInfoUri); + + // 요청 보내서 응답 받기 + String response = webClient.post() + .uri(kakaoUserInfoUri) + .header("Content-Type", "application/x-www-form-urlencoded;charset=utf-8") + .header("Authorization", "Bearer " + kakaoToken.access_token()) + .retrieve() + .bodyToMono(String.class) + .block(); + + // 수신된 응답 Mapping + ObjectMapper objectMapper = new ObjectMapper(); + KakaoProfile kakaoProfile; + try { + kakaoProfile = objectMapper.readValue(response, KakaoProfile.class); + + } catch (Exception e) { + throw new RuntimeException(e); + } + + return kakaoProfile; } } -//https://velog.io/@dab2in/Spring-boot-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8 diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoMemberInfo.java b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoMemberInfo.java deleted file mode 100644 index 963f7f56..00000000 --- a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoMemberInfo.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.gongjakso.server.global.security.kakao.dto; - -public class KakaoMemberInfo { -} diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoProfile.java b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoProfile.java new file mode 100644 index 00000000..1c9d5a2c --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoProfile.java @@ -0,0 +1,39 @@ +package com.gongjakso.server.global.security.kakao.dto; + +public record KakaoProfile( + // 2023년 12월까지 없었던 것으로 보이는 데이터인데, 현재 계속 조회됨. (포럼에 문의된 상황) + Boolean setPrivacyInfo, + Long id, + String connected_at, + Properties properties, + KakaoAccount kakao_account +) { + // 계정 프로퍼티 내용 (카카오 문서에 맞추어 Snake Case 사용) + public record Properties( + String nickname, + String profile_image, + String thumbnail_image + ) { + } + + // 사용자의 카카오 계정 정보 + public record KakaoAccount( + Boolean profile_nickname_needs_agreement, + Boolean profile_image_needs_agreement, + Profile profile, + Boolean has_email, + Boolean email_needs_agreement, + Boolean is_email_valid, + Boolean is_email_verified, + String email + ) { + public record Profile( + String nickname, + String thumbnail_image_url, + String profile_image_url, + Boolean is_default_image + ) { + } + + } +} diff --git a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java index 01db2920..43daa02c 100644 --- a/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java +++ b/src/main/java/com/gongjakso/server/global/security/kakao/dto/KakaoToken.java @@ -8,6 +8,7 @@ public record KakaoToken( String refresh_token, String token_type, Integer expires_in, - Integer refresh_token_expires_in + Integer refresh_token_expires_in, + String scope ) { } From ad024a538368031e3c2371563d5ec9472b113e27 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Sun, 28 Jan 2024 14:09:02 +0900 Subject: [PATCH 23/26] =?UTF-8?q?[#3]=20chore:=20JWT=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/OauthService.java | 6 +++ .../server/global/config/SecurityConfig.java | 20 ++++++++ .../server/global/exception/ErrorCode.java | 2 + .../security/jwt/JwtAccessDeniedHandler.java | 24 ++++++++-- .../jwt/JwtAuthenticationEntryPoint.java | 38 ++++++++------- .../server/global/security/jwt/JwtFilter.java | 47 +++++++++++++++++++ .../global/security/jwt/TokenProvider.java | 2 +- 7 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/gongjakso/server/global/security/jwt/JwtFilter.java diff --git a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java b/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java index 6ac5a00d..433ac801 100644 --- a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java +++ b/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java @@ -8,6 +8,7 @@ import com.gongjakso.server.global.security.kakao.KakaoClient; import com.gongjakso.server.global.security.kakao.dto.KakaoProfile; import com.gongjakso.server.global.security.kakao.dto.KakaoToken; +import com.gongjakso.server.global.util.redis.RedisClient; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -19,6 +20,7 @@ public class OauthService { private final KakaoClient kakaoClient; + private final RedisClient redisClient; private final TokenProvider tokenProvider; private final MemberRepository memberRepository; @@ -48,6 +50,10 @@ public LoginRes signIn(String code) { TokenDto tokenDto = tokenProvider.createToken(member); + // Redis에 RefreshToken 저장 + // TODO: timeout 관련되어 constant가 아닌 tokenProvider 내의 메소드로 관리할 수 있도록 수정 필요 + redisClient.setValue(member.getEmail(), tokenDto.refreshToken(), 30 * 24 * 60 * 60 * 1000L); + // Response return LoginRes.of(member, tokenDto); } diff --git a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java index a3257dbe..2d6e76ad 100644 --- a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java +++ b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java @@ -1,5 +1,9 @@ package com.gongjakso.server.global.config; +import com.gongjakso.server.global.security.jwt.JwtAccessDeniedHandler; +import com.gongjakso.server.global.security.jwt.JwtAuthenticationEntryPoint; +import com.gongjakso.server.global.security.jwt.JwtFilter; +import com.gongjakso.server.global.security.jwt.TokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,6 +15,7 @@ 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; @@ -22,6 +27,10 @@ @RequiredArgsConstructor public class SecurityConfig { + private final TokenProvider tokenProvider; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + /** * FilterChain 설정 * @param http - 시큐리티 설정을 담당하는 객체 @@ -41,6 +50,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable); + // JWT 관련 필터 설정 및 예외 처리 + http.exceptionHandling((exceptionHandling) -> + exceptionHandling + .accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + ); + http.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); + // 요청 URI별 권한 설정 http.authorizeHttpRequests((authorize) -> // Swagger UI 외부 접속 허용 @@ -51,6 +68,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 이외의 모든 요청은 인증 정보 필요 .anyRequest().permitAll()); + // JWT 관련 환경 설정 + + return http.build(); } diff --git a/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java b/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java index 620fa1e1..c8a5e30b 100644 --- a/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java +++ b/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java @@ -18,6 +18,8 @@ public enum ErrorCode { INVALID_VALUE_EXCEPTION(HttpStatus.BAD_REQUEST, 2002, "올바르지 않은 요청 값입니다."), UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED, 2003, "권한이 없는 요청입니다."), ALREADY_DELETE_EXCEPTION(HttpStatus.BAD_REQUEST, 2004, "이미 삭제된 리소스입니다."), + FORBIDDEN_EXCEPTION(HttpStatus.FORBIDDEN, 2005, "인가되지 않는 요청입니다."), + // 3000: Auth Error KAKAO_TOKEN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3000, "토큰 발급에서 오류가 발생했습니다."), diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java index c097d1d3..ecf296a5 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java @@ -1,6 +1,8 @@ package com.gongjakso.server.global.security.jwt; -import jakarta.servlet.ServletException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gongjakso.server.global.exception.ErrorCode; +import com.gongjakso.server.global.exception.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.access.AccessDeniedException; @@ -11,12 +13,24 @@ @Component public class JwtAccessDeniedHandler implements AccessDeniedHandler { + // 인가 실패 관련 403 핸들링 @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { - accessDeniedException.getCause().printStackTrace(); - + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write(accessDeniedException.getMessage()); response.setStatus(HttpServletResponse.SC_FORBIDDEN); + + setResponse(response); + } + + // Error 관련 응답 Response 생성 메소드 + private void setResponse(HttpServletResponse response) throws IOException{ + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(ErrorCode.FORBIDDEN_EXCEPTION.getHttpStatus().value()); + + ErrorResponse errorResponse = new ErrorResponse(ErrorCode.FORBIDDEN_EXCEPTION); + ObjectMapper objectMapper = new ObjectMapper(); + String errorJson = objectMapper.writeValueAsString(errorResponse); + + response.getWriter().write(errorJson); } } diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java index 0e731ae5..0ef8ff5d 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java @@ -1,7 +1,8 @@ package com.gongjakso.server.global.security.jwt; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; +import com.gongjakso.server.global.exception.ErrorCode; +import com.gongjakso.server.global.exception.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.core.AuthenticationException; @@ -15,28 +16,29 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { // 인증 관련 에러 처리, 401 @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { Object exception = request.getAttribute("exception"); -// if (exception instanceof ErrorCode) { -// ErrorCode errorCode = (ErrorCode) exception; -// setResponse(response,errorCode); -// -// return; -// } + // exception에 할당된 속성이 ErrorCode일 경우, 관련된 응답 객체 정보를 삽입하도록 설정 + if (exception instanceof ErrorCode) { + setResponse(response, (ErrorCode) exception); + + return; + } response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); } -// private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException{ -// response.setContentType("application/json;charset=UTF-8"); -// response.setStatus(errorCode.getHttpStatus().value()); -// -// ApplicationErrorResponse errorResponse = new ApplicationErrorResponse(errorCode); -// ObjectMapper objectMapper = new ObjectMapper(); -// String errorJson = objectMapper.writeValueAsString(errorResponse); -// -// response.getWriter().write(errorJson); -// } + // Error 관련 응답 Response 생성 메소드 + private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException{ + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(errorCode.getHttpStatus().value()); + + ErrorResponse errorResponse = new ErrorResponse(errorCode); + ObjectMapper objectMapper = new ObjectMapper(); + String errorJson = objectMapper.writeValueAsString(errorResponse); + + response.getWriter().write(errorJson); + } } diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/JwtFilter.java b/src/main/java/com/gongjakso/server/global/security/jwt/JwtFilter.java new file mode 100644 index 00000000..2cde7211 --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/jwt/JwtFilter.java @@ -0,0 +1,47 @@ +package com.gongjakso.server.global.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +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.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final TokenProvider tokenProvider; + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = resolveToken(request); + String requestURI = request.getRequestURI(); + + // 토큰이 존재할 경우, Authentication에 인증 정보 저장 및 로그 출력 + if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { + Authentication authentication = tokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + // log.info("Security Context 인증 정보 저장: " + authentication.getEmail(), requestURI); + } + + filterChain.doFilter(request, response); + } + + // Request Header에서 토큰 조회 및 Bearer 문자열 제거 후 반환하는 메소드 + private String resolveToken(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + + // Token 정보가 존재할 경우 Bearer 문자열 제거 + if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { + return token.substring(7); + } + + return null; + } +} diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java index f2b98a44..74676db3 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java @@ -38,7 +38,7 @@ public class TokenProvider { private final RedisTemplate redisTemplate; // ATK 만료시간: 1일 - private static final long accessTokenExpirationTime = 24 * 60 * 60 * 1000L; + private static final long accessTokenExpirationTime = 7 * 24 * 60 * 60 * 1000L; // RTK 만료시간: 30일 private static final long refreshTokenExpirationTime = 30 * 24 * 60 * 60 * 1000L; From ac2f9dbff1913aea9dacf1db35a1bf07faaee8be Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Mon, 29 Jan 2024 00:06:14 +0900 Subject: [PATCH 24/26] =?UTF-8?q?[#3]=20chore:=20PrincipalDetails=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20Authentication=EC=97=90=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 5 +- .../server/domain/member/dto/MemberReq.java | 6 +- .../server/domain/member/dto/MemberRes.java | 7 ++ .../domain/member/enumerate/MemberType.java | 11 ++- .../member/repository/MemberRepository.java | 3 + .../global/security/PrincipalDetails.java | 84 +++++++++++++++++++ .../global/security/jwt/TokenProvider.java | 34 ++------ 7 files changed, 121 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/gongjakso/server/global/security/PrincipalDetails.java diff --git a/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java b/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java index 1923effa..579aa4d1 100644 --- a/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java +++ b/src/main/java/com/gongjakso/server/domain/member/controller/MemberController.java @@ -5,6 +5,7 @@ import com.gongjakso.server.domain.member.entity.Member; import com.gongjakso.server.domain.member.service.MemberService; import com.gongjakso.server.global.common.ApplicationResponse; +import com.gongjakso.server.global.security.PrincipalDetails; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -24,7 +25,7 @@ public class MemberController { private final MemberService memberService; @PutMapping("") - public ApplicationResponse update(@AuthenticationPrincipal Member member, @Valid @RequestBody MemberReq memberReq) { - return ApplicationResponse.ok(memberService.update(member, memberReq)); + public ApplicationResponse update(@AuthenticationPrincipal PrincipalDetails principalDetails, @Valid @RequestBody MemberReq memberReq) { + return ApplicationResponse.ok(memberService.update(principalDetails.getMember(), memberReq)); } } diff --git a/src/main/java/com/gongjakso/server/domain/member/dto/MemberReq.java b/src/main/java/com/gongjakso/server/domain/member/dto/MemberReq.java index d7be5d18..6441b892 100644 --- a/src/main/java/com/gongjakso/server/domain/member/dto/MemberReq.java +++ b/src/main/java/com/gongjakso/server/domain/member/dto/MemberReq.java @@ -1,6 +1,10 @@ package com.gongjakso.server.domain.member.dto; -public record MemberReq(String name, +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record MemberReq(@NotNull String name, String status, String major, String job) { diff --git a/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java b/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java index 74167ed5..63ac3d7b 100644 --- a/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java +++ b/src/main/java/com/gongjakso/server/domain/member/dto/MemberRes.java @@ -25,6 +25,13 @@ public static MemberRes of(Member member) { return MemberRes.builder() .memberId(member.getMemberId()) .email(member.getEmail()) + .name(member.getName()) + .profileUrl(member.getProfileUrl()) + .memberType(member.getMemberType()) + .loginType(member.getLoginType()) + .status(member.getStatus()) + .major(member.getMajor()) + .job(member.getJob()) .build(); } } diff --git a/src/main/java/com/gongjakso/server/domain/member/enumerate/MemberType.java b/src/main/java/com/gongjakso/server/domain/member/enumerate/MemberType.java index 18a8d9f9..7d0f0eab 100644 --- a/src/main/java/com/gongjakso/server/domain/member/enumerate/MemberType.java +++ b/src/main/java/com/gongjakso/server/domain/member/enumerate/MemberType.java @@ -1,5 +1,14 @@ package com.gongjakso.server.domain.member.enumerate; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor public enum MemberType { - GENERAL, ADMIN + GENERAL("ROLE_GENERAL", "일반"), + ADMIN("ROLE_ADMIN", "관리자"); + + private final String role; + private final String title; } diff --git a/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java b/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java index d806c39e..0b2b4a62 100644 --- a/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/gongjakso/server/domain/member/repository/MemberRepository.java @@ -1,6 +1,7 @@ package com.gongjakso.server.domain.member.repository; import com.gongjakso.server.domain.member.entity.Member; +import com.gongjakso.server.domain.member.enumerate.MemberType; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -8,4 +9,6 @@ public interface MemberRepository extends JpaRepository { Optional findMemberByEmailAndDeletedAtIsNull(String email); + + Optional findMemberByEmailAndMemberTypeAndDeletedAtIsNull(String email, MemberType memberType); } diff --git a/src/main/java/com/gongjakso/server/global/security/PrincipalDetails.java b/src/main/java/com/gongjakso/server/global/security/PrincipalDetails.java new file mode 100644 index 00000000..fa9efddf --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/PrincipalDetails.java @@ -0,0 +1,84 @@ +package com.gongjakso.server.global.security; + +import com.gongjakso.server.domain.member.entity.Member; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +public class PrincipalDetails implements UserDetails, OAuth2User { + + @Getter + private final Member member; + private Map attributes; + + // 일반 로그인 + public PrincipalDetails(Member member) { + this.member = member; + } + + // OAuth 로그인 + public PrincipalDetails(Member member, Map attributes) { + this.member = member; + this.attributes = attributes; + } + + // 권한 정보 반환 (GENERAL, ADMIN 중 하나) + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList(); + authorities.add(new SimpleGrantedAuthority(member.getMemberType().getRole())); + + return authorities; + } + + // 사용자의 비밀번호 반환 + @Override + public String getPassword() { + return member.getPassword(); + } + + // 사용자의 이름 반환 + @Override + public String getUsername() { + return member.getName(); + } + + // 계정이 잠기지 않았으므로 true 반환 + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + // 패스워드가 만료되지 않았으므로 true 반환 + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + // 계속 사용 가능한 것이기에 true 반환 + @Override + public boolean isEnabled() { + return true; + } + + @Override + public String getName() { + return null; + } + + @Override + public Map getAttributes() { + return attributes; + } +} diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java index 74676db3..c6f7bc24 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java @@ -3,6 +3,9 @@ import com.gongjakso.server.domain.member.entity.Member; import com.gongjakso.server.domain.member.enumerate.MemberType; import com.gongjakso.server.domain.member.repository.MemberRepository; +import com.gongjakso.server.global.exception.ApplicationException; +import com.gongjakso.server.global.exception.ErrorCode; +import com.gongjakso.server.global.security.PrincipalDetails; import com.gongjakso.server.global.security.jwt.dto.TokenDto; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; @@ -13,19 +16,12 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import java.security.Key; -import java.util.Arrays; -import java.util.Collection; import java.util.Date; -import java.util.stream.Collectors; @Component @RequiredArgsConstructor @@ -35,7 +31,6 @@ public class TokenProvider { private String secretKey; private Key key; private final MemberRepository memberRepository; - private final RedisTemplate redisTemplate; // ATK 만료시간: 1일 private static final long accessTokenExpirationTime = 7 * 24 * 60 * 60 * 1000L; @@ -122,14 +117,9 @@ public boolean validateToken(String token) { */ public TokenDto accessTokenReissue(String token) { String email = getEmail(token); - MemberType type = getType(token); - - Member member = memberRepository.findMemberByEmailAndDeletedAtIsNull(email).orElseThrow(RuntimeException::new); // Exception은 실제 개발에서는 커스텀 필요 - String storedRefreshToken = (String) redisTemplate.opsForValue().get(email + type.toString()); // Key는 email + role로 저장되어 있으며, value가 해당 정보에 대한 refreshToken임. - if(storedRefreshToken == null || !storedRefreshToken.equals(token)) { - throw new RuntimeException(); - } + MemberType memberType = getType(token); + Member member = memberRepository.findMemberByEmailAndMemberTypeAndDeletedAtIsNull(email, memberType).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION)); String accessToken = createAccessToken(member); // 해당 부분에 refreshToken의 만료기간이 얼마 남지 않았을 때, 자동 재발급하는 로직을 추가할 수 있음. @@ -147,17 +137,11 @@ public TokenDto accessTokenReissue(String token) { */ public Authentication getAuthentication(String token) { String email = getEmail(token); - MemberType type = getType(token); - - Member member = memberRepository.findMemberByEmailAndDeletedAtIsNull(email).orElseThrow(RuntimeException::new); // Exception은 실제 개발에서는 커스텀 필요 - Collection authorities = - Arrays.stream(member.getMemberType().toString().split(",")) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - - UserDetails details = new org.springframework.security.core.userdetails.User(email, "", authorities); + MemberType memberType = getType(token); + Member member = memberRepository.findMemberByEmailAndMemberTypeAndDeletedAtIsNull(email, memberType).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION)); + PrincipalDetails principalDetails = new PrincipalDetails(member); - return new UsernamePasswordAuthenticationToken(details, "", authorities); + return new UsernamePasswordAuthenticationToken(principalDetails, "", principalDetails.getAuthorities()); } /** From 856e2a873bd2abf7d37eb021f63310452e11b85c Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Mon, 29 Jan 2024 00:07:26 +0900 Subject: [PATCH 25/26] =?UTF-8?q?[#3]=20fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EB=82=B4=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B0=80=EB=8A=A5=ED=95=9C=20=EC=B9=BC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gongjakso/server/domain/member/entity/Member.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/gongjakso/server/domain/member/entity/Member.java b/src/main/java/com/gongjakso/server/domain/member/entity/Member.java index 77953f2c..adc4e258 100644 --- a/src/main/java/com/gongjakso/server/domain/member/entity/Member.java +++ b/src/main/java/com/gongjakso/server/domain/member/entity/Member.java @@ -53,6 +53,9 @@ public class Member extends BaseTimeEntity { public void update(MemberReq memberReq) { this.name = memberReq.name(); + this.status = memberReq.status(); + this.major = memberReq.major(); + this.job = memberReq.job(); } @Builder From 792677c10959b2b9e1c065be6d595dd5d1122cb8 Mon Sep 17 00:00:00 2001 From: dl-00-e8 Date: Tue, 30 Jan 2024 00:32:27 +0900 Subject: [PATCH 26/26] =?UTF-8?q?[#3]=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83,=20=ED=9A=8C=EC=9B=90=ED=83=88=ED=87=B4,=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/AuthController.java | 24 +++++--- .../server/domain/member/entity/Member.java | 2 + .../{OauthService.java => AuthService.java} | 36 ++++++++--- .../server/global/config/SecurityConfig.java | 8 ++- .../server/global/exception/ErrorCode.java | 4 +- .../global/security/ExceptionFilter.java | 43 +++++++++++++ .../security/jwt/JwtAccessDeniedHandler.java | 6 +- .../jwt/JwtAuthenticationEntryPoint.java | 5 +- .../server/global/security/jwt/JwtFilter.java | 20 +++++- .../global/security/jwt/TokenProvider.java | 61 +++++++++---------- .../server/global/util/redis/RedisClient.java | 7 ++- 11 files changed, 158 insertions(+), 58 deletions(-) rename src/main/java/com/gongjakso/server/domain/member/service/{OauthService.java => AuthService.java} (61%) create mode 100644 src/main/java/com/gongjakso/server/global/security/ExceptionFilter.java diff --git a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java index 76cc1f5f..218401ec 100644 --- a/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java +++ b/src/main/java/com/gongjakso/server/domain/member/controller/AuthController.java @@ -1,14 +1,16 @@ package com.gongjakso.server.domain.member.controller; import com.gongjakso.server.domain.member.dto.LoginRes; -import com.gongjakso.server.domain.member.service.OauthService; +import com.gongjakso.server.domain.member.service.AuthService; import com.gongjakso.server.global.common.ApplicationResponse; +import com.gongjakso.server.global.security.PrincipalDetails; import com.gongjakso.server.global.security.jwt.dto.TokenDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -18,7 +20,7 @@ @Tag(name = "Auth", description = "인증 관련 API") public class AuthController { - private final OauthService oauthService; + private final AuthService authService; @GetMapping("/test") public String test() { @@ -27,28 +29,30 @@ public String test() { @Operation(summary = "로그인 API", description = "카카오 로그인 페이지로 리다이렉트되어 카카오 로그인을 수행할 수 있도록 안내") @PostMapping("/sign-in") - // A0N84umtbNvrN7llZLciPDB8F4j4X9NTmC8HzbIkamDVmz9XSGbUzzj-L6sKPXVcAAABjUsXrDansOtctwzlGQ public ApplicationResponse signIn(@RequestParam(name = "code") String code) { - return ApplicationResponse.ok(oauthService.signIn(code)); + return ApplicationResponse.ok(authService.signIn(code)); } @Operation(summary = "로그아웃 API", description = "로그아웃된 JWT 블랙리스트 등록") @PostMapping("/sign-out") - public ApplicationResponse signOut(HttpServletRequest request) { - oauthService.signOut(request); + public ApplicationResponse signOut(HttpServletRequest request, @AuthenticationPrincipal PrincipalDetails principalDetails) { + String token = request.getHeader("Authorization"); + authService.signOut(token, principalDetails.getMember()); return ApplicationResponse.ok(); } @Operation(summary = "회원탈퇴 API", description = "회원탈퇴 등록") @PostMapping("/withdrawal") - public ResponseEntity withdrawal() { - return null; + public ApplicationResponse withdrawal(@AuthenticationPrincipal PrincipalDetails principalDetails) { + authService.withdrawal(principalDetails.getMember()); + return ApplicationResponse.ok(); } @Operation(summary = "토큰재발급 API", description = "RefreshToken 정보로 요청 시, ") @GetMapping("/reissue") - public ResponseEntity reissue() { - return null; + public ApplicationResponse reissue(HttpServletRequest request, @AuthenticationPrincipal PrincipalDetails principalDetails) { + String token = request.getHeader("Authorization"); + return ApplicationResponse.ok(authService.reissue(token, principalDetails.getMember())); } } diff --git a/src/main/java/com/gongjakso/server/domain/member/entity/Member.java b/src/main/java/com/gongjakso/server/domain/member/entity/Member.java index adc4e258..9ea266d7 100644 --- a/src/main/java/com/gongjakso/server/domain/member/entity/Member.java +++ b/src/main/java/com/gongjakso/server/domain/member/entity/Member.java @@ -9,10 +9,12 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; @Getter @Entity @Table(name = "member") +@SQLDelete(sql = "UPDATE member SET deleted_at = NOW() where member_id = ?") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member extends BaseTimeEntity { diff --git a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java b/src/main/java/com/gongjakso/server/domain/member/service/AuthService.java similarity index 61% rename from src/main/java/com/gongjakso/server/domain/member/service/OauthService.java rename to src/main/java/com/gongjakso/server/domain/member/service/AuthService.java index 433ac801..a40bad3a 100644 --- a/src/main/java/com/gongjakso/server/domain/member/service/OauthService.java +++ b/src/main/java/com/gongjakso/server/domain/member/service/AuthService.java @@ -3,13 +3,14 @@ import com.gongjakso.server.domain.member.dto.LoginRes; import com.gongjakso.server.domain.member.entity.Member; import com.gongjakso.server.domain.member.repository.MemberRepository; +import com.gongjakso.server.global.exception.ApplicationException; +import com.gongjakso.server.global.exception.ErrorCode; import com.gongjakso.server.global.security.jwt.TokenProvider; import com.gongjakso.server.global.security.jwt.dto.TokenDto; import com.gongjakso.server.global.security.kakao.KakaoClient; import com.gongjakso.server.global.security.kakao.dto.KakaoProfile; import com.gongjakso.server.global.security.kakao.dto.KakaoToken; import com.gongjakso.server.global.util.redis.RedisClient; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,7 +18,7 @@ @Service @Transactional(readOnly = true) @RequiredArgsConstructor -public class OauthService { +public class AuthService { private final KakaoClient kakaoClient; private final RedisClient redisClient; @@ -58,20 +59,41 @@ public LoginRes signIn(String code) { return LoginRes.of(member, tokenDto); } - public void signOut(HttpServletRequest httpServletRequest) { + public void signOut(String token, Member member) { // Validation + String accessToken = token.substring(7); + tokenProvider.validateToken(accessToken); - // Business Logic + // Business Logic - Refresh Token 삭제 및 Access Token 블랙리스트 등록 + String key = member.getEmail(); + redisClient.deleteValue(key); + redisClient.setValue(accessToken, "logout", tokenProvider.getExpiration(accessToken)); // Response } - public TokenDto reissue() { + @Transactional + public void withdrawal(Member member) { // Validation - // Business Logic + // Business Logic - 회원 논리적 삭제 진행 + memberRepository.delete(member); // Response - return null; + } + + public TokenDto reissue(String token, Member member) { + // Validation - RefreshToken 유효성 검증 + String refreshToken = token.substring(7); + tokenProvider.validateToken(refreshToken); + String email = tokenProvider.getEmail(refreshToken); + String redisRefreshToken = redisClient.getValue(email); + // 입력받은 refreshToken과 Redis의 RefreshToken 간의 일치 여부 검증 + if(refreshToken.isBlank() || redisRefreshToken.isEmpty() || !redisRefreshToken.equals(refreshToken)) { + throw new ApplicationException(ErrorCode.WRONG_TOKEN_EXCEPTION); + } + + // Business Logic & Response - Access Token 새로 발급 + Refresh Token의 유효 기간이 Access Token의 유효 기간보다 짧아졌을 경우 Refresh Token도 재발급 + return tokenProvider.reissue(member, refreshToken); } } diff --git a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java index 2d6e76ad..23f89417 100644 --- a/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java +++ b/src/main/java/com/gongjakso/server/global/config/SecurityConfig.java @@ -1,9 +1,9 @@ package com.gongjakso.server.global.config; +import com.gongjakso.server.global.security.ExceptionFilter; import com.gongjakso.server.global.security.jwt.JwtAccessDeniedHandler; import com.gongjakso.server.global.security.jwt.JwtAuthenticationEntryPoint; import com.gongjakso.server.global.security.jwt.JwtFilter; -import com.gongjakso.server.global.security.jwt.TokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -27,7 +27,8 @@ @RequiredArgsConstructor public class SecurityConfig { - private final TokenProvider tokenProvider; + private final JwtFilter jwtFilter; + private final ExceptionFilter exceptionFilter; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @@ -56,7 +57,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .accessDeniedHandler(jwtAccessDeniedHandler) .authenticationEntryPoint(jwtAuthenticationEntryPoint) ); - http.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(exceptionFilter, JwtFilter.class); // 요청 URI별 권한 설정 http.authorizeHttpRequests((authorize) -> diff --git a/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java b/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java index c8a5e30b..b383302c 100644 --- a/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java +++ b/src/main/java/com/gongjakso/server/global/exception/ErrorCode.java @@ -23,7 +23,9 @@ public enum ErrorCode { // 3000: Auth Error KAKAO_TOKEN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3000, "토큰 발급에서 오류가 발생했습니다."), - KAKAO_USER_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3001, "카카오 프로필 정보를 가져오는 과정에서 오류가 발생했습니디."); + KAKAO_USER_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3001, "카카오 프로필 정보를 가져오는 과정에서 오류가 발생했습니디."), + WRONG_TOKEN_EXCEPTION(HttpStatus.UNAUTHORIZED, 3002, "유효하지 않은 토큰입니다."), + LOGOUT_TOKEN_EXCEPTION(HttpStatus.UNAUTHORIZED, 3003, "로그아웃된 토큰입니다"); private final HttpStatus httpStatus; private final Integer code; diff --git a/src/main/java/com/gongjakso/server/global/security/ExceptionFilter.java b/src/main/java/com/gongjakso/server/global/security/ExceptionFilter.java new file mode 100644 index 00000000..aa03f14d --- /dev/null +++ b/src/main/java/com/gongjakso/server/global/security/ExceptionFilter.java @@ -0,0 +1,43 @@ +package com.gongjakso.server.global.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gongjakso.server.global.exception.ApplicationException; +import com.gongjakso.server.global.exception.ErrorCode; +import com.gongjakso.server.global.exception.ErrorResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class ExceptionFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + // Jwt Filter에서 발생하는 Exception Handling + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (ApplicationException e) { + setResponse(response); + } + } + + // Error 관련 응답 Response 생성 메소드 + private void setResponse(HttpServletResponse response) throws IOException{ + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(ErrorCode.FORBIDDEN_EXCEPTION.getHttpStatus().value()); + + ErrorResponse errorResponse = new ErrorResponse(ErrorCode.LOGOUT_TOKEN_EXCEPTION); + String errorJson = objectMapper.writeValueAsString(errorResponse); + + response.getWriter().write(errorJson); + } +} diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java index ecf296a5..ab05247c 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAccessDeniedHandler.java @@ -5,6 +5,7 @@ import com.gongjakso.server.global.exception.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; @@ -12,7 +13,11 @@ import java.io.IOException; @Component +@RequiredArgsConstructor public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + // 인가 실패 관련 403 핸들링 @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { @@ -28,7 +33,6 @@ private void setResponse(HttpServletResponse response) throws IOException{ response.setStatus(ErrorCode.FORBIDDEN_EXCEPTION.getHttpStatus().value()); ErrorResponse errorResponse = new ErrorResponse(ErrorCode.FORBIDDEN_EXCEPTION); - ObjectMapper objectMapper = new ObjectMapper(); String errorJson = objectMapper.writeValueAsString(errorResponse); response.getWriter().write(errorJson); diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java index 0ef8ff5d..ebb7aef9 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/JwtAuthenticationEntryPoint.java @@ -5,6 +5,7 @@ import com.gongjakso.server.global.exception.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @@ -12,8 +13,11 @@ import java.io.IOException; @Component +@RequiredArgsConstructor public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + // 인증 관련 에러 처리, 401 @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { @@ -36,7 +40,6 @@ private void setResponse(HttpServletResponse response, ErrorCode errorCode) thro response.setStatus(errorCode.getHttpStatus().value()); ErrorResponse errorResponse = new ErrorResponse(errorCode); - ObjectMapper objectMapper = new ObjectMapper(); String errorJson = objectMapper.writeValueAsString(errorResponse); response.getWriter().write(errorJson); diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/JwtFilter.java b/src/main/java/com/gongjakso/server/global/security/jwt/JwtFilter.java index 2cde7211..6e95d2b3 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/JwtFilter.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/JwtFilter.java @@ -1,5 +1,8 @@ package com.gongjakso.server.global.security.jwt; +import com.gongjakso.server.global.exception.ApplicationException; +import com.gongjakso.server.global.exception.ErrorCode; +import com.gongjakso.server.global.util.redis.RedisClient; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -8,20 +11,24 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Slf4j +@Component @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { private final TokenProvider tokenProvider; + private final RedisClient redisClient; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); - String requestURI = request.getRequestURI(); + // String requestURI = request.getRequestURI(); // 토큰이 존재할 경우, Authentication에 인증 정보 저장 및 로그 출력 if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { @@ -37,9 +44,16 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse private String resolveToken(HttpServletRequest request) { String token = request.getHeader("Authorization"); - // Token 정보가 존재할 경우 Bearer 문자열 제거 + // Token 정보 존재 여부 및 Bearer 토큰인지 확인 if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { - return token.substring(7); + // 블랙리스트 토큰인 경우 + String substringToken = token.substring(7); + String value = redisClient.getValue(substringToken); + if (value.equals("logout")) { + throw new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION); + } + + return substringToken; } return null; diff --git a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java index c6f7bc24..aeb5bf72 100644 --- a/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java +++ b/src/main/java/com/gongjakso/server/global/security/jwt/TokenProvider.java @@ -65,7 +65,6 @@ private String createAccessToken(Member member) { .compact(); } - /** * RTK 생성 * @param member - 사용자 정보를 추출하여 리프레쉬 토큰 생성 @@ -111,22 +110,23 @@ public boolean validateToken(String token) { } /** - * 리프레쉬 토큰 기반으로 액세스 토큰 재발급 - * @param token - 리프레쉬 토큰 + * 리프레쉬 토큰 기반으로 액세스 토큰 재발급 + 리프레쉬 토큰의 유효기간이 액세스 토큰의 유효기간보다 짧을 경우, 리프레쉬 토큰도 재발급 + * @param member - 재발급을 요청한 사용자 정보 + * @param refreshToken - 재발급을 요청했던 리프레쉬 토큰 * @return 재발급된 액세스 토큰을 담은 TokenDto 객체 반환 */ - public TokenDto accessTokenReissue(String token) { - String email = getEmail(token); - MemberType memberType = getType(token); - - Member member = memberRepository.findMemberByEmailAndMemberTypeAndDeletedAtIsNull(email, memberType).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION)); + public TokenDto reissue(Member member, String refreshToken) { + // 액세스 토큰 재발급 String accessToken = createAccessToken(member); - // 해당 부분에 refreshToken의 만료기간이 얼마 남지 않았을 때, 자동 재발급하는 로직을 추가할 수 있음. + // 리프레쉬 토큰 재발급 조건 및 로직 + if(getExpiration(refreshToken) <= getExpiration(accessToken)) { + refreshToken = createRefreshToken(member); + } return TokenDto.builder() .accessToken(accessToken) - .refreshToken(token) + .refreshToken(refreshToken) .build(); } @@ -144,13 +144,31 @@ public Authentication getAuthentication(String token) { return new UsernamePasswordAuthenticationToken(principalDetails, "", principalDetails.getAuthorities()); } + /** + * 토큰에서 email 정보 반환 + * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 + * @return 사용자의 email 반환 + */ + public String getEmail(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject(); + } + + /** + * 토큰에서 사용자의 역할 반환 + * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 + * @return 사용자의 역할 반환 (UserRole) + */ + public MemberType getType(String token) { + return MemberType.valueOf((String) Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("role")); + } + /** * 토큰의 만료기한 반환 * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 * @return 해당 토큰의 만료정보를 반환 */ - public Date getExpiration(String token) { - return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration(); + public Long getExpiration(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration().getTime(); } /** @@ -164,23 +182,4 @@ private Claims getClaims(Member member) { return claims; } - - /** - * 토큰에서 email 정보 반환 - * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 - * @return 사용자의 email 반환 - */ - private String getEmail(String token) { - return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject(); - } - - /** - * 토큰에서 사용자의 역할 반환 - * @param token - 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴 - * @return 사용자의 역할 반환 (UserRole) - */ - private MemberType getType(String token) { - return MemberType.valueOf((String) Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("role")); - } - } diff --git a/src/main/java/com/gongjakso/server/global/util/redis/RedisClient.java b/src/main/java/com/gongjakso/server/global/util/redis/RedisClient.java index 446d74cf..5a3498a3 100644 --- a/src/main/java/com/gongjakso/server/global/util/redis/RedisClient.java +++ b/src/main/java/com/gongjakso/server/global/util/redis/RedisClient.java @@ -32,7 +32,12 @@ public void setValue(String key, String value, Long timeout) { */ public String getValue(String key) { ValueOperations values = redisTemplate.opsForValue(); - return Objects.requireNonNull(values.get(key)).toString(); + + if(values.get(key) == null) { + return ""; + } + + return values.get(key).toString(); } /**