From 0de3dd8ec36dc998399330c679011d4e32ed76be Mon Sep 17 00:00:00 2001 From: andyoon Date: Thu, 28 May 2026 17:14:07 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20JWT=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=20=EB=B0=8F=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 ++++++ src/main/resources/application.yml | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/build.gradle b/build.gradle index 51bd7ddf..15b6ff06 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,12 @@ dependencies { // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' } tasks.named('test') { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 736ad7d5..2adef266 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,11 @@ spring: application: name: "umc10th_Spring_Practice" # "umc10th" + jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 datasource: driver-class-name: com.mysql.cj.jdbc.Driver # MySQL JDBC 드라이버 클래스 이름 From 2125fd859e54d649bdfa3d785e77d809b0524a7a Mon Sep 17 00:00:00 2001 From: andyoon Date: Thu, 28 May 2026 17:16:02 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=B0=8F=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/filter/JwtAuthFilter.java | 64 +++++++++++++ .../umc/global/security/util/JwtUtil.java | 93 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/main/java/umc/global/security/filter/JwtAuthFilter.java create mode 100644 src/main/java/umc/global/security/util/JwtUtil.java diff --git a/src/main/java/umc/global/security/filter/JwtAuthFilter.java b/src/main/java/umc/global/security/filter/JwtAuthFilter.java new file mode 100644 index 00000000..53d89277 --- /dev/null +++ b/src/main/java/umc/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,64 @@ +package umc.global.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; +import umc.global.apiPayload.code.GeneralErrorCode; +import umc.global.security.exception.SecurityErrorResponseWriter; +import umc.global.security.service.CustomUserDetailsService; +import umc.global.security.util.JwtUtil; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final SecurityErrorResponseWriter securityErrorResponseWriter; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + try { + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // 토큰에서 이메일 추출 + String email = jwtUtil.getEmail(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } catch (Exception e) { + securityErrorResponseWriter.write(response, GeneralErrorCode.UNAUTHORIZED); + } + } +} diff --git a/src/main/java/umc/global/security/util/JwtUtil.java b/src/main/java/umc/global/security/util/JwtUtil.java new file mode 100644 index 00000000..ee69844b --- /dev/null +++ b/src/main/java/umc/global/security/util/JwtUtil.java @@ -0,0 +1,93 @@ +package umc.global.security.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; +import umc.global.security.entity.AuthMember; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(AuthMember member) { + return createToken(member, accessExpiration); + } + + /** 토큰에서 이메일 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 이메일을 토큰에서 추출합니다 + */ + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기 + } catch (JwtException e) { + return null; + } + } + + /** 토큰 유효성 확인 + * + * @param token 유효한지 확인할 토큰 + * @return True, False 반환합니다 + */ + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 + private String createToken(AuthMember member, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = member.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(member.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", member.getUsername()) + .issuedAt(Date.from(now)) // 언제 발급한지 + .expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지 + .signWith(secretKey) // sign할 Key + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} \ No newline at end of file From 0320d722a6456a9aea47bef35ff8c1e10bca2eb2 Mon Sep 17 00:00:00 2001 From: andyoon Date: Thu, 28 May 2026 17:28:14 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc/global/config/SecurityConfig.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/main/java/umc/global/config/SecurityConfig.java b/src/main/java/umc/global/config/SecurityConfig.java index 42c6fc09..fc86eae7 100644 --- a/src/main/java/umc/global/config/SecurityConfig.java +++ b/src/main/java/umc/global/config/SecurityConfig.java @@ -9,8 +9,13 @@ 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 umc.global.security.exception.CustomAccessDenied; import umc.global.security.exception.CustomEntryPoint; +import umc.global.security.exception.SecurityErrorResponseWriter; +import umc.global.security.filter.JwtAuthFilter; +import umc.global.security.service.CustomUserDetailsService; +import umc.global.security.util.JwtUtil; @EnableWebSecurity @@ -20,6 +25,9 @@ public class SecurityConfig { private final CustomAccessDenied customAccessDenied; private final CustomEntryPoint customEntryPoint; + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final SecurityErrorResponseWriter securityErrorResponseWriter; private final String[] allowUris = { // Swagger 허용 @@ -35,25 +43,29 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(requests -> requests + // public API 허용 .requestMatchers(allowUris).permitAll() + // 그 이외 API는 인증 필요 .anyRequest().authenticated() ) + // 폼 로그인 .formLogin(form -> form .defaultSuccessUrl("/swagger-ui/index.html", true) .permitAll() ) + // JWT 필터 + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) + // 세션 + .sessionManagement(AbstractHttpConfigurer::disable) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .permitAll() ) + // 예외 상황 핸들러 .exceptionHandling(exception -> exception - .defaultAuthenticationEntryPointFor( // 폼 로그인을 위한 임시 설정 - customEntryPoint, - request -> request.getRequestURI().startsWith("/api") - ) .accessDeniedHandler(customAccessDenied) - // .authenticationEntryPoint(customEntryPoint) // 전역 설정 + .authenticationEntryPoint(customEntryPoint) // 전역 설정 ) ; @@ -64,4 +76,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public JwtAuthFilter jwtAuthFilter(){ + return new JwtAuthFilter(jwtUtil, customUserDetailsService, securityErrorResponseWriter); + } } From 1476650994b6b75286c3ab86e8de7c5abc2a6498 Mon Sep 17 00:00:00 2001 From: andyoon Date: Thu, 28 May 2026 17:57:19 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20JWT=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PublicMemberController.java | 8 ++++++++ .../member/converter/MemberConverter.java | 6 ++++++ .../umc/domain/member/dto/MemberReqDTO.java | 5 +++++ .../umc/domain/member/dto/MemberResDTO.java | 5 +++++ .../member/exception/code/MemberErrorCode.java | 11 +++++++++-- .../exception/code/MemberSuccessCode.java | 3 +++ .../domain/member/service/MemberService.java | 17 +++++++++++++++++ src/main/resources/application.yml | 14 +++++++------- 8 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/main/java/umc/domain/member/controller/PublicMemberController.java b/src/main/java/umc/domain/member/controller/PublicMemberController.java index e45b8b15..2815f9fe 100644 --- a/src/main/java/umc/domain/member/controller/PublicMemberController.java +++ b/src/main/java/umc/domain/member/controller/PublicMemberController.java @@ -27,4 +27,12 @@ public ApiResponse createMember( BaseSuccessCode code = MemberSuccessCode.MEMBER_CREATED; return ApiResponse.onSuccess(code, memberService.createMember(request)); } + + @PostMapping("/login") + public ApiResponse login( + @RequestBody @Valid MemberReqDTO.Login request + ) { + BaseSuccessCode code = MemberSuccessCode.MEMBER_LOGIN; + return ApiResponse.onSuccess(code, memberService.login(request)); + } } diff --git a/src/main/java/umc/domain/member/converter/MemberConverter.java b/src/main/java/umc/domain/member/converter/MemberConverter.java index d2fa87ec..154adcb6 100644 --- a/src/main/java/umc/domain/member/converter/MemberConverter.java +++ b/src/main/java/umc/domain/member/converter/MemberConverter.java @@ -69,4 +69,10 @@ public static MemberResDTO.CreateMember toCreateMember( .build(); } + public static MemberResDTO.Login toLogin(String accessToken){ + return MemberResDTO.Login.builder() + .accessToken(accessToken) + .build(); + } + } diff --git a/src/main/java/umc/domain/member/dto/MemberReqDTO.java b/src/main/java/umc/domain/member/dto/MemberReqDTO.java index a5ed0ef9..98c2648b 100644 --- a/src/main/java/umc/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/umc/domain/member/dto/MemberReqDTO.java @@ -35,4 +35,9 @@ public record Agree( @NotNull Boolean marketing ) {} } + + public record Login( + @Email @NotBlank String email, + @NotBlank String paassword + ) {} } diff --git a/src/main/java/umc/domain/member/dto/MemberResDTO.java b/src/main/java/umc/domain/member/dto/MemberResDTO.java index b498a199..4812329a 100644 --- a/src/main/java/umc/domain/member/dto/MemberResDTO.java +++ b/src/main/java/umc/domain/member/dto/MemberResDTO.java @@ -40,4 +40,9 @@ public record HomeMission( public record CreateMember( Long memberId ) {} + + @Builder + public record Login( + String accessToken + ) {} } diff --git a/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java b/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java index 960dc5c2..83d0bfc8 100644 --- a/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java @@ -9,8 +9,15 @@ @AllArgsConstructor public enum MemberErrorCode implements BaseErrorCode { - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾을 수 없습니다."), - EMAIL_ALREADY_EXIST(HttpStatus.CONFLICT, "MEMBER409_1", "해당 이메일은 이미 사용 중 입니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, + "MEMBER404_1", + "해당 사용자를 찾을 수 없습니다."), + EMAIL_ALREADY_EXIST(HttpStatus.CONFLICT, + "MEMBER409_1", + "해당 이메일은 이미 사용 중 입니다."), + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, + "MEMBER401_1", + "이메일 또는 비밀번호가 일치하지 않습니다."), ; private final HttpStatus status; diff --git a/src/main/java/umc/domain/member/exception/code/MemberSuccessCode.java b/src/main/java/umc/domain/member/exception/code/MemberSuccessCode.java index 6f53a4f5..e8ed2bf0 100644 --- a/src/main/java/umc/domain/member/exception/code/MemberSuccessCode.java +++ b/src/main/java/umc/domain/member/exception/code/MemberSuccessCode.java @@ -15,6 +15,9 @@ public enum MemberSuccessCode implements BaseSuccessCode { MEMBER_CREATED(HttpStatus.OK, "MEMBER200_2", "회원을 성공적으로 생성했습니다."), + MEMBER_LOGIN(HttpStatus.OK, + "MEMBER200_3", + "로그인에 성공했습니다."), ; private final HttpStatus status; diff --git a/src/main/java/umc/domain/member/service/MemberService.java b/src/main/java/umc/domain/member/service/MemberService.java index 47d882ea..f88e62c5 100644 --- a/src/main/java/umc/domain/member/service/MemberService.java +++ b/src/main/java/umc/domain/member/service/MemberService.java @@ -36,6 +36,8 @@ import umc.domain.term.exception.TermException; import umc.domain.term.exception.code.TermErrorCode; import umc.domain.term.repository.TermRepository; +import umc.global.security.entity.AuthMember; +import umc.global.security.util.JwtUtil; import java.util.List; import java.util.Map; @@ -55,6 +57,7 @@ public class MemberService { private final TermRepository termRepository; private final MemberPreferredCategoryRepository memberPreferredCategoryRepository; private final TermAgreementRepository termAgreementRepository; + private final JwtUtil jwtUtil; @Transactional(readOnly = true) public MemberResDTO.GetInfo getInfo(Long memberId) { @@ -199,4 +202,18 @@ private void validateTermExist( throw new TermException(TermErrorCode.TERM_MASTER_DATA_NOT_FOUND); } } + + @Transactional + public MemberResDTO.Login login(MemberReqDTO.Login request){ + Member member = memberRepository.findByEmail(request.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.LOGIN_FAILED)); + + if(!passwordEncoder.matches(request.paassword(), member.getPassword())){ + throw new MemberException(MemberErrorCode.LOGIN_FAILED); + } + + String accessToken = jwtUtil.createAccessToken(new AuthMember(member)); + + return MemberConverter.toLogin(accessToken); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2adef266..d40b8f2f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,12 +1,6 @@ spring: application: name: "umc10th_Spring_Practice" # "umc10th" - jwt: - token: - secretKey: ${JWT_SECRET_KEY} - expiration: - access: 1800000 # 30분 - datasource: driver-class-name: com.mysql.cj.jdbc.Driver # MySQL JDBC 드라이버 클래스 이름 url: ${DB_URL} # jdbc:mysql://localhost:3306/{데이터베이스명} @@ -21,4 +15,10 @@ spring: ddl-auto: update # 애플리케이션 실행 시 데이터베이스 스키마의 상태를 설정 properties: hibernate: - format_sql: true # 출력되는 SQL 쿼리를 보기 좋게 포맷팅 \ No newline at end of file + format_sql: true # 출력되는 SQL 쿼리를 보기 좋게 포맷팅 + +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 \ No newline at end of file From ee28a93665aa6c9e8838d20048a92b7b9bfd276e Mon Sep 17 00:00:00 2001 From: andyoon Date: Thu, 28 May 2026 18:28:29 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20API=20JWT=20=EC=9D=B8=EC=A6=9D=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc/domain/member/controller/MemberController.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/umc/domain/member/controller/MemberController.java b/src/main/java/umc/domain/member/controller/MemberController.java index dc5a25ca..2eb37bf8 100644 --- a/src/main/java/umc/domain/member/controller/MemberController.java +++ b/src/main/java/umc/domain/member/controller/MemberController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import umc.domain.member.dto.MemberReqDTO; import umc.domain.member.dto.MemberResDTO; @@ -9,6 +10,7 @@ import umc.domain.member.service.MemberService; import umc.global.apiPayload.ApiResponse; import umc.global.apiPayload.code.BaseSuccessCode; +import umc.global.security.entity.AuthMember; @RestController @RequiredArgsConstructor @@ -18,10 +20,11 @@ public class MemberController { private final MemberService memberService; @GetMapping("/users/me") - public ApiResponse getInfo(){ - Long memberId = 1L; // TODO: memberId from access token + public ApiResponse getInfo( + @AuthenticationPrincipal AuthMember member + ){ BaseSuccessCode code = MemberSuccessCode.MEMBER_VIEW; - MemberResDTO.GetInfo response = memberService.getInfo(memberId); + MemberResDTO.GetInfo response = memberService.getInfo(member.getMember().getId()); return ApiResponse.onSuccess(code, response); } From 79362733b4b6898dfd342db73c825481b520fc5f Mon Sep 17 00:00:00 2001 From: andyoon Date: Thu, 28 May 2026 19:48:40 +0900 Subject: [PATCH 6/8] =?UTF-8?q?chore:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20OAut?= =?UTF-8?q?h=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++ .../umc/global/config/SecurityConfig.java | 30 +++++++++++++++++-- src/main/resources/application.yml | 22 +++++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 15b6ff06..125e50a7 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' implementation 'org.springframework.boot:spring-boot-configuration-processor' + + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/src/main/java/umc/global/config/SecurityConfig.java b/src/main/java/umc/global/config/SecurityConfig.java index fc86eae7..dbd145be 100644 --- a/src/main/java/umc/global/config/SecurityConfig.java +++ b/src/main/java/umc/global/config/SecurityConfig.java @@ -1,11 +1,13 @@ package umc.global.config; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -14,6 +16,8 @@ import umc.global.security.exception.CustomEntryPoint; import umc.global.security.exception.SecurityErrorResponseWriter; import umc.global.security.filter.JwtAuthFilter; +import umc.global.security.handler.OAuthSuccessHandler; +import umc.global.security.service.CustomOAuthService; import umc.global.security.service.CustomUserDetailsService; import umc.global.security.util.JwtUtil; @@ -28,6 +32,7 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final CustomUserDetailsService customUserDetailsService; private final SecurityErrorResponseWriter securityErrorResponseWriter; + private final OAuthSuccessHandler oAuthSuccessHandler; private final String[] allowUris = { // Swagger 허용 @@ -35,11 +40,12 @@ public class SecurityConfig { "/swagger-resources/**", "/v3/api-docs/**", - "/public/**" + "/public/**", + "/oauth/**" }; @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuthService customOAuthService) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(requests -> requests @@ -56,7 +62,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // JWT 필터 .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) // 세션 - .sessionManagement(AbstractHttpConfigurer::disable) + .sessionManagement( + AbstractHttpConfigurer::disable + ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") @@ -67,6 +75,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .accessDeniedHandler(customAccessDenied) .authenticationEntryPoint(customEntryPoint) // 전역 설정 ) + // OAuth + .oauth2Login(oauth -> oauth + .authorizationEndpoint(auth -> auth + .baseUri("/oauth/authorize") + ) + .redirectionEndpoint(redirect -> redirect + .baseUri("/oauth/callback/**") + ) + // 인증 완료 후 정보 활용 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuthService) + ) + // 성공 시 JWT 토큰 발행할 핸들러 + .successHandler(oAuthSuccessHandler) + ) ; return http.build(); @@ -81,4 +104,5 @@ public PasswordEncoder passwordEncoder() { public JwtAuthFilter jwtAuthFilter(){ return new JwtAuthFilter(jwtUtil, customUserDetailsService, securityErrorResponseWriter); } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d40b8f2f..66d4e1de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,8 +17,28 @@ spring: hibernate: format_sql: true # 출력되는 SQL 쿼리를 보기 좋게 포맷팅 + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_REST_API_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "http://localhost:8080/oauth/callback/kakao" + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + user-name-attribute: id + jwt: token: secretKey: ${JWT_SECRET_KEY} expiration: - access: 1800000 # 30분 \ No newline at end of file + access: 1800000 # 30분 From 28675ac263a587540964423a1d4f9aef684a7bc0 Mon Sep 17 00:00:00 2001 From: andyoon Date: Fri, 29 May 2026 01:26:05 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20memberId=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/filter/JwtAuthFilter.java | 19 ++++++++++----- .../service/CustomUserDetailsService.java | 8 +++++++ .../umc/global/security/util/JwtUtil.java | 24 ++++++++++++++++++- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/main/java/umc/global/security/filter/JwtAuthFilter.java b/src/main/java/umc/global/security/filter/JwtAuthFilter.java index 53d89277..2e1c718f 100644 --- a/src/main/java/umc/global/security/filter/JwtAuthFilter.java +++ b/src/main/java/umc/global/security/filter/JwtAuthFilter.java @@ -44,15 +44,22 @@ protected void doFilterInternal( token = token.replace("Bearer ", ""); // AccessToken 검증하기: 올바른 토큰이면 if (jwtUtil.isValid(token)) { - // 토큰에서 이메일 추출 - String email = jwtUtil.getEmail(token); - // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 - UserDetails user = customUserDetailsService.loadUserByUsername(email); + // 토큰에서 memberId 추출 + Long memberId = jwtUtil.getMemberId(token); + + if (memberId == null) { + securityErrorResponseWriter.write(response, GeneralErrorCode.UNAUTHORIZED); + return; + } + + UserDetails member = customUserDetailsService.loadUserByMemberId(memberId); + Authentication auth = new UsernamePasswordAuthenticationToken( - user, + member, null, - user.getAuthorities() + member.getAuthorities() ); + // 인증 완료 후 SecurityContextHolder에 넣기 SecurityContextHolder.getContext().setAuthentication(auth); } diff --git a/src/main/java/umc/global/security/service/CustomUserDetailsService.java b/src/main/java/umc/global/security/service/CustomUserDetailsService.java index b9defa4c..5f052e71 100644 --- a/src/main/java/umc/global/security/service/CustomUserDetailsService.java +++ b/src/main/java/umc/global/security/service/CustomUserDetailsService.java @@ -6,6 +6,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import umc.domain.member.entity.Member; +import umc.domain.member.enums.SocialProvider; import umc.domain.member.exception.MemberException; import umc.domain.member.exception.code.MemberErrorCode; import umc.domain.member.repository.MemberRepository; @@ -25,4 +26,11 @@ public UserDetails loadUserByUsername( .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); return new AuthMember(member); } + + public UserDetails loadUserByMemberId(Long memberId){ + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + return new AuthMember(member); + } } diff --git a/src/main/java/umc/global/security/util/JwtUtil.java b/src/main/java/umc/global/security/util/JwtUtil.java index ee69844b..9dbf778d 100644 --- a/src/main/java/umc/global/security/util/JwtUtil.java +++ b/src/main/java/umc/global/security/util/JwtUtil.java @@ -36,11 +36,13 @@ public String createAccessToken(AuthMember member) { return createToken(member, accessExpiration); } + /** 토큰에서 이메일 가져오기 * * @param token 유저 정보를 추출할 토큰 * @return 유저 이메일을 토큰에서 추출합니다 */ + /* public String getEmail(String token) { try { return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기 @@ -48,6 +50,7 @@ public String getEmail(String token) { return null; } } + */ /** 토큰 유효성 확인 * @@ -75,7 +78,8 @@ private String createToken(AuthMember member, Duration expiration) { return Jwts.builder() .subject(member.getUsername()) // User 이메일을 Subject로 .claim("role", authorities) - .claim("email", member.getUsername()) + .claim("email", member.getUsername()) // 일반 로그인 + .claim("memberId", member.getMember().getId()) .issuedAt(Date.from(now)) // 언제 발급한지 .expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지 .signWith(secretKey) // sign할 Key @@ -90,4 +94,22 @@ private Jws getClaims(String token) throws JwtException { .build() .parseSignedClaims(token); } + + public Long getMemberId(String token){ + try{ + Object memberId = getClaims(token).getPayload().get("memberId"); + + if(memberId instanceof Integer id){ + return id.longValue(); + } + + if(memberId instanceof Long id){ + return id; + } + + return null; + } catch (JwtException e){ + return null; + } + } } \ No newline at end of file From a4107b68f863f55b36c895811386bfbb3713afae Mon Sep 17 00:00:00 2001 From: andyoon Date: Fri, 29 May 2026 01:26:48 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20OAuth?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/converter/MemberConverter.java | 17 +++++ .../exception/code/MemberErrorCode.java | 3 + .../member/repository/MemberRepository.java | 3 + .../umc/global/security/dto/KakaoDTO.java | 34 ++++++++++ .../umc/global/security/dto/OAuthDTO.java | 10 +++ .../global/security/entity/AuthMember.java | 2 +- .../global/security/entity/OAuthMember.java | 34 ++++++++++ .../security/handler/OAuthSuccessHandler.java | 59 ++++++++++++++++ .../security/service/CustomOAuthService.java | 67 +++++++++++++++++++ 9 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/main/java/umc/global/security/dto/KakaoDTO.java create mode 100644 src/main/java/umc/global/security/dto/OAuthDTO.java create mode 100644 src/main/java/umc/global/security/entity/OAuthMember.java create mode 100644 src/main/java/umc/global/security/handler/OAuthSuccessHandler.java create mode 100644 src/main/java/umc/global/security/service/CustomOAuthService.java diff --git a/src/main/java/umc/domain/member/converter/MemberConverter.java b/src/main/java/umc/domain/member/converter/MemberConverter.java index 154adcb6..85d9e757 100644 --- a/src/main/java/umc/domain/member/converter/MemberConverter.java +++ b/src/main/java/umc/domain/member/converter/MemberConverter.java @@ -3,11 +3,14 @@ import org.springframework.data.domain.Page; import umc.domain.member.dto.MemberResDTO; import umc.domain.member.entity.Member; +import umc.domain.member.enums.Gender; import umc.domain.member.exception.code.MemberErrorCode; import umc.domain.mission.entity.Mission; import umc.domain.store.entity.Region; import umc.domain.store.entity.Store; +import umc.global.security.dto.OAuthDTO; +import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -75,4 +78,18 @@ public static MemberResDTO.Login toLogin(String accessToken){ .build(); } + // nullable = false 거나 NullPointerException이 발생 가능한 attribute에는 더미 + public static Member toMember(OAuthDTO dto) { + return Member.builder() + .name(dto.getName()) + .email(dto.getSocialEmail()) + .password("") + .gender(Gender.NONE) + .birth(LocalDate.of(2000, 1, 1)) + .address("NONE") + .socialProvider(dto.getSocialProvider()) + .socialId(dto.getSocialId()) + .currentPoint(0L) + .build(); + } } diff --git a/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java b/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java index 83d0bfc8..19532359 100644 --- a/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java @@ -18,6 +18,9 @@ public enum MemberErrorCode implements BaseErrorCode { LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "이메일 또는 비밀번호가 일치하지 않습니다."), + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, + "MEMBER400_1", + "지원하지 않는 소셜 로그인 제공자입니다."), ; private final HttpStatus status; diff --git a/src/main/java/umc/domain/member/repository/MemberRepository.java b/src/main/java/umc/domain/member/repository/MemberRepository.java index 398ad5d4..883a6197 100644 --- a/src/main/java/umc/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/domain/member/repository/MemberRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import umc.domain.member.entity.Member; +import umc.domain.member.enums.SocialProvider; import java.util.Optional; @@ -10,4 +11,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); + + Optional findBySocialProviderAndSocialId(SocialProvider provider, String socialId); } diff --git a/src/main/java/umc/global/security/dto/KakaoDTO.java b/src/main/java/umc/global/security/dto/KakaoDTO.java new file mode 100644 index 00000000..572600b7 --- /dev/null +++ b/src/main/java/umc/global/security/dto/KakaoDTO.java @@ -0,0 +1,34 @@ +package umc.global.security.dto; + +import lombok.RequiredArgsConstructor; +import umc.domain.member.enums.SocialProvider; + +@RequiredArgsConstructor +public class KakaoDTO implements OAuthDTO{ + + private final String id; + private final String email; + private final String name; + + @Override + public SocialProvider getSocialProvider(){ + return SocialProvider.KAKAO; + } + + @Override + public String getSocialId(){ + return id; + } + + @Override + public String getSocialEmail(){ + return email; + } + + @Override + public String getName(){ + return name; + } + + +} diff --git a/src/main/java/umc/global/security/dto/OAuthDTO.java b/src/main/java/umc/global/security/dto/OAuthDTO.java new file mode 100644 index 00000000..56ebf9c4 --- /dev/null +++ b/src/main/java/umc/global/security/dto/OAuthDTO.java @@ -0,0 +1,10 @@ +package umc.global.security.dto; + +import umc.domain.member.enums.SocialProvider; + +public interface OAuthDTO { + SocialProvider getSocialProvider(); + String getSocialId(); + String getSocialEmail(); + String getName(); +} diff --git a/src/main/java/umc/global/security/entity/AuthMember.java b/src/main/java/umc/global/security/entity/AuthMember.java index 2c48acc9..43286d8e 100644 --- a/src/main/java/umc/global/security/entity/AuthMember.java +++ b/src/main/java/umc/global/security/entity/AuthMember.java @@ -28,6 +28,6 @@ public Collection getAuthorities(){ @Override public String getUsername(){ - return member.getEmail(); + return member.getEmail(); } } diff --git a/src/main/java/umc/global/security/entity/OAuthMember.java b/src/main/java/umc/global/security/entity/OAuthMember.java new file mode 100644 index 00000000..ab45672f --- /dev/null +++ b/src/main/java/umc/global/security/entity/OAuthMember.java @@ -0,0 +1,34 @@ +package umc.global.security.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; +import umc.domain.member.entity.Member; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@RequiredArgsConstructor +public class OAuthMember implements OAuth2User { + + @Getter + private final Member member; + private final Map attributes; + + @Override + public Map getAttributes(){ + return attributes; + } + + @Override + public Collection getAuthorities(){ + return List.of(); + } + + @Override + public String getName(){ + return member.getSocialId(); + } +} diff --git a/src/main/java/umc/global/security/handler/OAuthSuccessHandler.java b/src/main/java/umc/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 00000000..bec3e99c --- /dev/null +++ b/src/main/java/umc/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,59 @@ +package umc.global.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import umc.domain.member.converter.MemberConverter; +import umc.domain.member.dto.MemberResDTO; +import umc.domain.member.exception.code.MemberSuccessCode; +import umc.global.apiPayload.ApiResponse; +import umc.global.apiPayload.code.BaseSuccessCode; +import umc.global.security.entity.AuthMember; +import umc.global.security.entity.OAuthMember; +import umc.global.security.util.JwtUtil; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class OAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + + BaseSuccessCode code = MemberSuccessCode.MEMBER_LOGIN; + + // Content-Type, Status 설정 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // 인증 객체 컨테이너에서 OAuth 인증 객체 가져오기 + OAuthMember member = (OAuthMember) authentication.getPrincipal(); + + // 토큰 제작을 위해 OAuth 인증 객체에서 Member 추출 -> AuthMember 제작 + String accessToken = jwtUtil.createAccessToken( + new AuthMember(member.getMember()) + ); + + // 응답 통일 객체 래핑 + ApiResponse responseBody = ApiResponse.onSuccess( + code, + MemberConverter.toLogin(accessToken) + ); + + // 응답 출력 + objectMapper.writeValue(response.getWriter(), responseBody); + } +} diff --git a/src/main/java/umc/global/security/service/CustomOAuthService.java b/src/main/java/umc/global/security/service/CustomOAuthService.java new file mode 100644 index 00000000..507a69c9 --- /dev/null +++ b/src/main/java/umc/global/security/service/CustomOAuthService.java @@ -0,0 +1,67 @@ +package umc.global.security.service; + +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; +import umc.domain.member.converter.MemberConverter; +import umc.domain.member.entity.Member; +import umc.domain.member.enums.SocialProvider; +import umc.domain.member.exception.MemberException; +import umc.domain.member.exception.code.MemberErrorCode; +import umc.domain.member.repository.MemberRepository; +import umc.global.security.dto.KakaoDTO; +import umc.global.security.dto.OAuthDTO; +import umc.global.security.entity.OAuthMember; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuthService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser( + OAuth2UserRequest userRequest + ) throws OAuth2AuthenticationException { + // (필수) 인증 서버의 일회성 토큰을 이용해 정보 조회 & 유저 객체 생성 + OAuth2User oAuthMember = super.loadUser(userRequest); + + // 유저 객체에서 정보 추출 + SocialProvider provider; + String socialId; + Map attributes = oAuthMember.getAttribute("kakao_account"); + Map profile = (Map) attributes.get("profile"); + try { + provider = SocialProvider.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase()); + socialId = String.valueOf((Long) oAuthMember.getAttribute("id")); + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + // OAuth 공통 정보 DTO로 매핑 + OAuthDTO dto; + switch (provider) { + case KAKAO -> { + String email = attributes.get("email").toString(); + String name = profile.get("nickname").toString(); + dto = new KakaoDTO(socialId, email, name); + } + default -> throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + // DB 저장: 있다면 그 데이터 가져오고 없으면 새로 저장 + Member member = memberRepository.findBySocialProviderAndSocialId(provider, socialId) + .orElseGet(() -> { + Member newMember = MemberConverter.toMember(dto); + memberRepository.save(newMember); + return newMember; + }); + + return new OAuthMember(member, oAuthMember.getAttributes()); + } +} \ No newline at end of file