diff --git a/Hyeonu/.gitignore b/Hyeonu/.gitignore index 79e19b3f..4c2ef43f 100644 --- a/Hyeonu/.gitignore +++ b/Hyeonu/.gitignore @@ -39,3 +39,5 @@ out/ ### VS Code ### .vscode/ +# 인텔리제이 모듈 설정 파일 무시 +*.xml \ No newline at end of file diff --git a/Hyeonu/build.gradle b/Hyeonu/build.gradle index 2b6a1a72..335af929 100644 --- a/Hyeonu/build.gradle +++ b/Hyeonu/build.gradle @@ -37,6 +37,15 @@ 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' + + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/Hyeonu/keyword_summary/ch09.md b/Hyeonu/keyword_summary/ch09.md new file mode 100644 index 00000000..9dfb4768 --- /dev/null +++ b/Hyeonu/keyword_summary/ch09.md @@ -0,0 +1,58 @@ +- 세션과 토큰의 차이는? + + | 구분 | 세션 기반 인증 | 토큰 기반 인증 | + | --- | --- | --- | + | 인증 정보 저장 위치 | 서버 |클라이언트(브라우저/앱) | + | 상태 유지 | Stateful(상태 유지) | Stateless(상태 없음) | + | 인증 방식 | 서버가 세션 ID 기억 | 토큰 자체로 사용자 인증 | + | 저장 형태 | 쿠키(JSESSIONID) | JWT 등 토큰 | + | 확장성 | 서버 늘어나면 관리 복잡 | 서버 확장 쉬움 | + | 보안 | 상대적으로 안전 | 토큰 탈취 시 위험 | + + **세션은 서버가 로그인 상태를 기억하고, 토큰은 토큰 자체가 인증 정보를 가진다.** + + +- 엑세스 토큰과 리프레시 토큰이란? + + **Access Token** + + 실제 인증에 사용하는 메인 토큰 + + - API 요청 시 사용 + - Authorization 헤더에 담아 전송 + - 유효기간이 짧음 + + **Refresh Token** + + - 로그인 유지 목적 + - Access Token 만료 시 새 토큰 발급 + - 유효기간이 김 (2주~ 1개월) + + 흐름 + + - 로그인 성공 + - Access + Refresh Token 발급 + - Access Token 만료 + - Refresh Token으로 재발급 + - 재로그인 없이 계속 사용 + + +- OAuth 1.0과 OAuth 2.0의 차이는? + + **OAuth** + + 비밀번호를 직접 공유하지 않고 제 3자 서비스가 권한을 위임받는 인증 방식 + + 예) 카카오, 구글, 네이버 로그인 + + | 구분 | OAuth 1.0 | OAuth 2.0 | + | --- | --- | --- | + | 보안 방식 | 복잡한 서명(Signature) | HTTPS 기반 | + | 구현 난이도 | 어려움 | 쉬움 | + | 성능 | 느림 | 빠름 | + | 사용성 | 낮음 | 높음 | + | 현재 사용 | 거의 안 씀 | 대부분 사용 | + + OAuth 1.0은 서명 기반으로 복잡하고, OAuth 2.0은 토큰 기반으로 단순하고 확장성이 좋다. + + OAuth 2.0은 OAuth 1.0의 복잡한 서명 방식을 제거하고 Access Token 기반 인증을 도입하여 구현이 단순해지고 확장성이 향상된 방식이다. \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index 1a4135b9..f9303259 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -5,8 +5,10 @@ import com.example.umc10th.domain.member.service.MemberService; import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; +import com.example.umc10th.global.security.entity.AuthMember; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -23,11 +25,13 @@ public class MemberController { @PostMapping("/v1/members/me") public ResponseEntity> getInfo( // 받은 JSON 데이터를 자바 객체(dto)로 변환해서 씀 - @RequestBody MemberReqDTO.GetInfo dto + // @RequestBody MemberReqDTO.GetInfo dto + // 헤더에 담긴 토큰을 가지고 사용자 정보 리턴 + @AuthenticationPrincipal AuthMember member ){ return ResponseEntity .status(MemberSuccessCode.OK.getStatus()) - .body(ApiResponse.onSuccess(MemberSuccessCode.OK,memberService.getInfo(dto))); + .body(ApiResponse.onSuccess(MemberSuccessCode.OK,memberService.getInfo(member))); } // 회원가입 @@ -39,4 +43,14 @@ public ResponseEntity> signUp( .status(MemberSuccessCode.SIGN_UP.getStatus()) .body(ApiResponse.onSuccess(MemberSuccessCode.SIGN_UP,memberService.signUp(dto))); } + + // 로그인 + @PostMapping("/auth/login") + public ResponseEntity>login( + @RequestBody MemberReqDTO.LoginReqDTO dto + ){ + return ResponseEntity + .status(MemberSuccessCode.LOGIN.getStatus()) + .body(ApiResponse.onSuccess(MemberSuccessCode.LOGIN,memberService.login(dto))); +} } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index 25ab43da..eaaac706 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -3,6 +3,7 @@ import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.global.security.dto.OAuthDTO; public class MemberConverter { public static MemberResDTO.GetInfo toGetInfo(Member member) { @@ -36,4 +37,20 @@ public static Member toMember(MemberReqDTO.SignUpReqDTO dto, String encodedPassw public static MemberResDTO.SignUpResDTO toSignUpResDTO(Member member) { return new MemberResDTO.SignUpResDTO(member.getId()); } + + // 로그인 + public static MemberResDTO.LoginResDTO toLoginResDTO(String token){ + return MemberResDTO.LoginResDTO.builder() + .accessToken(token) + .build(); + } + + public static Member toMember(OAuthDTO dto) { + return Member.builder() + .email(dto.getSocialEmail()) + .name(dto.getName()) + .socialType(dto.getSocialType()) + .socialUid(dto.getSocialUid()) + .build(); + } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index d28130cf..91c40329 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -23,4 +23,10 @@ public record SignUpReqDTO( String email, String password ){} + + // 로그인 + public record LoginReqDTO( + String email, + String password + ){} } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java index 88bf982d..4b1a5388 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java @@ -25,4 +25,11 @@ public record GetInfo( public record SignUpResDTO( Long id ){} + + // 로그인 + + @Builder + public record LoginResDTO( + String accessToken + ){} } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/entity/Member.java index 0f390e54..90bcad51 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -4,6 +4,7 @@ import com.example.umc10th.domain.member.enums.Address; import com.example.umc10th.domain.member.enums.Gender; import com.example.umc10th.domain.member.enums.MemberStatus; +import com.example.umc10th.domain.member.enums.SocialType; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -27,7 +28,7 @@ public class Member extends BaseEntity { @Column(name="name", nullable=false) private String name; - @Column(name="gender", nullable=false) + @Column(name="gender") @Enumerated(EnumType.STRING) private Gender gender; @@ -59,4 +60,11 @@ public class Member extends BaseEntity { @Builder.Default private MemberStatus status=MemberStatus.ACTIVE; + @Column(name="social_type") + @Enumerated(EnumType.STRING) + private SocialType socialType; + + @Column(name="social_uid") + private String socialUid; + } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java new file mode 100644 index 00000000..843ecfb8 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java @@ -0,0 +1,5 @@ +package com.example.umc10th.domain.member.enums; + +public enum SocialType { + KAKAO +} diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index c2fe99e8..a4f47c8d 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -8,7 +8,8 @@ @RequiredArgsConstructor public enum MemberErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, " MEMBER404_1","해당 사용자를 찾을 수 없습니다."), - ; + INVALID_PASSWORD(HttpStatus.NOT_FOUND,"MEMBER404_2","비밀번호가 일치하지 않습니다." ), + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.NOT_FOUND,"MEMBER404_3" ,"지원하지 않는 소셜 로그인입니다." ); private final HttpStatus status; private final String code; diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java index 5720f48a..38283c21 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java @@ -14,7 +14,11 @@ public enum MemberSuccessCode implements BaseSuccessCode { "성공적으로 유저를 조회했습니다."), SIGN_UP(HttpStatus.OK, "MEMBER200_2", - "회원가입에 성공했습니다."); + "회원가입에 성공했습니다."), + + LOGIN(HttpStatus.OK, + "MEMBER200_3", + "로그인에 성공했습니다."); private final HttpStatus status; diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index 8d9fa19f..f34d570c 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -1,9 +1,11 @@ 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 org.springframework.data.jpa.repository.Query; + import java.util.Optional; public interface MemberRepository extends JpaRepository { @@ -11,4 +13,6 @@ public interface MemberRepository extends JpaRepository { Optional findActiveMember(String name); Optional findByEmail(String username); + + Optional findBySocialTypeAndSocialUid(SocialType socialType, String socialUid); } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 76ea6d08..9c7bdd16 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -7,6 +7,8 @@ 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.entity.AuthMember; +import com.example.umc10th.global.security.util.JwtUtil; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -16,15 +18,11 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; // 마이 페이지 - public MemberResDTO.GetInfo getInfo(MemberReqDTO.GetInfo dto) { - // DTO에서 유저 ID를 추출 - Long memberId=dto.id(); - // DB에서 해당 유저 ID로 데이터 조회 - Member member=memberRepository.findById(memberId) - .orElseThrow(()-> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + public MemberResDTO.GetInfo getInfo(AuthMember member) { // 컨버터를 이용해서 응답 DTO 생성 & return - return MemberConverter.toGetInfo(member); + return MemberConverter.toGetInfo(member.getMember()); } // 회원가입 @@ -41,6 +39,24 @@ public MemberResDTO.SignUpResDTO signUp(MemberReqDTO.SignUpReqDTO dto) { // Entity -> ResponseDTO 변환 return MemberConverter.toSignUpResDTO(savedMember); + } + + // 로그인 + public MemberResDTO.LoginResDTO login(MemberReqDTO.LoginReqDTO dto){ + + // 이메일로 유저 찾기 + Member member=memberRepository.findByEmail(dto.email()) + .orElseThrow(()->new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 비밀번호 검증 + if(!passwordEncoder.matches(dto.password(),member.getPassword())){ + throw new MemberException(MemberErrorCode.INVALID_PASSWORD); + } + + // 토큰 발급 + AuthMember authMember=new AuthMember(member); + String token=jwtUtil.createAccessToken(authMember); + return MemberConverter.toLoginResDTO(token); } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Hyeonu/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 16900ba4..b2dcf3e4 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/Hyeonu/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -1,7 +1,13 @@ package com.example.umc10th.global.config; +import com.example.umc10th.global.security.filter.JwtAuthFilter; import com.example.umc10th.global.security.handler.CustomAccessDenied; import com.example.umc10th.global.security.handler.CustomEntryPoint; +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 lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -10,66 +16,85 @@ 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; -@EnableWebSecurity // Spring Security 활성화 -@Configuration // 스프링 설정 파일임을 선언 +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor public class SecurityConfig { - // 허용 URI 목록 + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final CustomOAuthService customOAuthService; // 추가 + private final String[] allowUris = { - // Swagger 허용 - "/swagger-ui/**", // API 문서 UI - "/swagger-resources/**", // Swagger 리소스 - "/v3/api-docs/**", // Openapi 스펙 문서 - "/api/auth/**" // 인증 관련 엔드 포인트(로그인, 회원가입 등) + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/api/auth/**", + "/oauth/**" // OAuth 경로 추가 }; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - // RestAPI는 보통 CSRF 공격에 덜 취약하므로 비활성화 .csrf(AbstractHttpConfigurer::disable) - // 요청 권한 설정 .authorizeHttpRequests(requests -> requests - .requestMatchers(allowUris).permitAll() // allowUris는 누구나 접근 가능 - .anyRequest().authenticated() // 그 외 모든 요청은 로그인 필요 - ) - // 폼 로그인 설정 - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) // 로그인 성공 시 이동 - .permitAll() // 로그인 페이지는 모든 사용자가 접근 가능 + .requestMatchers(allowUris).permitAll() + .anyRequest().authenticated() ) - // 로그아웃 설정 + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(AbstractHttpConfigurer::disable) + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) .logout(logout -> logout - .logoutUrl("/logout") // 이 URL로 POST 요청 시 로그아웃 - .logoutSuccessUrl("/login?logout") // 로그아웃 후 로그인 페이지로 이동 + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") .permitAll() ) - // 예외 상황 핸들러 + // OAuth 추가 + .oauth2Login(oauth -> oauth + .authorizationEndpoint(auth -> auth + .baseUri("/oauth/authorize") + ) + .redirectionEndpoint(redirect -> redirect + .baseUri("/oauth/callback/**") + ) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuthService) + ) + .successHandler(oAuthSuccessHandler()) + ) .exceptionHandling(exception -> exception - .accessDeniedHandler(customAccessDenied()) // 403 발생 - .authenticationEntryPoint(customEntryPoint())) //401 발생 - ; - + .accessDeniedHandler(customAccessDenied()) + .authenticationEntryPoint(customEntryPoint()) + ); return http.build(); } - // PasswordEncoder- 비밀번호 암호화 - // BCrypt 알고리즘으로 해시 암호화(같은 비밀번호도 매번 다른 해시값이 생성되어 암호화) @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - // @Bean - public CustomAccessDenied customAccessDenied(){ + public CustomAccessDenied customAccessDenied() { return new CustomAccessDenied(); } @Bean - public CustomEntryPoint customEntryPoint(){ + public CustomEntryPoint customEntryPoint() { return new CustomEntryPoint(); } -} + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + // 추가 + @Bean + public OAuthSuccessHandler oAuthSuccessHandler() { + return new OAuthSuccessHandler(jwtUtil); + } +} \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java new file mode 100644 index 00000000..64fbe2d4 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java @@ -0,0 +1,31 @@ +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; + } +} \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java new file mode 100644 index 00000000..144f4c53 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java @@ -0,0 +1,10 @@ +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/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java index 1e3d3da4..5d345721 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java @@ -30,7 +30,9 @@ public Collection getAuthorities(){ } @Override - public String getUsername(){ - return member.getEmail(); // 이메일을 username으로 사용(식별자로) + public String getUsername() { + // 소셜 로그인이면 socialUid, 일반 로그인이면 email + return member.getSocialUid() != null ? member.getSocialUid() : member.getEmail(); } + } diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java new file mode 100644 index 00000000..90eb679f --- /dev/null +++ b/Hyeonu/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; + +@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.getSocialUid(); + } +} diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java new file mode 100644 index 00000000..b5e73529 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,71 @@ +package com.example.umc10th.global.security.filter; + +import com.example.umc10th.domain.member.enums.SocialType; +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.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.NonNull; +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( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull 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; + if (socialType != null) { + user = customUserDetailsService.loadUserByUidAndSocialType(socialType, uid); + } else { + String email = jwtUtil.getEmail(token); + user = customUserDetailsService.loadUserByUsername(email); + } + + 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); + } + } +} \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 00000000..9bc5f4c6 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,56 @@ +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 { + // 사전 작업: Response 매핑할 ObjectMapper 선언 + ObjectMapper objectMapper = new ObjectMapper(); + BaseSuccessCode code = MemberSuccessCode.OK; + + // Content-Type, Status 설정 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // 인증 객체 컨테이너에서 OAuth 인증 객체 가져오기 + OAuthMember member = (OAuthMember) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + // 토큰 제작을 위해 OAuth 인증 객체에서 Member 추출 -> AuthMember 제작 + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + // 응답 통일 객체 래핑 + ApiResponse responseBody = ApiResponse.onSuccess( + code, + MemberConverter.toLoginResDTO(accessToken) + ); + + // 응답 출력 + objectMapper.writeValue(response.getOutputStream(), responseBody); + } +} diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java new file mode 100644 index 00000000..ba9678b5 --- /dev/null +++ b/Hyeonu/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 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); + + // 유저 객체에서 정보 추출 + 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); + } + + // OAuth 공통 정보 DTO로 매핑 + 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); + } + + // DB 저장: 있다면 그 데이터 가져오고 없으면 새로 저장 + Member member = memberRepository.findBySocialTypeAndSocialUid(providerId, socialUid) + .orElseGet(() -> { + Member newMember = MemberConverter.toMember(dto); + memberRepository.save(newMember); + return newMember; + }); + return new OAuthMember(member, oAuthMember.getAttributes()); + } +} diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java index dc1306d2..8a791304 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java @@ -1,6 +1,7 @@ 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.exception.MemberException; import com.example.umc10th.domain.member.exception.code.MemberErrorCode; import com.example.umc10th.domain.member.repository.MemberRepository; @@ -16,17 +17,21 @@ // Spring Security가 "이 인터페이스를 구현한 클래스로 사용자를 조회해라" public class CustomUserDetailsService implements UserDetailsService { - // DB에서 회원을 조회하기 위한 Repository private final MemberRepository memberRepository; @Override - public UserDetails loadUserByUsername( + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return new AuthMember(member); + } + + public UserDetails loadUserByUidAndSocialType( + SocialType socialType, String username - )throws UsernameNotFoundException{ - // 이메일로 DB에서 회원 조회 - Member member=memberRepository.findByEmail(username) - .orElseThrow(()->new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - // AuthMember로 감싸서 반환 + ) throws UsernameNotFoundException { + Member member = memberRepository.findBySocialTypeAndSocialUid(socialType, username) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); return new AuthMember(member); } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java new file mode 100644 index 00000000..d00580af --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,99 @@ +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; + } + } + + // 추가: UID 가져오기 + 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 (Exception e) { + return null; + } + } + + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 변경: subject를 email → uid로, social_type claim 추가 + 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()) // UID를 Subject로 + .claim("role", authorities) + .claim("social_type", member.getMember().getSocialType()) // social_type 추가 + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(expiration))) + .signWith(secretKey) + .compact(); + } + + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +}