From 560926a8033d8f911c2e6ff33c84532dfac61ea6 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 26 May 2026 13:05:58 +0900 Subject: [PATCH 1/8] =?UTF-8?q?build:=20JWT=EC=99=80=20OAuth=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sangwan/build.gradle | 12 +++++++++- Sangwan/src/main/resources/application.yml | 28 +++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Sangwan/build.gradle b/Sangwan/build.gradle index 0bfa2b3b..956c67c5 100644 --- a/Sangwan/build.gradle +++ b/Sangwan/build.gradle @@ -26,11 +26,16 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' @@ -51,7 +56,12 @@ tasks.named('bootRun') { .findAll { line -> line.trim() && !line.trim().startsWith('#') && line.contains('=') } .each { line -> def (key, value) = line.split('=', 2) - environment key.trim(), value.trim() + def cleanedValue = value.trim() + if ((cleanedValue.startsWith('"') && cleanedValue.endsWith('"')) || + (cleanedValue.startsWith("'") && cleanedValue.endsWith("'"))) { + cleanedValue = cleanedValue.substring(1, cleanedValue.length() - 1) + } + environment key.trim(), cleanedValue } } } diff --git a/Sangwan/src/main/resources/application.yml b/Sangwan/src/main/resources/application.yml index f4157beb..876dfeb7 100644 --- a/Sangwan/src/main/resources/application.yml +++ b/Sangwan/src/main/resources/application.yml @@ -16,4 +16,30 @@ spring: ddl-auto: update # ?????? ?? ? ?????? ???? ??? ?? properties: hibernate: - format_sql: true # ???? SQL ??? ?? ?? ??? \ No newline at end of file + 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: "{baseUrl}/oauth/callback/{registrationId}" + 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분 From 3ee75a33dcc512ee22c9ff48357b4997e9cc04ee Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 26 May 2026 13:06:03 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20JWT=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B0=9D=EC=B2=B4=EC=99=80=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/entity/AuthMember.java | 46 ++++++++ .../global/security/filter/JwtAuthFilter.java | 70 ++++++++++++ .../service/CustomUserDetailsService.java | 38 +++++++ .../umc10th/global/security/util/JwtUtil.java | 105 ++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java b/Sangwan/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java new file mode 100644 index 00000000..b2834195 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java @@ -0,0 +1,46 @@ +package com.example.umc10th.global.security.entity; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.MemberStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class AuthMember implements UserDetails { + + private final Member member; + + public Long getId() { + return member.getId(); + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + if (member.getSocialId() != null) { + return member.getSocialId(); + } + return member.getEmail(); + } + + @Override + public boolean isEnabled() { + return member.getStatus() == MemberStatus.ACTIVE; + } +} diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/Sangwan/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java new file mode 100644 index 00000000..bef5d950 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,70 @@ +package com.example.umc10th.global.security.filter; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.domain.member.enums.SocialType; +import com.example.umc10th.global.security.service.CustomUserDetailsService; +import com.example.umc10th.global.security.util.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +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 java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + try { + String token = request.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + token = token.replace("Bearer ", ""); + if (jwtUtil.isValid(token)) { + String uid = jwtUtil.getUid(token); + SocialType socialType = jwtUtil.getSocialType(token); + UserDetails user = socialType == null + ? customUserDetailsService.loadUserByUsername(uid) + : customUserDetailsService.loadUserByUidAndSocialType(socialType, uid); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + filterChain.doFilter(request, response); + } catch (Exception e) { + ObjectMapper mapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + mapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/Sangwan/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java new file mode 100644 index 00000000..9bcc18e4 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,38 @@ +package com.example.umc10th.global.security.service; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.SocialType; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.security.entity.AuthMember; +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; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다.")); + + return new AuthMember(member); + } + + public UserDetails loadUserByUidAndSocialType( + SocialType socialType, + String username + ) throws UsernameNotFoundException { + Member member = memberRepository.findBySocialTypeAndSocialId(socialType, username) + .orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다.")); + + return new AuthMember(member); + } +} diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java b/Sangwan/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java new file mode 100644 index 00000000..bcb37680 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,105 @@ +package com.example.umc10th.global.security.util; + +import com.example.umc10th.domain.member.enums.SocialType; +import com.example.umc10th.global.security.entity.AuthMember; +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 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); + } + + public String createAccessToken(AuthMember member) { + return createToken(member, accessExpiration); + } + + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); + } catch (JwtException e) { + return null; + } + } + + public String getUid(String token) { + try { + return getClaims(token).getPayload().getSubject(); + } catch (JwtException e) { + return null; + } + } + + public SocialType getSocialType(String token) { + try { + Object socialType = getClaims(token).getPayload().get("social_type"); + if (socialType == null) { + return null; + } + return SocialType.valueOf(socialType.toString().toUpperCase()); + } catch (JwtException | IllegalArgumentException e) { + return null; + } + } + + 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(",")); + + var builder = Jwts.builder() + .subject(member.getUsername()) + .claim("role", authorities) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(expiration))) + .signWith(secretKey); + + if (member.getMember().getSocialType() == null) { + builder.claim("email", member.getUsername()); + } else { + builder.claim("social_type", member.getMember().getSocialType()); + } + + return builder.compact(); + } + + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} From eb3a34d2facb2a2f24b146f8c4e675706b8c5331 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 26 May 2026 13:06:08 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EA=B3=BC=20JWT=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 28 ++++++++++----- .../member/converter/MemberConverter.java | 25 ++++++++++++++ .../domain/member/dto/MemberReqDTO.java | 8 +++-- .../domain/member/dto/MemberResDTO.java | 5 +++ .../exception/code/MemberErrorCode.java | 2 ++ .../exception/code/MemberSuccessCode.java | 1 + .../member/repository/MemberRepository.java | 3 ++ .../domain/member/service/MemberService.java | 34 ++++++++++++++----- 8 files changed, 86 insertions(+), 20 deletions(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index c3628f06..86b22e7d 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -7,10 +7,12 @@ import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.global.dto.CursorPageRes; import com.example.umc10th.global.dto.OffsetPageRes; +import com.example.umc10th.global.security.entity.AuthMember; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -30,25 +32,33 @@ public ApiResponse signup( return ApiResponse.onSuccess(MemberSuccessCode.SIGNUP, memberService.signup(request)); } + // 로그인 + @PostMapping("/login") + public ApiResponse login( + @Valid @RequestBody MemberReqDTO.LoginReq request + ) { + return ApiResponse.onSuccess(MemberSuccessCode.OK, memberService.login(request)); + } + // 홈화면 조회 @GetMapping("/me/home") public ApiResponse getHome( - @RequestParam Long memberId // TODO: 인증 구현 후 SecurityContext로 대체 + @AuthenticationPrincipal AuthMember member ) { - return ApiResponse.onSuccess(MemberSuccessCode.HOME, memberService.getHome(memberId)); + return ApiResponse.onSuccess(MemberSuccessCode.HOME, memberService.getHome(member.getId())); } // 미션 목록 조회 (진행중 / 진행완료) @GetMapping("/me/missions") public ApiResponse> getMissions( - @RequestParam Long memberId, // TODO: 인증 구현 후 SecurityContext로 대체 + @AuthenticationPrincipal AuthMember member, @RequestParam String status, @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "10") @Positive(message = "페이지 크기는 1 이상이어야 합니다.") int size ) { return ApiResponse.onSuccess(MemberSuccessCode.MISSION_LIST, - memberService.getMissions(memberId, status, cursor, size)); + memberService.getMissions(member.getId(), status, cursor, size)); } // 미션 성공 누르기 @@ -63,21 +73,21 @@ public ApiResponse requestMissionSuccess( // 진행중인 미션 조회 (오프셋 기반 페이지네이션) @PostMapping("/me/missions/inprogress") public ApiResponse> getInProgressMissions( - @Valid @RequestBody MemberReqDTO.GetInProgressMissionsReq request, + @AuthenticationPrincipal AuthMember member, @RequestParam(defaultValue = "0") @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다.") int page, @RequestParam(defaultValue = "10") @Positive(message = "페이지 크기는 1 이상이어야 합니다.") int size ) { return ApiResponse.onSuccess(MemberSuccessCode.INPROGRESS_MISSIONS, - memberService.getInProgressMissions(request, page, size)); + memberService.getInProgressMissions(member.getId(), page, size)); } // 내 정보 가져오기 - @PostMapping("/me") + @GetMapping("/me") public ApiResponse getMyInfo( - @Valid @RequestBody MemberReqDTO.MyInfoReq myInfoReq + @AuthenticationPrincipal AuthMember member ) { - return ApiResponse.onSuccess(MemberSuccessCode.MYINFO, memberService.requestMyInfo(myInfoReq)); + return ApiResponse.onSuccess(MemberSuccessCode.MYINFO, memberService.requestMyInfo(member)); } } diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index 81262c92..34b838ab 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -6,6 +6,7 @@ import com.example.umc10th.domain.member.entity.mapping.MemberAgreement; import com.example.umc10th.domain.member.entity.mapping.MemberFoodCategory; import com.example.umc10th.domain.member.entity.mapping.RegionProgress; +import com.example.umc10th.domain.member.enums.Gender; import com.example.umc10th.domain.member.enums.MemberStatus; import com.example.umc10th.domain.member.enums.Role; import com.example.umc10th.domain.mission.entity.Mission; @@ -14,6 +15,7 @@ import com.example.umc10th.domain.term.entity.Term; import com.example.umc10th.global.dto.CursorPageRes; import com.example.umc10th.global.dto.OffsetPageRes; +import com.example.umc10th.global.security.dto.OAuthDTO; import org.springframework.data.domain.Page; import java.time.LocalDate; @@ -40,6 +42,23 @@ public static Member toMember(MemberReqDTO.SignupReq request, String encodedPass .build(); } + public static Member toMember(OAuthDTO dto) { + return Member.builder() + .email(dto.getSocialEmail()) + .password("") + .socialType(dto.getSocialType()) + .socialId(dto.getSocialUid()) + .name(dto.getName()) + .role(Role.USER) + .status(MemberStatus.ACTIVE) + .step(0) + .totalPoint(0) + .gender(Gender.NONE) + .birth(LocalDate.of(1900, 1, 1)) + .isVerified(false) + .build(); + } + public static MemberAgreement toMemberAgreement(Member member, Term term, Boolean isAgreed) { return MemberAgreement.builder() .member(member) @@ -73,6 +92,12 @@ public static MemberResDTO.MyInfoRes toGetInfo(Member member) { .build(); } + public static MemberResDTO.Login toLogin(String accessToken) { + return MemberResDTO.Login.builder() + .accessToken(accessToken) + .build(); + } + public static MemberResDTO.HomeMissionItem toHomeMissionItem(Mission mission) { return MemberResDTO.HomeMissionItem.builder() .missionId(mission.getId()) diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index 28590dcd..8025be9e 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -33,13 +33,15 @@ public record SignupReq( List<@NotNull @Positive Long> foodCategoryIds ) {} + public record LoginReq( + @NotBlank @Email String email, + @NotBlank String password + ) {} + public record TermAgreementReq( @NotNull(message = "약관 ID는 필수입니다.") @Positive(message = "약관 ID는 양수여야 합니다.") Long termId, @NotNull(message = "약관 동의 여부는 필수입니다.") Boolean isAgreed ) {} - public record GetInProgressMissionsReq( - @NotNull(message = "사용자 ID는 필수입니다.") Long memberId - ) {} } diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java index d982681a..374b9b95 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java @@ -66,4 +66,9 @@ public record MyInfoRes( Integer point ){} + @Builder + public record Login( + String accessToken + ) {} + } diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index b1f5b17d..ae74836c 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -13,6 +13,8 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER_4001", "이미 가입된 회원 정보가 존재합니다."), INVALID_BIRTHDAY_FORMAT(HttpStatus.BAD_REQUEST, "MEMBER_4002", "생년월일 형식이 올바르지 않습니다. (YYYY-MM-DD)"), REQUIRED_TERM_NOT_AGREED(HttpStatus.BAD_REQUEST, "MEMBER_4003", "필수 약관에 동의해야 합니다."), + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, "MEMBER_4004", "지원하지 않는 소셜 로그인 제공자입니다."), + INVALID_LOGIN(HttpStatus.UNAUTHORIZED, "MEMBER_401", "이메일 또는 비밀번호가 올바르지 않습니다."), TERM_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_4041", "해당 약관을 찾을 수 없습니다."), FOOD_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_4042", "해당 음식 카테고리를 찾을 수 없습니다."); diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java index be86f537..8f49cd94 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java @@ -9,6 +9,7 @@ @RequiredArgsConstructor public enum MemberSuccessCode implements BaseSuccessCode { + OK(HttpStatus.OK, "MEMBER_200", "성공적으로 요청을 처리했습니다."), SIGNUP(HttpStatus.CREATED, "MEMBER_201", "반가워요! 회원 가입이 완료되었습니다."), HOME(HttpStatus.OK, "HOME_200", "홈 화면 조회가 완료되었습니다."), MISSION_LIST(HttpStatus.OK, "MISSION_200", "미션 목록 조회가 완료되었습니다."), diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index e7fc30a1..b3074fe7 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -1,6 +1,7 @@ package com.example.umc10th.domain.member.repository; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.SocialType; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -10,4 +11,6 @@ public interface MemberRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByEmail(String email); + + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); } diff --git a/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 05be1d44..871af165 100644 --- a/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/Sangwan/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -24,9 +24,15 @@ import com.example.umc10th.domain.term.repository.TermRepository; import com.example.umc10th.global.dto.CursorPageRes; import com.example.umc10th.global.dto.OffsetPageRes; +import com.example.umc10th.global.security.entity.AuthMember; +import com.example.umc10th.global.security.util.JwtUtil; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -55,6 +61,8 @@ public class MemberService { private final MemberAgreementRepository memberAgreementRepository; private final MemberFoodCategoryRepository memberFoodCategoryRepository; private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; @Transactional public MemberResDTO.SignupRes signup(MemberReqDTO.SignupReq request) { @@ -78,6 +86,19 @@ public MemberResDTO.SignupRes signup(MemberReqDTO.SignupReq request) { return MemberConverter.toSignupRes(savedMember); } + public MemberResDTO.Login login(MemberReqDTO.LoginReq request) { + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.email(), request.password()) + ); + AuthMember member = (AuthMember) authentication.getPrincipal(); + + return MemberConverter.toLogin(jwtUtil.createAccessToken(member)); + } catch (AuthenticationException e) { + throw new MemberException(MemberErrorCode.INVALID_LOGIN); + } + } + public MemberResDTO.HomeRes getHome(Long memberId) { Member member = memberRepository.findById(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); @@ -118,13 +139,12 @@ public CursorPageRes getMissions(Long memberId, String return MemberConverter.toMissionListRes(missions, hasNext, nextCursor, cursor == null, !hasNext); } - public OffsetPageRes getInProgressMissions( - MemberReqDTO.GetInProgressMissionsReq request, int page, int size) { - memberRepository.findById(request.memberId()) + public OffsetPageRes getInProgressMissions(Long memberId, int page, int size) { + memberRepository.findById(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); Page memberMissionPage = memberMissionRepository.findPageByMemberIdAndStatus( - request.memberId(), MemberMissionStatus.CHALLENGING, PageRequest.of(page, size)); + memberId, MemberMissionStatus.CHALLENGING, PageRequest.of(page, size)); return MemberConverter.toInProgressMissionPageRes(memberMissionPage, page); } @@ -134,10 +154,8 @@ public MemberResDTO.MissionSuccessRes requestMissionSuccess(Long missionId) { return null; } - public MemberResDTO.MyInfoRes requestMyInfo(MemberReqDTO.MyInfoReq myInfoReq) { - Member member = memberRepository.findById(myInfoReq.id()) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - return MemberConverter.toGetInfo(member); + public MemberResDTO.MyInfoRes requestMyInfo(AuthMember authMember) { + return MemberConverter.toGetInfo(authMember.getMember()); } private LocalDate parseBirthday(String birthday) { From 235b90dd308ec6f6e386271f9664e8546e345c85 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 26 May 2026 13:06:12 +0900 Subject: [PATCH 4/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=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/global/security/dto/KakaoDTO.java | 32 +++++++++ .../umc10th/global/security/dto/OAuthDTO.java | 14 ++++ .../global/security/entity/OAuthMember.java | 34 ++++++++++ .../security/handler/OAuthSuccessHandler.java | 50 ++++++++++++++ .../security/service/CustomOAuthService.java | 66 +++++++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java b/Sangwan/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java new file mode 100644 index 00000000..0743b4db --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java @@ -0,0 +1,32 @@ +package com.example.umc10th.global.security.dto; + +import com.example.umc10th.domain.member.enums.SocialType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class KakaoDTO implements OAuthDTO { + + private final String id; + private final String email; + private final String name; + + @Override + public SocialType getSocialType() { + return SocialType.KAKAO; + } + + @Override + public String getSocialUid() { + return id; + } + + @Override + public String getSocialEmail() { + return email; + } + + @Override + public String getName() { + return name; + } +} diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java b/Sangwan/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java new file mode 100644 index 00000000..0773190a --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java @@ -0,0 +1,14 @@ +package com.example.umc10th.global.security.dto; + +import com.example.umc10th.domain.member.enums.SocialType; + +public interface OAuthDTO { + + SocialType getSocialType(); + + String getSocialUid(); + + String getSocialEmail(); + + String getName(); +} diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java b/Sangwan/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java new file mode 100644 index 00000000..2299f87a --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java @@ -0,0 +1,34 @@ +package com.example.umc10th.global.security.entity; + +import com.example.umc10th.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +public class OAuthMember implements OAuth2User { + + 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/Sangwan/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java b/Sangwan/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 00000000..cfe4fcda --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,50 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.dto.MemberResDTO; +import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.security.entity.AuthMember; +import com.example.umc10th.global.security.entity.OAuthMember; +import com.example.umc10th.global.security.util.JwtUtil; +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.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import java.io.IOException; + +@RequiredArgsConstructor +public class OAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + ObjectMapper objectMapper = new ObjectMapper(); + BaseSuccessCode code = MemberSuccessCode.OK; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + OAuthMember member = (OAuthMember) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + ApiResponse responseBody = ApiResponse.onSuccess( + code, + MemberConverter.toLogin(accessToken) + ); + + objectMapper.writeValue(response.getOutputStream(), responseBody); + } +} diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java b/Sangwan/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java new file mode 100644 index 00000000..059edfe5 --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java @@ -0,0 +1,66 @@ +package com.example.umc10th.global.security.service; + +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.SocialType; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.security.dto.KakaoDTO; +import com.example.umc10th.global.security.dto.OAuthDTO; +import com.example.umc10th.global.security.entity.OAuthMember; +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 org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional +public class CustomOAuthService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + @SuppressWarnings("unchecked") + public OAuth2User loadUser( + OAuth2UserRequest userRequest + ) throws OAuth2AuthenticationException { + OAuth2User oAuthMember = super.loadUser(userRequest); + + SocialType providerId; + String socialUid; + Map attributes = oAuthMember.getAttribute("kakao_account"); + Map profile = (Map) attributes.get("profile"); + try { + providerId = SocialType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase()); + socialUid = String.valueOf((Long) oAuthMember.getAttribute("id")); + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + OAuthDTO dto; + switch (providerId) { + case KAKAO -> { + String email = attributes.get("email").toString(); + String name = profile.get("nickname").toString(); + dto = new KakaoDTO(socialUid, email, name); + } + default -> throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + Member member = memberRepository.findBySocialTypeAndSocialId(providerId, socialUid) + .orElseGet(() -> { + Member newMember = MemberConverter.toMember(dto); + memberRepository.save(newMember); + return newMember; + }); + + return new OAuthMember(member, oAuthMember.getAttributes()); + } +} From 80804d5b8b05e228fef1e08a17495e51784614ec Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 26 May 2026 13:06:16 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20me=20API=EB=A5=BC=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/global/config/SecurityConfig.java | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 4ae528ac..70a494ca 100644 --- a/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -4,27 +4,58 @@ import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; import com.example.umc10th.global.apiPayload.code.GeneralSuccessCode; +import com.example.umc10th.global.security.filter.JwtAuthFilter; +import com.example.umc10th.global.security.handler.OAuthSuccessHandler; +import com.example.umc10th.global.security.service.CustomOAuthService; +import com.example.umc10th.global.security.service.CustomUserDetailsService; +import com.example.umc10th.global.security.util.JwtUtil; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; import java.io.IOException; import java.nio.charset.StandardCharsets; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final CustomOAuthService customOAuthService; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public OAuthSuccessHandler oAuthSuccessHandler() { + return new OAuthSuccessHandler(jwtUtil); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) + throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -34,21 +65,31 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers( "/api/v1/members/signup", "/api/v1/members/login", + "/oauth/authorize/**", + "/oauth/callback/**", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**" ).permitAll() .anyRequest().authenticated() ) - .formLogin(form -> form - .loginProcessingUrl("/api/v1/members/login") - .usernameParameter("email") - .passwordParameter("password") - .successHandler((request, response, authentication) -> - writeSuccessResponse(response, GeneralSuccessCode.OK)) - .failureHandler((request, response, exception) -> - writeErrorResponse(response, GeneralErrorCode.UNAUTHORIZED)) - .permitAll() + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + //세션 + .sessionManagement(AbstractHttpConfigurer::disable) + //JWT 필터 + .addFilterAfter(jwtAuthFilter(), LogoutFilter.class) + .oauth2Login(oauth -> oauth + .authorizationEndpoint(auth -> auth + .baseUri("/oauth/authorize") + ) + .redirectionEndpoint(redirect -> redirect + .baseUri("/oauth/callback/**") + ) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuthService) + ) + .successHandler(oAuthSuccessHandler()) ) .logout(logout -> logout .logoutUrl("/api/v1/members/logout") From c171d9b63627babf4af96b43c35ff2f6c178fb89 Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 26 May 2026 13:08:02 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/global/security/AuthMember.java | 44 ------------------- .../security/CustomUserDetailsService.java | 27 ------------ 2 files changed, 71 deletions(-) delete mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/AuthMember.java delete mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/AuthMember.java b/Sangwan/src/main/java/com/example/umc10th/global/security/AuthMember.java deleted file mode 100644 index 827fab74..00000000 --- a/Sangwan/src/main/java/com/example/umc10th/global/security/AuthMember.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.umc10th.global.security; - -import com.example.umc10th.domain.member.entity.Member; -import com.example.umc10th.domain.member.enums.MemberStatus; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.jspecify.annotations.NonNull; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.Collection; -import java.util.List; - -@Getter -@RequiredArgsConstructor -public class AuthMember implements UserDetails { - - private final Member member; - - public Long getId() { - return member.getId(); - } - - @Override - public @NonNull Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())); - } - - @Override - public @NonNull String getPassword() { - return member.getPassword(); - } - - @Override - public @NonNull String getUsername() { - return member.getEmail(); - } - - @Override - public boolean isEnabled() { - return member.getStatus() == MemberStatus.ACTIVE; - } -} diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java b/Sangwan/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java deleted file mode 100644 index 0f6265ae..00000000 --- a/Sangwan/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.umc10th.global.security; - -import com.example.umc10th.domain.member.entity.Member; -import com.example.umc10th.domain.member.repository.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.jspecify.annotations.NonNull; -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; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class CustomUserDetailsService implements UserDetailsService { - - private final MemberRepository memberRepository; - - @Override - public @NonNull UserDetails loadUserByUsername(@NonNull String email) throws UsernameNotFoundException { - Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다.")); - - return new AuthMember(member); - } -} From e5bf384d416060818a48893a95151e0d5f5688bf Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Tue, 26 May 2026 13:17:48 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EB=B3=B4=EC=95=88=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20JSON=20=EC=9E=91=EC=84=B1=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc10th/global/config/SecurityConfig.java | 61 +++---------------- .../global/security/filter/JwtAuthFilter.java | 16 ++--- .../security/handler/OAuthSuccessHandler.java | 19 +----- .../handler/SecurityResponseWriter.java | 40 ++++++++++++ 4 files changed, 53 insertions(+), 83 deletions(-) create mode 100644 Sangwan/src/main/java/com/example/umc10th/global/security/handler/SecurityResponseWriter.java diff --git a/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 70a494ca..5246f26a 100644 --- a/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/Sangwan/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -1,15 +1,13 @@ package com.example.umc10th.global.config; -import com.example.umc10th.global.apiPayload.code.BaseErrorCode; -import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; import com.example.umc10th.global.apiPayload.code.GeneralSuccessCode; import com.example.umc10th.global.security.filter.JwtAuthFilter; import com.example.umc10th.global.security.handler.OAuthSuccessHandler; +import com.example.umc10th.global.security.handler.SecurityResponseWriter; import com.example.umc10th.global.security.service.CustomOAuthService; import com.example.umc10th.global.security.service.CustomUserDetailsService; import com.example.umc10th.global.security.util.JwtUtil; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -23,9 +21,6 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.LogoutFilter; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -34,6 +29,7 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final CustomUserDetailsService customUserDetailsService; private final CustomOAuthService customOAuthService; + private final SecurityResponseWriter securityResponseWriter; @Bean public PasswordEncoder passwordEncoder() { @@ -42,12 +38,12 @@ public PasswordEncoder passwordEncoder() { @Bean public JwtAuthFilter jwtAuthFilter() { - return new JwtAuthFilter(jwtUtil, customUserDetailsService); + return new JwtAuthFilter(jwtUtil, customUserDetailsService, securityResponseWriter); } @Bean public OAuthSuccessHandler oAuthSuccessHandler() { - return new OAuthSuccessHandler(jwtUtil); + return new OAuthSuccessHandler(jwtUtil, securityResponseWriter); } @Bean @@ -94,59 +90,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .logout(logout -> logout .logoutUrl("/api/v1/members/logout") .logoutSuccessHandler((request, response, authentication) -> - writeSuccessResponse(response, GeneralSuccessCode.OK)) + securityResponseWriter.writeSuccess(response, GeneralSuccessCode.OK)) .permitAll() ) .exceptionHandling(exception -> exception .authenticationEntryPoint((request, response, authException) -> - writeErrorResponse(response, GeneralErrorCode.UNAUTHORIZED)) + securityResponseWriter.writeFailure(response, GeneralErrorCode.UNAUTHORIZED)) .accessDeniedHandler((request, response, accessDeniedException) -> - writeErrorResponse(response, GeneralErrorCode.FORBIDDEN)) + securityResponseWriter.writeFailure(response, GeneralErrorCode.FORBIDDEN)) ); return http.build(); } - - private void writeSuccessResponse(HttpServletResponse response, BaseSuccessCode code) throws IOException { - writeResponse(response, code.getStatus().value(), true, code.getCode(), code.getMessage()); - } - - private void writeErrorResponse(HttpServletResponse response, BaseErrorCode code) throws IOException { - writeResponse(response, code.getStatus().value(), false, code.getCode(), code.getMessage()); - } - - private void writeResponse(HttpServletResponse response, int status, boolean isSuccess, - String code, String message) throws IOException { - response.setStatus(status); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write( - "{\"isSuccess\":%s,\"code\":\"%s\",\"message\":\"%s\",\"result\":null}" - .formatted(isSuccess, escapeJson(code), escapeJson(message)) - ); - } - - private String escapeJson(String value) { - StringBuilder escaped = new StringBuilder(); - for (int i = 0; i < value.length(); i++) { - char ch = value.charAt(i); - switch (ch) { - case '"' -> escaped.append("\\\""); - case '\\' -> escaped.append("\\\\"); - case '\b' -> escaped.append("\\b"); - case '\f' -> escaped.append("\\f"); - case '\n' -> escaped.append("\\n"); - case '\r' -> escaped.append("\\r"); - case '\t' -> escaped.append("\\t"); - default -> { - if (ch < 0x20) { - escaped.append(String.format("\\u%04x", (int) ch)); - } else { - escaped.append(ch); - } - } - } - } - return escaped.toString(); - } } diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/Sangwan/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java index bef5d950..8dd3084d 100644 --- a/Sangwan/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -1,12 +1,10 @@ package com.example.umc10th.global.security.filter; -import com.example.umc10th.global.apiPayload.ApiResponse; -import com.example.umc10th.global.apiPayload.code.BaseErrorCode; -import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; import com.example.umc10th.domain.member.enums.SocialType; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.global.security.handler.SecurityResponseWriter; import com.example.umc10th.global.security.service.CustomUserDetailsService; import com.example.umc10th.global.security.util.JwtUtil; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -25,6 +23,7 @@ public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final CustomUserDetailsService customUserDetailsService; + private final SecurityResponseWriter securityResponseWriter; @Override protected void doFilterInternal( @@ -57,14 +56,7 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } catch (Exception e) { - ObjectMapper mapper = new ObjectMapper(); - BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; - - response.setContentType("application/json;charset=UTF-8"); - response.setStatus(code.getStatus().value()); - - ApiResponse errorResponse = ApiResponse.onFailure(code, null); - mapper.writeValue(response.getOutputStream(), errorResponse); + securityResponseWriter.writeFailure(response, GeneralErrorCode.UNAUTHORIZED); } } } diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java b/Sangwan/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java index cfe4fcda..6f06959d 100644 --- a/Sangwan/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java @@ -1,14 +1,10 @@ package com.example.umc10th.global.security.handler; import com.example.umc10th.domain.member.converter.MemberConverter; -import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; -import com.example.umc10th.global.apiPayload.ApiResponse; -import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; import com.example.umc10th.global.security.entity.AuthMember; import com.example.umc10th.global.security.entity.OAuthMember; import com.example.umc10th.global.security.util.JwtUtil; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -23,6 +19,7 @@ public class OAuthSuccessHandler implements AuthenticationSuccessHandler { private final JwtUtil jwtUtil; + private final SecurityResponseWriter securityResponseWriter; @Override public void onAuthenticationSuccess( @@ -30,21 +27,9 @@ public void onAuthenticationSuccess( HttpServletResponse response, Authentication authentication ) throws IOException, ServletException { - ObjectMapper objectMapper = new ObjectMapper(); - BaseSuccessCode code = MemberSuccessCode.OK; - - response.setContentType("application/json;charset=UTF-8"); - response.setStatus(code.getStatus().value()); - OAuthMember member = (OAuthMember) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); - ApiResponse responseBody = ApiResponse.onSuccess( - code, - MemberConverter.toLogin(accessToken) - ); - - objectMapper.writeValue(response.getOutputStream(), responseBody); + securityResponseWriter.writeSuccess(response, MemberSuccessCode.OK, MemberConverter.toLogin(accessToken)); } } diff --git a/Sangwan/src/main/java/com/example/umc10th/global/security/handler/SecurityResponseWriter.java b/Sangwan/src/main/java/com/example/umc10th/global/security/handler/SecurityResponseWriter.java new file mode 100644 index 00000000..f2ae42bc --- /dev/null +++ b/Sangwan/src/main/java/com/example/umc10th/global/security/handler/SecurityResponseWriter.java @@ -0,0 +1,40 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +public class SecurityResponseWriter { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public void writeSuccess(HttpServletResponse response, BaseSuccessCode code) throws IOException { + writeSuccess(response, code, null); + } + + public void writeSuccess(HttpServletResponse response, BaseSuccessCode code, T result) throws IOException { + write(response, code.getStatus().value(), ApiResponse.onSuccess(code, result)); + } + + public void writeFailure(HttpServletResponse response, BaseErrorCode code) throws IOException { + writeFailure(response, code, null); + } + + public void writeFailure(HttpServletResponse response, BaseErrorCode code, T result) throws IOException { + write(response, code.getStatus().value(), ApiResponse.onFailure(code, result)); + } + + private void write(HttpServletResponse response, int status, ApiResponse body) throws IOException { + response.setStatus(status); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getOutputStream(), body); + } +} From 5ae6a2ba20ab2d07e98bec5e1b747b4fb2259a8b Mon Sep 17 00:00:00 2001 From: SangwanYu Date: Wed, 27 May 2026 13:15:31 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=209=EC=A3=BC=EC=B0=A8=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sangwan/keyword_summary/ch09.md | 282 ++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 Sangwan/keyword_summary/ch09.md diff --git a/Sangwan/keyword_summary/ch09.md b/Sangwan/keyword_summary/ch09.md new file mode 100644 index 00000000..3c732de1 --- /dev/null +++ b/Sangwan/keyword_summary/ch09.md @@ -0,0 +1,282 @@ +- 세션과 토큰의 차이는? + + --- + + > **세션(Session)은 서버가 클라이언트의 인증 상태를 서버 메모리에 저장하는 Stateful 방식이고, 토큰(Token)은 인증 정보를 클라이언트가 직접 보관하며 서버는 저장하지 않는 Stateless 방식이다. 두 방식의 핵심 차이는 "누가 상태를 기억하느냐"에 있다.** + > + + --- + + ### 세션 기반 인증 (Stateful) + + 세션 기반 인증은 사용자가 로그인하면 서버가 세션 객체를 생성하고, 그 세션 ID를 쿠키(JSESSIONID)에 담아 클라이언트에 전달하는 방식이다. 이후 클라이언트는 매 요청마다 이 쿠키를 서버에 전송하고, 서버는 세션 저장소에서 해당 ID를 조회하여 사용자를 식별한다. + + ``` + 1. 클라이언트 → 서버: 로그인 요청 (email + password) + 2. 서버: 인증 성공 → 세션 생성 (sessionId: abc123) + 3. 서버 → 클라이언트: Set-Cookie: JSESSIONID=abc123 + 4. 클라이언트 → 서버: 요청 + Cookie: JSESSIONID=abc123 + 5. 서버: 세션 저장소에서 abc123 조회 → 사용자 확인 + ``` + + Spring Security 폼 로그인 설정 (세션 방식): + + ```java + .formLogin(form -> form + .defaultSuccessUrl("/swagger-ui/index.html", true) + .permitAll() + ) + // 인증 정보는 서버 세션에 저장되고, 브라우저는 JSESSIONID 쿠키를 전송 + // 서버는 해당 ID로 세션을 찾아 사용자 인증 상태를 유지 (Stateful) + ``` + + --- + + ### 토큰 기반 인증 (Stateless) + + 토큰 기반 인증은 로그인 성공 시 서버가 JWT 토큰을 발급하고, 클라이언트가 이를 저장(로컬 스토리지, 세션 스토리지 등)하는 방식이다. 이후 요청 시 Authorization: Bearer 헤더에 토큰을 담아 전송하면, 서버는 DB 조회 없이 토큰의 서명만 검증하여 사용자를 식별한다. + + ``` + 1. 클라이언트 → 서버: 로그인 요청 (email + password) + 2. 서버: 인증 성공 → JWT 토큰 생성 후 반환 + 3. 클라이언트: 토큰을 로컬 스토리지에 저장 + 4. 클라이언트 → 서버: 요청 + Authorization: Bearer eyJhbGci... + 5. 서버: 토큰 서명 검증 → 사용자 확인 (DB 조회 없음) + ``` + + --- + + ### 세션 vs 토큰 비교 + + | 구분 | 세션 기반 (Stateful) | 토큰 기반 (Stateless) | + | --- | --- | --- | + | 상태 저장 위치 | 서버 (메모리/DB) | 클라이언트 (로컬 스토리지 등) | + | 서버 확장성 | 낮음 (세션 공유 문제) | 높음 (서버 간 공유 불필요) | + | 로그아웃 처리 | 즉시 가능 (서버에서 세션 삭제) | 어려움 (토큰 만료 전까지 유효) | + | 보안 | 세션 하이재킹 위험 | 토큰 탈취 시 만료 전까지 악용 가능 | + | 서버 부하 | 높음 (세션 저장소 유지) | 낮음 (서명 검증만 수행) | + | 모바일/앱 지원 | 쿠키 의존으로 불편 | 헤더 기반으로 편리 | + | 주요 사용 사례 | 전통적인 웹 서비스, 금융 서비스 | REST API, MSA, 모바일 앱 | + + --- + + ### 어떤 방식을 선택해야 할까? + + 최근에는 모바일 앱, 프론트엔드와 백엔드가 분리된 구조, REST API 기반 서비스가 많아지면서 토큰 기반 인증(JWT)을 범용적으로 더 많이 사용하는 추세이다. 반면 금융 서비스처럼 보안이 최우선인 경우에는 세션 방식이 유리하다. + + 서버가 여러 대로 확장되는 환경(Scale-out)에서는 세션 공유 문제가 발생하므로 토큰 방식이 훨씬 유리하다. 세션 방식을 유지하려면 Redis 같은 별도의 세션 공유 저장소가 필요하다. + +- 엑세스 토큰과 리프레시 토큰이란? + + --- + + > **액세스 토큰(Access Token)은 API 요청 시 인증에 사용하는 단기 JWT 토큰이고, 리프레시 토큰(Refresh Token)은 액세스 토큰이 만료되었을 때 재발급을 위해 사용하는 장기 보조 토큰이다. 두 토큰을 함께 사용하면 보안성과 사용자 편의성을 동시에 높일 수 있다.** + > + + --- + + ### 왜 두 가지 토큰이 필요한가? + + JWT 토큰은 한번 발급되면 서버에서 임의로 무효화할 수 없다는 단점이 있다. 만약 토큰이 해커에게 탈취된다면, 토큰이 만료될 때까지 해커가 해당 사용자인 척 API를 호출할 수 있다. + + 이 문제를 해결하기 위한 전략이 바로 **액세스 토큰 + 리프레시 토큰** 조합이다. + + - 액세스 토큰의 유효기간을 짧게 설정(30분~1시간)하여 탈취 시 피해를 최소화한다. + - 리프레시 토큰의 유효기간은 길게 설정(7일~30일)하여 사용자가 자주 재로그인하지 않아도 되게 한다. + + --- + + ### 액세스 토큰 (Access Token) + + 액세스 토큰은 실제 API 요청 시 인증에 사용하는 메인 JWT 토큰이다. 모든 Private API 호출 시 `Authorization: Bearer ` 헤더에 담아 전송한다. + + ```java + // JwtUtil에서 액세스 토큰 생성 + public String createAccessToken(AuthMember member) { + return Jwts.builder() + .subject(member.getUsername()) // 사용자 이메일 + .claim("role", authorities) // 권한 정보 + .issuedAt(Date.from(now)) // 발급 시간 + .expiration(Date.from(now.plus(accessExpiration))) // 만료 시간 (30분) + .signWith(secretKey) + .compact(); + } + ``` + + **특징:** + + - 유효기간: 짧게 설정 (30분 ~ 4시간 권장) + - 저장 위치: 클라이언트 메모리, 로컬 스토리지, 쿠키 + - 용도: 모든 인증이 필요한 API 요청에 사용 + - 만료 시: 리프레시 토큰으로 재발급 요청 + + --- + + ### 리프레시 토큰 (Refresh Token) + + 리프레시 토큰은 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 재발급받기 위한 보조 토큰이다. 리프레시 토큰 자체는 API 인증에 직접 사용하지 않는다. + + ``` + [액세스 토큰 만료 시 재발급 흐름] + 1. 클라이언트 → 서버: API 요청 + 만료된 액세스 토큰 + 2. 서버: 401 Unauthorized 응답 + 3. 클라이언트 → 서버: 토큰 재발급 요청 + 리프레시 토큰 + 4. 서버: 리프레시 토큰 검증 (DB 조회) + 5. 서버 → 클라이언트: 새로운 액세스 토큰 발급 + 6. 클라이언트: 새 액세스 토큰으로 원래 API 재요청 + ``` + + **특징:** + + - 유효기간: 길게 설정 (7일 ~ 30일) + - 저장 위치: 서버 DB 또는 Redis (보안을 위해 서버에 저장) + - 용도: 액세스 토큰 재발급 전용 + - 로그아웃 시: DB에서 리프레시 토큰 삭제 → 사실상 로그아웃 처리 가능 + + --- + + ### 액세스 토큰 vs 리프레시 토큰 비교 + + | 구분 | 액세스 토큰 (Access Token) | 리프레시 토큰 (Refresh Token) | + | --- | --- | --- | + | 역할 | API 인증에 직접 사용 | 액세스 토큰 재발급용 | + | 유효기간 | 짧음 (30분 ~ 4시간) | 김 (7일 ~ 30일) | + | 저장 위치 | 클라이언트 (로컬 스토리지 등) | 서버 DB 또는 Redis | + | 탈취 시 피해 | 만료 전까지 악용 가능 (짧아서 피해 최소화) | 매우 위험 (즉시 모든 권한 탈취) | + | 만료 시 처리 | 리프레시 토큰으로 재발급 | 재로그인 필요 | + + --- + + ### application.yml 설정 예시 + + ```yaml + jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 (ms 단위) + refresh: 604800000 # 7일 (ms 단위) + ``` + + --- + + ### 정리 + + 액세스 토큰과 리프레시 토큰의 조합은 JWT의 가장 큰 단점인 "토큰 무효화 불가" 문제를 보완하는 현실적인 해결책이다. 액세스 토큰은 짧은 유효기간으로 보안을 강화하고, 리프레시 토큰은 긴 유효기간으로 사용자 편의성을 보장한다. 리프레시 토큰을 서버 DB에 저장함으로써 로그아웃 시 즉각적인 무효화도 가능해진다. + +- OAuth 1.0과 OAuth 2.0의 차이는? + + --- + + > **OAuth(Open Authorization)는 제3자 애플리케이션이 사용자의 자격 증명(아이디/비밀번호) 없이도 사용자를 대신해 특정 서비스의 리소스에 접근할 수 있도록 허가하는 개방형 표준 프로토콜이다. OAuth 1.0과 2.0은 같은 목적을 가지지만 구현 방식과 보안 메커니즘에서 큰 차이가 있다.** + > + + --- + + ### OAuth란? + + 소셜 로그인(카카오로 로그인, 구글로 로그인 등)이 바로 OAuth를 활용한 대표적인 예이다. 사용자는 카카오 계정 정보를 우리 서비스에 직접 제공하지 않아도, 카카오에 로그인하고 권한을 허가함으로써 우리 서비스가 카카오 프로필 정보를 가져올 수 있게 된다. + + **OAuth의 주요 등장인물:** + + - **Resource Owner**: 사용자 (카카오 계정 소유자) + - **Client**: 우리가 만드는 서비스 (Spring Boot 앱) + - **Authorization Server**: 인증을 처리하는 서버 (카카오 인증 서버) + - **Resource Server**: 실제 데이터를 가진 서버 (카카오 API 서버) + + --- + + ### OAuth 1.0 + + OAuth 1.0은 2007년에 등장한 초기 버전으로, 모든 요청에 서명(Signature)을 포함시켜 보안을 보장하는 방식이다. + + ``` + [OAuth 1.0 흐름] + 1. Client → Authorization Server: Request Token 요청 + 2. Authorization Server → Client: Request Token 발급 + 3. Client → Resource Owner: 인증 페이지로 리다이렉트 + 4. Resource Owner: 권한 허가 + 5. Authorization Server → Client: Verifier 코드 전달 + 6. Client → Authorization Server: Access Token 요청 (Request Token + Verifier) + 7. Authorization Server → Client: Access Token 발급 + 8. Client → Resource Server: API 요청 (Access Token + 서명) + ``` + + **OAuth 1.0의 특징:** + + - 모든 API 요청에 HMAC-SHA1 서명 포함 (복잡한 암호화 로직 필요) + - HTTPS 없이도 보안 유지 가능 (서명 검증으로 위변조 방지) + - 구현이 매우 복잡하고 모바일 환경에 부적합 + - Access Token이 만료되지 않아 보안 취약점 존재 + + --- + + ### OAuth 2.0 + + OAuth 2.0은 2012년에 등장한 개선 버전으로, HTTPS를 기본으로 전제하여 서명 로직을 제거하고 구현을 단순화했다. 현재 카카오, 구글, 네이버 등 대부분의 소셜 로그인이 OAuth 2.0을 사용한다. + + ``` + [OAuth 2.0 Authorization Code 흐름 - 가장 많이 사용] + 1. Client → Resource Owner: 인증 페이지로 리다이렉트 + (예: https://kauth.kakao.com/oauth/authorize?client_id=...&redirect_uri=...) + 2. Resource Owner: 카카오 로그인 + 권한 허가 + 3. Authorization Server → Client: Authorization Code 전달 (redirect_uri로) + 4. Client → Authorization Server: Access Token 요청 (Authorization Code 사용) + 5. Authorization Server → Client: Access Token + Refresh Token 발급 + 6. Client → Resource Server: API 요청 (Access Token) + 7. Resource Server → Client: 사용자 정보 반환 + ``` + + **Spring Boot에서 카카오 OAuth 2.0 설정 예시:** + + ```yaml + spring: + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" + authorization-grant-type: authorization_code + 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 + ``` + + --- + + ### OAuth 1.0 vs OAuth 2.0 비교 + + | 구분 | OAuth 1.0 | OAuth 2.0 | + | --- | --- | --- | + | 등장 시기 | 2007년 | 2012년 | + | 보안 방식 | HMAC-SHA1 서명 (모든 요청에 서명 포함) | HTTPS 기반 (서명 없음) | + | 구현 복잡도 | 높음 (서명 로직 직접 구현) | 낮음 (라이브러리 활용 가능) | + | 토큰 종류 | Request Token + Access Token | Access Token + Refresh Token | + | 토큰 만료 | 만료 없음 (보안 취약) | 만료 있음 + Refresh Token으로 재발급 | + | 모바일 지원 | 부적합 (복잡한 서명 로직) | 적합 (다양한 Grant Type 지원) | + | Grant Type | 단일 방식 | 4가지 방식 (Authorization Code, Implicit, Client Credentials, Resource Owner Password) | + | 현재 사용 | 거의 사용 안 함 | 표준으로 사용 (카카오, 구글, 네이버 등) | + + --- + + ### OAuth 2.0의 4가지 Grant Type + + | Grant Type | 설명 | 주요 사용 사례 | + | --- | --- | --- | + | Authorization Code | 인증 코드를 통해 토큰 발급 (가장 안전) | 소셜 로그인 (카카오, 구글 등) | + | Implicit | 인증 코드 없이 바로 토큰 발급 (현재 권장 안 함) | SPA (현재는 PKCE로 대체) | + | Client Credentials | 사용자 없이 서버 간 인증 | MSA 서비스 간 통신 | + | Resource Owner Password | 사용자 ID/PW를 직접 전달 (권장 안 함) | 신뢰할 수 있는 1st-party 앱 | + + --- + + ### 정리 + + OAuth 2.0은 OAuth 1.0의 복잡한 서명 메커니즘을 HTTPS로 대체하여 구현을 대폭 단순화했다. 현재 대부분의 소셜 로그인은 OAuth 2.0의 Authorization Code Grant Type을 사용하며, Spring Security는 `spring-security-oauth2-client` 라이브러리를 통해 이를 손쉽게 구현할 수 있도록 지원한다. \ No newline at end of file