diff --git a/build.gradle b/build.gradle index 3706840..67f2524 100644 --- a/build.gradle +++ b/build.gradle @@ -29,14 +29,6 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - //Querydsl 추가 - implementation 'com.querydsl:querydsl-core:5.0.0' - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' - - annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" - annotationProcessor "jakarta.annotation:jakarta.annotation-api" - annotationProcessor "jakarta.persistence:jakarta.persistence-api" - // mysql driver runtimeOnly 'com.mysql:mysql-connector-j' @@ -45,9 +37,18 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // jwt + implementation 'com.auth0:java-jwt:4.2.1' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + +// //Querydsl 추가 +// implementation 'com.querydsl:querydsl-core:5.0.0' +// implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' +// +// annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" +// annotationProcessor "jakarta.annotation:jakarta.annotation-api" +// annotationProcessor "jakarta.persistence:jakarta.persistence-api" } checkstyle { @@ -62,16 +63,16 @@ tasks.named('test') { } -def querydslSrcDir = 'src/main/generated' - -sourceSets { - main.java.srcDirs += "$projectDir/build/generated" -} - -compileJava { - options.compilerArgs << '-Aquerydsl.generatedAnnotationClass=javax.annotation.Generated' -} - -clean { - delete file(querydslSrcDir) -} +//def querydslSrcDir = 'src/main/generated' +// +//sourceSets { +// main.java.srcDirs += "$projectDir/build/generated" +//} +// +//compileJava { +// options.compilerArgs << '-Aquerydsl.generatedAnnotationClass=javax.annotation.Generated' +//} +// +//clean { +// delete file(querydslSrcDir) +//} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/BaseTimeEntity.java b/src/main/java/com/readyvery/readyverydemo/domain/BaseTimeEntity.java new file mode 100644 index 0000000..19d27ec --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/domain/BaseTimeEntity.java @@ -0,0 +1,28 @@ +package com.readyvery.readyverydemo.domain; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; + +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +@Getter +@Setter +public class BaseTimeEntity { + + @CreatedDate + @Column(name = "created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "last_modified_at") + private LocalDateTime lastModifiedAt; +} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/Role.java b/src/main/java/com/readyvery/readyverydemo/domain/Role.java new file mode 100644 index 0000000..bda60cb --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/domain/Role.java @@ -0,0 +1,14 @@ +package com.readyvery.readyverydemo.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + + GUEST("ROLE_GUEST"), USER("ROLE_USER"); + + private final String key; +} + diff --git a/src/main/java/com/readyvery/readyverydemo/domain/SocialType.java b/src/main/java/com/readyvery/readyverydemo/domain/SocialType.java new file mode 100644 index 0000000..4273104 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/domain/SocialType.java @@ -0,0 +1,6 @@ +package com.readyvery.readyverydemo.domain; + +public enum SocialType { + KAKAO, NAVER, GOOGLE +} + diff --git a/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java b/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java index bf41adc..e9b52ab 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java @@ -1,4 +1,56 @@ package com.readyvery.readyverydemo.domain; -public class UserInfo { +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Builder +@Table(name = "USERS") +@AllArgsConstructor +@Slf4j +public class UserInfo extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(nullable = false) + private String email; // 이메일 + + @Column(nullable = false) + private String nickName; // 닉네임 + + + private String imageUrl; // 프로필 이미지 + private int age; // 나이 + + @Enumerated(EnumType.STRING) + private Role role; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private SocialType socialType; // KAKAO, NAVER, GOOGLE + + @Column(nullable = false) + private String socialId; // 로그인한 소셜 타입의 식별자 값 (일반 로그인인 경우 null) + + private String refreshToken; // 리프레시 토큰 + + public void updateRefresh(String updateRefreshToken) { + this.refreshToken = updateRefreshToken; + } } diff --git a/src/main/java/com/readyvery/readyverydemo/domain/repository/UserRepository.java b/src/main/java/com/readyvery/readyverydemo/domain/repository/UserRepository.java new file mode 100644 index 0000000..60f5241 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/domain/repository/UserRepository.java @@ -0,0 +1,22 @@ +package com.readyvery.readyverydemo.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.readyvery.readyverydemo.domain.SocialType; +import com.readyvery.readyverydemo.domain.UserInfo; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + + Optional findByRefreshToken(String refreshToken); + + /** + * 소셜 타입과 소셜의 식별값으로 회원 찾는 메소드 + * 정보 제공을 동의한 순간 DB에 저장해야하지만, 아직 추가 정보(사는 도시, 나이 등)를 입력받지 않았으므로 + * 유저 객체는 DB에 있지만, 추가 정보가 빠진 상태이다. + * 따라서 추가 정보를 입력받아 회원 가입을 진행할 때 소셜 타입, 식별자로 해당 회원을 찾기 위한 메소드 + */ + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/config/SpringSecurityConfig.java b/src/main/java/com/readyvery/readyverydemo/security/config/SpringSecurityConfig.java index 3cf6e5a..da1597d 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/config/SpringSecurityConfig.java +++ b/src/main/java/com/readyvery/readyverydemo/security/config/SpringSecurityConfig.java @@ -2,28 +2,117 @@ import static org.springframework.security.config.Customizer.*; +import java.util.Arrays; + import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.readyvery.readyverydemo.domain.repository.UserRepository; +import com.readyvery.readyverydemo.security.exception.CustomAuthenticationEntryPoint; +import com.readyvery.readyverydemo.security.jwt.filter.JwtAuthenticationProcessingFilter; +import com.readyvery.readyverydemo.security.jwt.service.JwtService; +import com.readyvery.readyverydemo.security.oauth2.handler.OAuth2LoginFailureHandler; +import com.readyvery.readyverydemo.security.oauth2.handler.OAuth2LoginSuccessHandler; +import com.readyvery.readyverydemo.security.oauth2.service.CustomOAuth2UserService; + +import lombok.RequiredArgsConstructor; @Configuration +@RequiredArgsConstructor public class SpringSecurityConfig { + + private final JwtService jwtService; + private final UserRepository userRepository; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler; + private final CustomOAuth2UserService customOAuth2UserService; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.authorizeHttpRequests((authz) -> authz - //.requestMatchers("/api/v1/user").authenticated() // 해당 요청은 인증이 필요함 - .anyRequest().permitAll() // 위를 제외한 나머지는 모두 허용 + http + // [PART 1] + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - //.formLogin(withDefaults()) // 기본 로그인 페이지 사용 - .httpBasic(withDefaults()); + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + + // [PART 2] + //== URL별 권한 관리 옵션 ==// + .authorizeHttpRequests((authz) -> authz + .requestMatchers( + "/jwt-test", + "/oauth2/**", + "/login" + ).permitAll() // 해당 요청은 인증이 필요함 + .anyRequest().authenticated() // 위를 제외한 나머지는 모두 허용 + ) + // [PART 3] + //== 소셜 로그인 설정 ==// + .oauth2Login(oauth2 -> oauth2 + .successHandler(oAuth2LoginSuccessHandler) // 동의하고 계속하기를 눌렀을 때 Handler 설정 + .failureHandler(oAuth2LoginFailureHandler) // 소셜 로그인 실패 시 핸들러 설정 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService))) + + // Custom Exception Handling + .exceptionHandling(exceptionHandling -> + exceptionHandling.authenticationEntryPoint(customAuthenticationEntryPoint(new ObjectMapper())) + ); + + // [PART4] + // 원래 스프링 시큐리티 필터 순서가 LogoutFilter 이후에 로그인 필터 동작 + // 따라서, LogoutFilter 이후에 우리가 만든 필터 동작하도록 설정 + // 순서 : LogoutFilter -> JwtAuthenticationProcessingFilter -> CustomJsonUsernamePasswordAuthenticationFilter + http.addFilterBefore(jwtAuthenticationProcessingFilter(), LogoutFilter.class); + return http.build(); } + @Bean + public CustomAuthenticationEntryPoint customAuthenticationEntryPoint(ObjectMapper objectMapper) { + return new CustomAuthenticationEntryPoint(objectMapper); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } // 패스워드 인코더 + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("POST", "PATCH", "GET", "DELETE")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } // CORS 설정 + @Bean public WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations()); } // 정적 리소스 보안 필터 해제 + + @Bean + public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { + JwtAuthenticationProcessingFilter jwtAuthenticationFilter = new JwtAuthenticationProcessingFilter(jwtService, + userRepository); + return jwtAuthenticationFilter; + } } diff --git a/src/main/java/com/readyvery/readyverydemo/security/exception/CustomAuthenticationEntryPoint.java b/src/main/java/com/readyvery/readyverydemo/security/exception/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..577af97 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/exception/CustomAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.readyvery.readyverydemo.security.exception; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.readyvery.readyverydemo.security.exception.dto.ErrorResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Autowired + public CustomAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse("Access Denied", request.getRequestURI()); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} + diff --git a/src/main/java/com/readyvery/readyverydemo/security/exception/dto/ErrorResponse.java b/src/main/java/com/readyvery/readyverydemo/security/exception/dto/ErrorResponse.java new file mode 100644 index 0000000..68b1dfa --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/exception/dto/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.readyvery.readyverydemo.security.exception.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ErrorResponse { + private String message; + private String path; + +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java new file mode 100644 index 0000000..006c678 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java @@ -0,0 +1,146 @@ +package com.readyvery.readyverydemo.security.jwt.filter; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.domain.repository.UserRepository; +import com.readyvery.readyverydemo.security.jwt.service.JwtService; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { + + private static final String NO_CHECK_URL = "/login"; // "/login"으로 들어오는 요청은 Filter 작동 X + + private final JwtService jwtService; + private final UserRepository userRepository; + + private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (request.getRequestURI().equals(NO_CHECK_URL)) { + filterChain.doFilter(request, response); // "/login" 요청이 들어오면, 다음 필터 호출 + return; // return으로 이후 현재 필터 진행 막기 (안해주면 아래로 내려가서 계속 필터 진행시킴) + } + + // 사용자 요청 헤더에서 RefreshToken 추출 + // -> RefreshToken이 없거나 유효하지 않다면(DB에 저장된 RefreshToken과 다르다면) null을 반환 + // 사용자의 요청 헤더에 RefreshToken이 있는 경우는, AccessToken이 만료되어 요청한 경우밖에 없다. + // 따라서, 위의 경우를 제외하면 추출한 refreshToken은 모두 null + String refreshToken = jwtService.extractRefreshTokenFromCookies(request) + .filter(jwtService::isTokenValid) + .orElse(null); + + // 리프레시 토큰이 요청 헤더에 존재했다면, 사용자가 AccessToken이 만료되어서 + // RefreshToken까지 보낸 것이므로 리프레시 토큰이 DB의 리프레시 토큰과 일치하는지 판단 후, + // 일치한다면 AccessToken을 재발급해준다. + if (refreshToken != null) { + log.info("refreshToken이 존재합니다."); + checkRefreshTokenAndReIssueAccessToken(response, refreshToken); + return; // RefreshToken을 보낸 경우에는 AccessToken을 재발급 하고 인증 처리는 하지 않게 하기위해 바로 return으로 필터 진행 막기 + } + + // RefreshToken이 없거나 유효하지 않다면, AccessToken을 검사하고 인증을 처리하는 로직 수행 + // AccessToken이 없거나 유효하지 않다면, 인증 객체가 담기지 않은 상태로 다음 필터로 넘어가기 때문에 403 에러 발생 + // AccessToken이 유효하다면, 인증 객체가 담긴 상태로 다음 필터로 넘어가기 때문에 인증 성공 + if (refreshToken == null) { + checkAccessTokenAndAuthentication(request, response, filterChain); + } + } + + /** + * [리프레시 토큰으로 유저 정보 찾기 & 액세스 토큰/리프레시 토큰 재발급 메소드] + * 파라미터로 들어온 헤더에서 추출한 리프레시 토큰으로 DB에서 유저를 찾고, 해당 유저가 있다면 + * JwtService.createAccessToken()으로 AccessToken 생성, + * reIssueRefreshToken()로 리프레시 토큰 재발급 & DB에 리프레시 토큰 업데이트 메소드 호출 + * 그 후 JwtService.sendAccessTokenAndRefreshToken()으로 응답 헤더에 보내기 + */ + public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) { + userRepository.findByRefreshToken(refreshToken) + .ifPresent(user -> { + String reIssuedRefreshToken = reIssueRefreshToken(user); + jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()), + reIssuedRefreshToken); + }); + } + + /** + * [리프레시 토큰 재발급 & DB에 리프레시 토큰 업데이트 메소드] + * jwtService.createRefreshToken()으로 리프레시 토큰 재발급 후 + * DB에 재발급한 리프레시 토큰 업데이트 후 Flush + */ + private String reIssueRefreshToken(UserInfo userInfo) { + String reIssuedRefreshToken = jwtService.createRefreshToken(); + userInfo.updateRefresh(reIssuedRefreshToken); + userRepository.saveAndFlush(userInfo); + return reIssuedRefreshToken; + } + + /** + * [액세스 토큰 체크 & 인증 처리 메소드] + * request에서 extractAccessToken()으로 액세스 토큰 추출 후, isTokenValid()로 유효한 토큰인지 검증 + * 유효한 토큰이면, 액세스 토큰에서 extractEmail로 Email을 추출한 후 findByEmail()로 해당 이메일을 사용하는 유저 객체 반환 + * 그 유저 객체를 saveAuthentication()으로 인증 처리하여 + * 인증 허가 처리된 객체를 SecurityContextHolder에 담기 + * 그 후 다음 인증 필터로 진행 + */ + public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + log.info("checkAccessTokenAndAuthentication() 호출"); + jwtService.extractAccessTokenFromCookies(request) + .filter(jwtService::isTokenValid) + .ifPresent(accessToken -> jwtService.extractEmail(accessToken) + .ifPresent(email -> userRepository.findByEmail(email) + .ifPresent(this::saveAuthentication))); + + filterChain.doFilter(request, response); + } + + /** + * [인증 허가 메소드] + * 파라미터의 유저 : 우리가 만든 회원 객체 / 빌더의 유저 : UserDetails의 User 객체 + * + * new UsernamePasswordAuthenticationToken()로 인증 객체인 Authentication 객체 생성 + * UsernamePasswordAuthenticationToken의 파라미터 + * 1. 위에서 만든 UserDetailsUser 객체 (유저 정보) + * 2. credential(보통 비밀번호로, 인증 시에는 보통 null로 제거) + * 3. Collection < ? extends GrantedAuthority>로, + * UserDetails의 User 객체 안에 Set authorities이 있어서 getter로 호출한 후에, + * new NullAuthoritiesMapper()로 GrantedAuthoritiesMapper 객체를 생성하고 mapAuthorities()에 담기 + * + * SecurityContextHolder.getContext()로 SecurityContext를 꺼낸 후, + * setAuthentication()을 이용하여 위에서 만든 Authentication 객체에 대한 인증 허가 처리 + */ + public void saveAuthentication(UserInfo myUser) { + + UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder() + .username(myUser.getEmail()) + .password("readyvery") + .roles(myUser.getRole().name()) + .build(); + + Authentication authentication = + new UsernamePasswordAuthenticationToken(userDetailsUser, null, + authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtService.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtService.java new file mode 100644 index 0000000..8936789 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtService.java @@ -0,0 +1,239 @@ +package com.readyvery.readyverydemo.security.jwt.service; + +import java.util.Arrays; +import java.util.Date; +import java.util.NoSuchElementException; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.domain.repository.UserRepository; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Getter +@Slf4j +public class JwtService { + + @Value("${jwt.secretKey}") + private String secretKey; + + @Value("${jwt.access.expiration}") + private Long accessTokenExpirationPeriod; + + @Value("${jwt.refresh.expiration}") + private Long refreshTokenExpirationPeriod; + + @Value("${jwt.access.cookie}") + private String accessCookie; + + @Value("${jwt.refresh.cookie}") + private String refreshCookie; + + /** + * JWT의 Subject와 Claim으로 email 사용 -> 클레임의 name을 "email"으로 설정 + * JWT의 헤더에 들어오는 값 : 'Authorization(Key) = Bearer {토큰} (Value)' 형식 + */ + private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; + private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; + private static final String EMAIL_CLAIM = "email"; + //private static final String BEARER = "Bearer "; + private static final String USER_NUMBER = "userNumber"; + + private final UserRepository userRepository; + + /** + * AccessToken 생성 메소드 + */ + public String createAccessToken(String email) { + UserInfo userInfo = userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("이메일에 해당하는 유저가 없습니다.")); + Date now = new Date(); + return JWT.create() // JWT 토큰을 생성하는 빌더 반환 + .withSubject(ACCESS_TOKEN_SUBJECT) // JWT의 Subject 지정 -> AccessToken이므로 AccessToken + .withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) // 토큰 만료 시간 설정 + + //클레임으로는 저희는 email 하나만 사용합니다. + //추가적으로 식별자나, 이름 등의 정보를 더 추가하셔도 됩니다. + //추가하실 경우 .withClaim(클래임 이름, 클래임 값) 으로 설정해주시면 됩니다 + .withClaim(EMAIL_CLAIM, email) + .withClaim(USER_NUMBER, userInfo.getId()) + .sign(Algorithm.HMAC512(secretKey)); // HMAC512 알고리즘 사용, application-jwt.yml에서 지정한 secret 키로 암호화 + } + + /** + * RefreshToken 생성 + * RefreshToken은 Claim에 email도 넣지 않으므로 withClaim() X + */ + public String createRefreshToken() { + Date now = new Date(); + return JWT.create() + .withSubject(REFRESH_TOKEN_SUBJECT) + .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod)) + .sign(Algorithm.HMAC512(secretKey)); + } + + // /** + // * AccessToken 헤더에 실어서 보내기 + // */ + // public void sendAccessToken(HttpServletResponse response, String accessToken) { + // response.setStatus(HttpServletResponse.SC_OK); + // + // response.setHeader(accessHeader, accessToken); + // log.info("재발급된 Access Token : {}", accessToken); + // } + + /** + * AccessToken + RefreshToken 헤더에 실어서 보내기 + */ + public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) { + + response.setStatus(HttpServletResponse.SC_OK); + + setAccessTokenCookie(response, accessToken); + setRefreshTokenCookie(response, refreshToken); + log.info("Access Token, Refresh Token 헤더 설정 완료"); + } + + // /** + // * 헤더에서 RefreshToken 추출 + // * 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서 + // * 헤더를 가져온 후 "Bearer"를 삭제(""로 replace) + // */ + // public Optional extractRefreshToken(HttpServletRequest request) { + // return Optional.ofNullable(request.getHeader(refreshHeader)) + // .filter(refreshToken -> refreshToken.startsWith(BEARER)) + // .map(refreshToken -> refreshToken.replace(BEARER, "")); + // } + + /** + * 헤더에서 AccessToken 추출 + * 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서 + * 헤더를 가져온 후 "Bearer"를 삭제(""로 replace) + */ + // public Optional extractAccessToken(HttpServletRequest request) { + // return Optional.ofNullable(request.getHeader(accessHeader)) + // .filter(refreshToken -> refreshToken.startsWith(BEARER)) + // .map(refreshToken -> refreshToken.replace(BEARER, "")); + // } + + /** + * 쿠키에서 RefreshToken 추출 + */ + public Optional extractRefreshTokenFromCookies(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + return Arrays.stream(cookies) + .filter(cookie -> refreshCookie.equals(cookie.getName())) // 올바른 필터링 조건 + .findFirst() + .map(Cookie::getValue); + } + return Optional.empty(); + } + + /** + * 쿠키에서 AccessToken 추출 + */ + public Optional extractAccessTokenFromCookies(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + return Arrays.stream(cookies) + .filter(cookie -> accessCookie.equals(cookie.getName())) // 올바른 필터링 조건 + .findFirst() + .map(Cookie::getValue); + } + return Optional.empty(); + } + + /** + * AccessToken에서 Email 추출 + * 추출 전에 JWT.require()로 검증기 생성 + * verify로 AceessToken 검증 후 + * 유효하다면 getClaim()으로 이메일 추출 + * 유효하지 않다면 빈 Optional 객체 반환 + */ + public Optional extractEmail(String accessToken) { + try { + // 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환 + return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey)) + .build() // 반환된 빌더로 JWT verifier 생성 + .verify(accessToken) // accessToken을 검증하고 유효하지 않다면 예외 발생 + .getClaim(EMAIL_CLAIM) // claim(Emial) 가져오기 + .asString()); + } catch (Exception e) { + log.error("액세스 토큰이 유효하지 않습니다."); + return Optional.empty(); + } + } + + /** + * AccessToken 헤더 설정 + */ + public void setAccessTokenCookie(HttpServletResponse response, String accessToken) { + Cookie accessTokenCookie = new Cookie(accessCookie, accessToken); // 쿠키 생성 + accessTokenCookie.setHttpOnly(true); // JavaScript가 쿠키를 읽는 것을 방지 + accessTokenCookie.setPath("/"); // 쿠키 경로 설정 + + // 필요한 경우 Secure 플래그 설정 (HTTPS에서만 쿠키 전송) + //accessTokenCookie.setSecure(true); + + // 필요한 경우 동일한 사이트 속성 설정 (쿠키 전송에 대한 제한) + // accessTokenCookie.setSameSite("Strict"); + + // 쿠키 만료 시간 설정 (예: 액세스 토큰 만료 시간과 같게 설정) + accessTokenCookie.setMaxAge(accessTokenExpirationPeriod.intValue()); // 초 단위로 설정 + response.addCookie(accessTokenCookie); // 응답에 쿠키 추가 + } + + /** + * RefreshToken 헤더 설정 + */ + public void setRefreshTokenCookie(HttpServletResponse response, String accessToken) { + Cookie refreshTokenCookie = new Cookie(refreshCookie, accessToken); // 쿠키 생성 + refreshTokenCookie.setHttpOnly(true); // JavaScript가 쿠키를 읽는 것을 방지 + refreshTokenCookie.setPath("/api/v1/refresh/token"); // 쿠키 경로 설정 + + // 필요한 경우 Secure 플래그 설정 (HTTPS에서만 쿠키 전송) + // accessTokenCookie.setSecure(true); + + // 필요한 경우 동일한 사이트 속성 설정 (쿠키 전송에 대한 제한) + // accessTokenCookie.setSameSite("Strict"); + + // 쿠키 만료 시간 설정 (예: 액세스 토큰 만료 시간과 같게 설정) + refreshTokenCookie.setMaxAge(accessTokenExpirationPeriod.intValue()); // 초 단위로 설정 + response.addCookie(refreshTokenCookie); // 응답에 쿠키 추가 + } + + /** + * RefreshToken DB 저장(업데이트) + */ + + public void updateRefreshToken(String email, String refreshToken) { + UserInfo user = userRepository.findByEmail(email) + .orElseThrow(() -> new NoSuchElementException("일치하는 회원이 없습니다.")); + + user.updateRefresh(refreshToken); + userRepository.save(user); + } + + public boolean isTokenValid(String token) { + try { + JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token); + return true; + } catch (Exception e) { + log.error("유효하지 않은 토큰입니다. {}", e.getMessage()); + return false; + } + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/CustomOAuth2User.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/CustomOAuth2User.java new file mode 100644 index 0000000..2d0eb46 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/CustomOAuth2User.java @@ -0,0 +1,38 @@ +package com.readyvery.readyverydemo.security.oauth2; + +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import com.readyvery.readyverydemo.domain.Role; + +import lombok.Getter; + +/** + * DefaultOAuth2User를 상속하고, email과 role 필드를 추가로 가진다. + */ +@Getter +public class CustomOAuth2User extends DefaultOAuth2User { + + private String email; + private Role role; + + /** + * Constructs a {@code DefaultOAuth2User} using the provided parameters. + * + * @param authorities the authorities granted to the user + * @param attributes the attributes about the user + * @param nameAttributeKey the key used to access the user's "name" from + * {@link #getAttributes()} + */ + public CustomOAuth2User(Collection authorities, + Map attributes, String nameAttributeKey, + String email, Role role) { + super(authorities, attributes, nameAttributeKey); + this.email = email; + this.role = role; + } +} + diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/OAuthAttributes.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/OAuthAttributes.java new file mode 100644 index 0000000..6f6b70b --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/OAuthAttributes.java @@ -0,0 +1,66 @@ +package com.readyvery.readyverydemo.security.oauth2; + +import java.util.Map; + +import com.readyvery.readyverydemo.domain.Role; +import com.readyvery.readyverydemo.domain.SocialType; +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.security.oauth2.userinfo.KakaoOAuth2UserInfo; +import com.readyvery.readyverydemo.security.oauth2.userinfo.OAuth2UserInfo; + +import lombok.Builder; +import lombok.Getter; + +/** + * 각 소셜에서 받아오는 데이터가 다르므로 + * 소셜별로 데이터를 받는 데이터를 분기 처리하는 DTO 클래스 + */ +@Getter +public class OAuthAttributes { + + private String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드 값, PK와 같은 의미 + private OAuth2UserInfo oauth2UserInfo; // 소셜 타입별 로그인 유저 정보(닉네임, 이메일, 프로필 사진 등등) + + @Builder + public OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oauth2UserInfo) { + this.nameAttributeKey = nameAttributeKey; + this.oauth2UserInfo = oauth2UserInfo; + } + + /** + * SocialType에 맞는 메소드 호출하여 OAuthAttributes 객체 반환 + * 파라미터 : userNameAttributeName -> OAuth2 로그인 시 키(PK)가 되는 값 / attributes : OAuth 서비스의 유저 정보들 + * 소셜별 of 메소드(ofGoogle, ofKaKao, ofNaver)들은 각각 소셜 로그인 API에서 제공하는 + * 회원의 식별값(id), attributes, nameAttributeKey를 저장 후 build + */ + public static OAuthAttributes of(SocialType socialType, + String userNameAttributeName, Map attributes) { + + return ofKakao(userNameAttributeName, attributes); + + } + + private static OAuthAttributes ofKakao(String userNameAttributeName, Map attributes) { + return OAuthAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oauth2UserInfo(new KakaoOAuth2UserInfo(attributes)) + .build(); + } + + /** + * of메소드로 OAuthAttributes 객체가 생성되어, 유저 정보들이 담긴 OAuth2UserInfo가 소셜 타입별로 주입된 상태 + * OAuth2UserInfo에서 socialId(식별값), nickname, imageUrl을 가져와서 build + * email에는 UUID로 중복 없는 랜덤 값 생성 + * role은 USER로 설정 + */ + public UserInfo toEntity(SocialType socialType, OAuth2UserInfo oauth2UserInfo) { + return UserInfo.builder() + .socialType(socialType) + .socialId(oauth2UserInfo.getId()) + .email(oauth2UserInfo.getEmail()) + .nickName(oauth2UserInfo.getNickName()) + .imageUrl(oauth2UserInfo.getImageUrl()) + .role(Role.USER) + .build(); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/handler/OAuth2LoginFailureHandler.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/handler/OAuth2LoginFailureHandler.java new file mode 100644 index 0000000..dd747ff --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/handler/OAuth2LoginFailureHandler.java @@ -0,0 +1,26 @@ +package com.readyvery.readyverydemo.security.oauth2.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, + ServletException { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("소셜 로그인 실패! 서버 로그를 확인해주세요."); + log.info("소셜 로그인에 실패했습니다. 에러 메시지 : {}", exception.getMessage()); + } +} + diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/handler/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..face0cc --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/handler/OAuth2LoginSuccessHandler.java @@ -0,0 +1,66 @@ +package com.readyvery.readyverydemo.security.oauth2.handler; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.readyvery.readyverydemo.domain.repository.UserRepository; +import com.readyvery.readyverydemo.security.jwt.service.JwtService; +import com.readyvery.readyverydemo.security.oauth2.CustomOAuth2User; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtService jwtService; + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws + IOException, ServletException { + log.info("OAuth2 Login 성공!"); + try { + CustomOAuth2User oAuth2User = (CustomOAuth2User)authentication.getPrincipal(); + + loginSuccess(response, oAuth2User); // 로그인에 성공한 경우 access, refresh 토큰 생성 + log.info("OAuth2 Login 성공! - 로그인 성공 후 리다이렉트"); + response.sendRedirect("http://localhost:3000/login"); + } catch (Exception e) { + throw e; + } + + } + + // TODO : 소셜 로그인 시에도 무조건 토큰 생성하지 말고 JWT 인증 필터처럼 RefreshToken 유/무에 따라 다르게 처리해보기 + // private void loginSuccess(HttpServletResponse response, CustomOAuth2User oAuth2User) throws IOException { + // String accessToken = jwtService.createAccessToken(oAuth2User.getEmail()); + // String refreshToken = jwtService.createRefreshToken(); + // response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken); + // response.addHeader(jwtService.getRefreshHeader(), "Bearer " + refreshToken); + // + // jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); + // jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken); + // } + + private void loginSuccess(HttpServletResponse response, CustomOAuth2User oAuth2User) throws IOException { + String accessToken = jwtService.createAccessToken(oAuth2User.getEmail()); + String refreshToken = jwtService.createRefreshToken(); + + jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); + jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken); + + // Redirect to the sign-up page + //response.sendRedirect("http://localhost:3000/login"); + } + +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/service/CustomOAuth2UserService.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..d8fe15b --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/service/CustomOAuth2UserService.java @@ -0,0 +1,97 @@ +package com.readyvery.readyverydemo.security.oauth2.service; + +import java.util.Collections; +import java.util.Map; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import com.readyvery.readyverydemo.domain.SocialType; +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.domain.repository.UserRepository; +import com.readyvery.readyverydemo.security.oauth2.CustomOAuth2User; +import com.readyvery.readyverydemo.security.oauth2.OAuthAttributes; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final UserRepository userRepository; + + private static final String KAKAO = "kakao"; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + log.info("CustomOAuth2UserService.loadUser() 실행 - OAuth2 로그인 요청 진입"); + + /** + * DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해 DefaultOAuth2User 객체를 생성 후 반환 + * DefaultOAuth2UserService의 loadUser()는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서 + * 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다. + * 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저 + */ + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + /** + * userRequest에서 registrationId 추출 후 registrationId으로 SocialType 저장 + * http://localhost:8080/oauth2/authorization/kakao에서 kakao가 registrationId + * userNameAttributeName은 이후에 nameAttributeKey로 설정된다. + */ + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + SocialType socialType = getSocialType(registrationId); + String userNameAttributeName = userRequest.getClientRegistration() + .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는 값 + Map attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json 값(유저 정보들) + + // socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성 + OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes); + + UserInfo createdUser = getUser(extractAttributes, socialType); // getUser() 메소드로 User 객체 생성 후 반환 + + // DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환 + return new CustomOAuth2User( + Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())), + attributes, + extractAttributes.getNameAttributeKey(), + createdUser.getEmail(), + createdUser.getRole() + ); + } + + private SocialType getSocialType(String registrationId) { + return SocialType.KAKAO; + } + + /** + * SocialType과 attributes에 들어있는 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드 + * 만약 찾은 회원이 있다면, 그대로 반환하고 없다면 saveUser()를 호출하여 회원을 저장한다. + */ + private UserInfo getUser(OAuthAttributes attributes, SocialType socialType) { + UserInfo findUser = userRepository.findBySocialTypeAndSocialId(socialType, + attributes.getOauth2UserInfo().getId()).orElse(null); + + if (findUser == null) { + return saveUser(attributes, socialType); + } + return findUser; + } + + /** + * OAuthAttributes의 toEntity() 메소드를 통해 빌더로 User 객체 생성 후 반환 + * 생성된 User 객체를 DB에 저장 : socialType, socialId, email, role 값만 있는 상태 + */ + private UserInfo saveUser(OAuthAttributes attributes, SocialType socialType) { + UserInfo createdUser = attributes.toEntity(socialType, attributes.getOauth2UserInfo()); + return userRepository.save(createdUser); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/KakaoOAuth2UserInfo.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/KakaoOAuth2UserInfo.java new file mode 100644 index 0000000..0db360c --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/KakaoOAuth2UserInfo.java @@ -0,0 +1,50 @@ +package com.readyvery.readyverydemo.security.oauth2.userinfo; + +import java.util.Map; + +public class KakaoOAuth2UserInfo extends OAuth2UserInfo { + + public KakaoOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return String.valueOf(attributes.get("id")); + } + + @Override + public String getNickName() { + Map account = (Map)attributes.get("kakao_account"); + Map profile = (Map)account.get("profile"); + + if (account == null || profile == null) { + return null; + } + + return (String)profile.get("nickname"); + } + + @Override + public String getImageUrl() { + Map account = (Map)attributes.get("kakao_account"); + Map profile = (Map)account.get("profile"); + + if (account == null || profile == null) { + return null; + } + + return (String)profile.get("thumbnail_image_url"); + } + + @Override + public String getEmail() { + Map account = (Map)attributes.get("kakao_account"); + + if (account == null) { + return null; + } + + return (String)account.get("email"); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/OAuth2UserInfo.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/OAuth2UserInfo.java new file mode 100644 index 0000000..a3ac889 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/OAuth2UserInfo.java @@ -0,0 +1,20 @@ +package com.readyvery.readyverydemo.security.oauth2.userinfo; + +import java.util.Map; + +public abstract class OAuth2UserInfo { + + protected Map attributes; + + public OAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public abstract String getId(); //소셜 식별 값 : 구글 - "sub", 카카오 - "id", 네이버 - "id" + + public abstract String getNickName(); + + public abstract String getImageUrl(); + + public abstract String getEmail(); +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/UserController.java b/src/main/java/com/readyvery/readyverydemo/src/user/UserController.java index a2dd83d..b46ee2f 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/user/UserController.java +++ b/src/main/java/com/readyvery/readyverydemo/src/user/UserController.java @@ -1,4 +1,47 @@ package com.readyvery.readyverydemo.src.user; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.readyvery.readyverydemo.src.user.dto.UserAuthRes; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") public class UserController { + + private final UserServiceImpl userServiceImpl; + + @GetMapping("/jwt-test") + public String jwtTest() { + return "jwtTest 요청 성공"; + } + + /** + * 사용자 정보 조회 + * 인증체크 후 사용자 정보를 반환합니다. + * @param userDetails + * @return + */ + @GetMapping("/auth") + public UserAuthRes userInfo(@AuthenticationPrincipal UserDetails userDetails) { + // 서비스 계층을 호출하여 사용자 정보를 조회합니다. + return userServiceImpl.getUserAuthByEmail(userDetails.getUsername()); + } + + /** + * Access 토큰 재발급 + * + * @return + */ + @GetMapping("/refresh/token") + public boolean refreshEndpoint() { + return true; + } + } diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/UserService.java b/src/main/java/com/readyvery/readyverydemo/src/user/UserService.java index cd71db4..e3523d1 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/user/UserService.java +++ b/src/main/java/com/readyvery/readyverydemo/src/user/UserService.java @@ -1,4 +1,9 @@ package com.readyvery.readyverydemo.src.user; -public class UserService { +import com.readyvery.readyverydemo.src.user.dto.UserAuthRes; + +public interface UserService { + // 기존 메서드들... + + UserAuthRes getUserAuthByEmail(String email); } diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceImpl.java new file mode 100644 index 0000000..d82245f --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceImpl.java @@ -0,0 +1,46 @@ +package com.readyvery.readyverydemo.src.user; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.domain.repository.UserRepository; +import com.readyvery.readyverydemo.src.user.dto.UserAuthRes; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + + // 기존 구현들... + + @Override + public UserAuthRes getUserAuthByEmail(String email) { + if (email == null || email.isEmpty()) { + throw new IllegalArgumentException("Email must not be null or empty"); + } + + Optional userInfoOptional = userRepository.findByEmail(email); + + if (userInfoOptional.isEmpty()) { + throw new RuntimeException("User not found with email: " + email); + } + + UserInfo userInfo = userInfoOptional.get(); + + return UserAuthRes.builder() + .id(userInfo.getId()) + .email(userInfo.getEmail()) + .name(userInfo.getNickName()) + .build(); + } +} + + + diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserAuthRes.java b/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserAuthRes.java new file mode 100644 index 0000000..cfa9b4c --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserAuthRes.java @@ -0,0 +1,14 @@ +package com.readyvery.readyverydemo.src.user.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserAuthRes { + private Long id; + private String email; + private String name; + +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0205d74..bd6a215 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,3 +3,21 @@ spring.datasource.username=root spring.datasource.password=12345678 spring.jpa.hibernate.ddl-auto=create spring.jackson.serialization.fail-on-empty-beans=false +# JWT Configuration +jwt.secretKey=Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw +jwt.access.expiration=86400 +jwt.access.cookie=accessToken +jwt.refresh.expiration=604800 +jwt.refresh.cookie=refreshToken +# Kakao +spring.security.oauth2.client.registration.kakao.client-id=e35c097c37d24f762290ec7e9e81887b +spring.security.oauth2.client.registration.kakao.client-secret=bMT73UM5sM7xfB5yulXMtsxPtjmsJfGS +spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao +spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post +spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.kakao.scope=profile_nickname, profile_image, account_email +spring.security.oauth2.client.registration.kakao.client-name=Kakao +spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token +spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me +spring.security.oauth2.client.provider.kakao.user-name-attribute=id