diff --git a/build.gradle b/build.gradle index 3ac30b2..616a590 100644 --- a/build.gradle +++ b/build.gradle @@ -36,9 +36,14 @@ dependencies { implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.1.0' testImplementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-api', version: '2.1.0' - implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' - runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' - runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + //jwt + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.3' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.3' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.3' + + //jwk + implementation 'com.auth0:java-jwt:3.18.2' + implementation 'com.auth0:jwks-rsa:0.20.0' // DB (MySQL) @@ -54,13 +59,26 @@ dependencies { implementation 'com.amazonaws:aws-java-sdk-s3' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'org.projectlombok:lombok:1.18.28' + annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' } tasks.named('test') { useJUnitPlatform() } +ext { + set('springCloudVersion', "2023.0.4") +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.4" + } +} \ No newline at end of file diff --git a/src/main/java/com/movelog/MoveLogApplication.java b/src/main/java/com/movelog/MoveLogApplication.java index 2fb815b..dfd98d6 100644 --- a/src/main/java/com/movelog/MoveLogApplication.java +++ b/src/main/java/com/movelog/MoveLogApplication.java @@ -3,11 +3,13 @@ import com.movelog.global.config.YamlPropertySourceFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.PropertySource; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication @EnableJpaAuditing +@EnableFeignClients @PropertySource(value = { "classpath:oauth2/application-oauth2.yml" }, factory = YamlPropertySourceFactory.class) @PropertySource(value = { "classpath:database/application-database.yml" }, factory = YamlPropertySourceFactory.class) //@PropertySource(value = { "classpath:s3/application-s3.yml" }, factory = YamlPropertySourceFactory.class) diff --git a/src/main/java/com/movelog/domain/auth/application/AuthService.java b/src/main/java/com/movelog/domain/auth/application/AuthService.java new file mode 100644 index 0000000..850157b --- /dev/null +++ b/src/main/java/com/movelog/domain/auth/application/AuthService.java @@ -0,0 +1,61 @@ +package com.movelog.domain.auth.application; + +import com.movelog.domain.auth.dto.NicknameRes; +import com.movelog.domain.user.domain.User; +import com.movelog.domain.user.domain.repository.UserRepository; +import com.movelog.global.config.security.token.UserPrincipal; +import com.movelog.global.payload.Message; +import com.auth0.jwt.JWT; +import com.auth0.jwt.interfaces.DecodedJWT; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final UserRepository userRepository; + + @Transactional + public User findOrCreateUser(String provider, String idToken) { + + DecodedJWT decodedJWT = JWT.decode(idToken); + String providerId = decodedJWT.getSubject(); // 사용자 고유 ID (sub) + String email = decodedJWT.getClaim("email").asString(); + String username = decodedJWT.getClaim("nickname").asString(); + + Optional optionalUser = userRepository.findByProviderAndProviderId(provider, providerId); + + if (optionalUser.isPresent()) { + return optionalUser.get(); + } else { + User newUser = new User("", username, email, "ROLE_USER", provider, providerId); + return userRepository.save(newUser); + } + } + + + @Transactional + public String findEmail(String providerId) { + User user = userRepository.findByProviderId(providerId) + .orElseThrow(EntityNotFoundException::new); + return user.getEmail(); + } + + + + @Transactional + public Message unlinkAccount(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(EntityNotFoundException::new); + userRepository.delete(user); + return Message.builder() + .message("회원 탈퇴에 성공 했습니다.") + .build(); + } +} diff --git a/src/main/java/com/movelog/domain/auth/application/CustomUserDetailsService.java b/src/main/java/com/movelog/domain/auth/application/CustomUserDetailsService.java new file mode 100644 index 0000000..daa7d49 --- /dev/null +++ b/src/main/java/com/movelog/domain/auth/application/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package com.movelog.domain.auth.application; + +import com.movelog.domain.user.domain.User; +import com.movelog.domain.user.domain.repository.UserRepository; +import com.movelog.global.config.security.token.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.data.crossstore.ChangeSetPersister; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + //이메일로 커스텀 + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(RuntimeException::new); + + return UserPrincipal.createUser(user); + } +} diff --git a/src/main/java/com/movelog/domain/auth/dto/AuthRes.java b/src/main/java/com/movelog/domain/auth/dto/AuthRes.java new file mode 100644 index 0000000..2e1f81f --- /dev/null +++ b/src/main/java/com/movelog/domain/auth/dto/AuthRes.java @@ -0,0 +1,30 @@ +package com.movelog.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record AuthRes( + @Schema(type = "string", example = "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2NTI3OTgxOTh9.6CoxHB_siOuz6PxsxHYQCgUT1_QbdyKTUwStQDutEd1-cIIARbQ0cyrnAmpIgi3IBoLRaqK7N1vXO42nYy4g5g", description = "access token 을 출력합니다.") + String accessToken, + + @Schema( type = "string", example ="Bearer", description="권한(Authorization) 값 해더의 명칭을 지정합니다.") + String tokenType, + + @Schema( type = "Role", example = "USER", description = "Role을 출력합니다.") + String role, + + @Schema( type = "boolean", example = "false", description = "회원가입 완료 여부를 출력합니다.") + boolean isRegistered +) { + + @Builder + public AuthRes { + if (tokenType == null) { + tokenType = "Bearer"; + } + if (role == null) { + role = "ROLE_USER"; + } + } +} diff --git a/src/main/java/com/movelog/domain/auth/dto/CustomOAuth2User.java b/src/main/java/com/movelog/domain/auth/dto/CustomOAuth2User.java new file mode 100644 index 0000000..0008fad --- /dev/null +++ b/src/main/java/com/movelog/domain/auth/dto/CustomOAuth2User.java @@ -0,0 +1,39 @@ +package com.movelog.domain.auth.dto; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +public record CustomOAuth2User( + UserDTO userDTO +) implements OAuth2User { + @Override + public Map getAttributes() { + return null; + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return userDTO.role(); + } + }); + return collection; + } + + @Override + public String getName() { + return null; + } + + public String getUsername() { + return userDTO.username(); + } +} diff --git a/src/main/java/com/movelog/domain/auth/dto/IdTokenReq.java b/src/main/java/com/movelog/domain/auth/dto/IdTokenReq.java new file mode 100644 index 0000000..8d95f8b --- /dev/null +++ b/src/main/java/com/movelog/domain/auth/dto/IdTokenReq.java @@ -0,0 +1,11 @@ +package com.movelog.domain.auth.dto; + +import lombok.Builder; +import lombok.Getter; + +@Builder +public record IdTokenReq( + String idToken, + String provider +) { +} \ No newline at end of file diff --git a/src/main/java/com/movelog/domain/auth/dto/NicknameRes.java b/src/main/java/com/movelog/domain/auth/dto/NicknameRes.java new file mode 100644 index 0000000..4860ec9 --- /dev/null +++ b/src/main/java/com/movelog/domain/auth/dto/NicknameRes.java @@ -0,0 +1,11 @@ +package com.movelog.domain.auth.dto; + +import lombok.Builder; + +@Builder +public record NicknameRes( + String nickname, + Long userid, + boolean isRegistered +) { +} diff --git a/src/main/java/com/movelog/domain/auth/dto/UserDTO.java b/src/main/java/com/movelog/domain/auth/dto/UserDTO.java new file mode 100644 index 0000000..d99be80 --- /dev/null +++ b/src/main/java/com/movelog/domain/auth/dto/UserDTO.java @@ -0,0 +1,11 @@ +package com.movelog.domain.auth.dto; + +import lombok.Builder; + +@Builder +public record UserDTO( + String role, + String name, + String username +) { +} diff --git a/src/main/java/com/movelog/domain/auth/presentation/AuthController.java b/src/main/java/com/movelog/domain/auth/presentation/AuthController.java new file mode 100644 index 0000000..57cca53 --- /dev/null +++ b/src/main/java/com/movelog/domain/auth/presentation/AuthController.java @@ -0,0 +1,87 @@ +package com.movelog.domain.auth.presentation; + +import com.movelog.domain.auth.dto.AuthRes; +import com.movelog.domain.auth.dto.IdTokenReq; +import com.movelog.domain.auth.application.AuthService; +import com.movelog.domain.auth.dto.NicknameRes; +import com.movelog.domain.user.domain.User; +import com.movelog.domain.user.domain.repository.UserRepository; +import com.movelog.global.config.security.jwt.JWTUtil; +import com.movelog.global.config.security.oidc.OidcProviderFactory; +import com.movelog.global.config.security.oidc.Provider; +import com.movelog.global.config.security.token.CurrentUser; +import com.movelog.global.config.security.token.UserPrincipal; +import com.movelog.global.payload.ErrorResponse; +import com.movelog.global.payload.Message; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Authorization", description = "Authorization API") +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final OidcProviderFactory oidcProviderFactory; + private final JWTUtil jwtUtil; + private final UserRepository userRepository; + + + private final AuthService authService; + + + @Operation(summary = "소셜 로그인", description = "idToken과 provider(ex. kakao)로 소셜 로그인을 수행합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = AuthRes.class)))}), + @ApiResponse(responseCode = "400", description = "로그인 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), + }) + @PostMapping("/login") + public ResponseEntity login(@RequestBody IdTokenReq idTokenReq) { + System.out.println("idTokenReq = " + idTokenReq.idToken()); + String providerId = oidcProviderFactory.getProviderId( + Provider.valueOf(idTokenReq.provider().toUpperCase()), idTokenReq.idToken()); + + if (providerId == null) { + return ResponseEntity.badRequest().build(); + } + + authService.findOrCreateUser(idTokenReq.provider(), idTokenReq.idToken()); + String email = authService.findEmail(providerId); + + String accessToken = jwtUtil.createJwt("access", providerId, "ROLE_USER", 3600000L, email); + + User user = userRepository.findByProviderId(providerId) + .orElseThrow(EntityNotFoundException::new); + + + AuthRes authRes = AuthRes.builder() + .accessToken(accessToken) + .isRegistered(user.isRegistered()) + .build(); + + return ResponseEntity.ok(authRes); + } + + + @Operation(summary = "회원 탈퇴", description = "해당 유저의 가입을 탈퇴합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공", content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = Message.class)))}), + @ApiResponse(responseCode = "400", description = "회원 탈퇴 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), + }) + @DeleteMapping() + public ResponseEntity unlinkSocialAccount( + @Parameter(description = "Accesstoken을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal + ) { + return ResponseEntity.ok(authService.unlinkAccount(userPrincipal.getId())); + } +} diff --git a/src/main/java/com/movelog/domain/common/BaseEntity.java b/src/main/java/com/movelog/domain/common/BaseEntity.java new file mode 100644 index 0000000..293195a --- /dev/null +++ b/src/main/java/com/movelog/domain/common/BaseEntity.java @@ -0,0 +1,30 @@ +package com.movelog.domain.common; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + @CreatedDate + @Column(name = "created_at", updatable=false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + +// @Enumerated(value = EnumType.STRING) +// @Column(name = "status") +// private Status status = Status.ACTIVE; +// +// public void updateStatus(Status status) { +// this.status = status; +// } +} diff --git a/src/main/java/com/movelog/domain/user/application/UserService.java b/src/main/java/com/movelog/domain/user/application/UserService.java new file mode 100644 index 0000000..1cdd3be --- /dev/null +++ b/src/main/java/com/movelog/domain/user/application/UserService.java @@ -0,0 +1,19 @@ +package com.movelog.domain.user.application; + +import com.movelog.domain.user.domain.repository.UserRepository; +import com.movelog.global.payload.Message; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + +} diff --git a/src/main/java/com/movelog/domain/user/domain/User.java b/src/main/java/com/movelog/domain/user/domain/User.java new file mode 100644 index 0000000..9b317ac --- /dev/null +++ b/src/main/java/com/movelog/domain/user/domain/User.java @@ -0,0 +1,52 @@ +package com.movelog.domain.user.domain; + + +import com.movelog.domain.common.BaseEntity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", updatable = false) + private Long id; + + private String nickname; + + private String username; + + private String email; + + private String role; + + private String provider; + + private String providerId; + + private boolean isRegistered; + + private String fcmToken; + + + @Builder + public User(String nickname, String username, String email, String role, String provider, String providerId) { + this.nickname = nickname; + this.username = username; + this.email = email; + this.role = role; + this.provider = provider; + this.providerId = providerId; + this.isRegistered = false; + } +} diff --git a/src/main/java/com/movelog/domain/user/domain/repository/UserRepository.java b/src/main/java/com/movelog/domain/user/domain/repository/UserRepository.java new file mode 100644 index 0000000..bf43f28 --- /dev/null +++ b/src/main/java/com/movelog/domain/user/domain/repository/UserRepository.java @@ -0,0 +1,19 @@ +package com.movelog.domain.user.domain.repository; + +import com.movelog.domain.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByProviderAndProviderId(String provider, String providerId); + + Optional findByEmail(String email); + + Optional findByProviderId(String providerId); + + @Query("SELECT u.id FROM User u") + List findAllUserId(); +} diff --git a/src/main/java/com/movelog/domain/user/presentation/UserController.java b/src/main/java/com/movelog/domain/user/presentation/UserController.java new file mode 100644 index 0000000..c5ced33 --- /dev/null +++ b/src/main/java/com/movelog/domain/user/presentation/UserController.java @@ -0,0 +1,30 @@ +package com.movelog.domain.user.presentation; + +import com.movelog.domain.user.application.UserService; +import com.movelog.global.config.security.token.CurrentUser; +import com.movelog.global.config.security.token.UserPrincipal; +import com.movelog.global.payload.ErrorResponse; +import com.movelog.global.payload.Message; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "User", description = "User API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + + +} diff --git a/src/main/java/com/movelog/global/config/WebMvcConfig.java b/src/main/java/com/movelog/global/config/WebMvcConfig.java index 9b9ebc7..11bb040 100644 --- a/src/main/java/com/movelog/global/config/WebMvcConfig.java +++ b/src/main/java/com/movelog/global/config/WebMvcConfig.java @@ -1,10 +1,12 @@ package com.movelog.global.config; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +@RequiredArgsConstructor @Configuration public class WebMvcConfig implements WebMvcConfigurer { @@ -23,4 +25,5 @@ public void addCorsMappings(CorsRegistry registry) { .allowCredentials(true) .maxAge(MAX_AGE_SECS); } + } diff --git a/src/main/java/com/movelog/global/config/security/SecurityConfig.java b/src/main/java/com/movelog/global/config/security/SecurityConfig.java new file mode 100644 index 0000000..d4e036b --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/SecurityConfig.java @@ -0,0 +1,61 @@ +package com.movelog.global.config.security; + +import com.movelog.domain.auth.application.CustomUserDetailsService; +import com.movelog.global.config.security.jwt.JWTFilter; +import com.movelog.global.config.security.jwt.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; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; + +import static org.springframework.security.config.Customizer.withDefaults; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JWTUtil jwtUtil; + private final CustomUserDetailsService userDetailsService; + + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(withDefaults()); + + http + .csrf((auth) -> auth.disable()); + + // Form 로그인 방식 disable + http + .formLogin((auth) -> auth.disable()); + + // HTTP Basic 인증 방식 disable + http + .httpBasic((auth) -> auth.disable()); + + //JWTFilter 등록 + http + .addFilterBefore(new JWTFilter(jwtUtil, userDetailsService), OAuth2LoginAuthenticationFilter.class); + + + // 경로별 인가 작업 + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers("/", "/**").permitAll() + .requestMatchers("/reissue").permitAll() + .anyRequest().authenticated()); + + // 세션 설정 : STATELESS + http + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} diff --git a/src/main/java/com/movelog/global/config/security/jwt/JWTFilter.java b/src/main/java/com/movelog/global/config/security/jwt/JWTFilter.java new file mode 100644 index 0000000..ddbe066 --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/jwt/JWTFilter.java @@ -0,0 +1,69 @@ +package com.movelog.global.config.security.jwt; + +import com.movelog.domain.auth.application.CustomUserDetailsService; +import com.movelog.global.config.security.token.UserPrincipal; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.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.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.io.PrintWriter; + +@RequiredArgsConstructor +@Slf4j +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + private final CustomUserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + + try { + String accessToken = getJwtFromRequest(request); + + if (StringUtils.hasText(accessToken) && jwtUtil.validateToken(accessToken)) { + String email = jwtUtil.getEmail(accessToken); + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + + UserPrincipal userPrincipal = (UserPrincipal) userDetails; + Authentication authToken = new UsernamePasswordAuthenticationToken(userPrincipal, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } catch (ExpiredJwtException e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + PrintWriter writer = response.getWriter(); + writer.print("Access token expired"); + return; + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + PrintWriter writer = response.getWriter(); + writer.print("Invalid access token"); + return; + } + + + filterChain.doFilter(request, response); + + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + log.info("bearerToken = {}", bearerToken.substring(7, bearerToken.length())); + return bearerToken.substring(7, bearerToken.length()); + } + return null; + } +} diff --git a/src/main/java/com/movelog/global/config/security/jwt/JWTUtil.java b/src/main/java/com/movelog/global/config/security/jwt/JWTUtil.java new file mode 100644 index 0000000..1315b8c --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/jwt/JWTUtil.java @@ -0,0 +1,80 @@ +package com.movelog.global.config.security.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.function.Function; + +@Component +public class JWTUtil { + + private SecretKey secretKey; + + public JWTUtil(@Value("${app.auth.token-secret}") String secret) { + + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + private Claims extractAllClaims(String token) { + return Jwts.parser().setSigningKey(secretKey).build().parseSignedClaims(token).getBody(); + } + + public Boolean validateToken(String token) { + final String username = extractUsername(token); + return (!isTokenExpired(token)); + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public String getUsername(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username").toString(); + } + + public String getEmail(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("email").toString(); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role").toString(); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } + + public String getCategory(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class); + } + + public String createJwt(String category,String username, String role, Long expiredMs, String email) { + + return Jwts.builder() + .claim("category", category) + .claim("email", email) + .claim("username", username) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/com/movelog/global/config/security/oidc/KakaoOidcProvider.java b/src/main/java/com/movelog/global/config/security/oidc/KakaoOidcProvider.java new file mode 100644 index 0000000..8e271b6 --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/oidc/KakaoOidcProvider.java @@ -0,0 +1,51 @@ +package com.movelog.global.config.security.oidc; + +import com.auth0.jwk.Jwk; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.JwkProviderBuilder; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.security.interfaces.RSAPublicKey; +import java.util.Map; + +@Component +public class KakaoOidcProvider implements OidcProvider{ + + private final JwkProvider jwkProvider; + + public KakaoOidcProvider(@Value("${oauth.kakao.public-key-info}") String jwkUrl) { + try { + jwkProvider = new JwkProviderBuilder(new URL(jwkUrl)).build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create JwkProvider", e); + } + } + + + + @Override + public String getProviderId(String idToken) { + try { + Map headers = parseHeaders(idToken); + System.out.println("Parsed Headers: " + headers); // 디버깅 용 출력 + + DecodedJWT jwt = JWT.decode(idToken); + Jwk jwk = jwkProvider.get(jwt.getKeyId()); + Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null); + algorithm.verify(jwt); + + String providerId = jwt.getClaim("sub").asString(); + System.out.println("Provider ID: " + providerId); // Debugging output + + return providerId; + } catch (Exception e) { + throw new RuntimeException("Invalid ID token", e); + } + } + +} diff --git a/src/main/java/com/movelog/global/config/security/oidc/OidcProvider.java b/src/main/java/com/movelog/global/config/security/oidc/OidcProvider.java new file mode 100644 index 0000000..64a3005 --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/oidc/OidcProvider.java @@ -0,0 +1,24 @@ +package com.movelog.global.config.security.oidc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.Map; + +import static org.apache.commons.codec.binary.Base64.decodeBase64; + +public interface OidcProvider { + String getProviderId(String idToken); + + default Map parseHeaders(String token) { + String header = token.split("\\.")[0]; + + try { + String decodedHeader = new String(decodeBase64(header), "UTF-8"); + System.out.println("Decoded Header: " + decodedHeader); // 디버깅 용 출력 + return new ObjectMapper().readValue(decodedHeader, Map.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/movelog/global/config/security/oidc/OidcProviderFactory.java b/src/main/java/com/movelog/global/config/security/oidc/OidcProviderFactory.java new file mode 100644 index 0000000..1383bf5 --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/oidc/OidcProviderFactory.java @@ -0,0 +1,49 @@ +package com.movelog.global.config.security.oidc; + +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Map; + +@Component +public class OidcProviderFactory { + + private final Map authProviderMap; + private final KakaoOidcProvider kakaoOidcProvider; +// private final GoogleOidcProvider googleOidcProvider; +// private final NaverOidcProvider naverOidcProvider; + + public OidcProviderFactory( + KakaoOidcProvider kakaoOidcProvider +// GoogleOidcProvider googleOidcProvider, +// NaverOidcProvider naverOidcProvider + ) { + authProviderMap = new EnumMap<>(Provider.class); + this.kakaoOidcProvider = kakaoOidcProvider; +// this.googleOidcProvider = googleOidcProvider; +// this.naverOidcProvider = naverOidcProvider; + initialize(); + } + + private void initialize() { + authProviderMap.put(Provider.KAKAO, kakaoOidcProvider); +// authProviderMap.put(Provider.GOOGLE, googleOidcProvider); +// authProviderMap.put(Provider.NAVER, naverOidcProvider); + } + + public String getProviderId(Provider provider, String idToken) { + System.out.println("getProvider(provider).getProviderId(idToken) = " + getProvider(provider).getProviderId(idToken)); + return getProvider(provider).getProviderId(idToken); + } + + + private OidcProvider getProvider(Provider provider) { + OidcProvider oidcProvider = authProviderMap.get(provider); + System.out.println("oidcProvider = " + oidcProvider); + System.out.println("oidcProvider.getProviderId = " + oidcProvider.getProviderId("eyJraWQiOiI5ZjI1MmRhZGQ1ZjIzM2Y5M2QyZmE1MjhkMTJmZWEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxYzNjNDNiZTViYmYxMWVkNjI1ZTMyMDZhODI2ZTUzZSIsInN1YiI6IjM2Mzc0OTc0ODkiLCJhdXRoX3RpbWUiOjE3MjE5MTI2NzcsImlzcyI6Imh0dHBzOi8va2F1dGgua2FrYW8uY29tIiwiZXhwIjoxNzIxOTU1ODc3LCJpYXQiOjE3MjE5MTI2NzcsImVtYWlsIjoic3NtamgwMzAxQG5hdmVyLmNvbSJ9.cIMebTedEde8BrUUasnRJivdFJmyL62kvUDepnK7NlHp5dl9ygYqZNnoerDT5YU6bta503Mzp9-7U_MBRdqNPr7NXwvJ76NEAJcp3UwCV7DJJRlKMZv1PySqUJmpZiCwmCYhJV-UvEU2G9NF0mK_1CQ6-c2YnbAzxtf8DJ9jib6M8jyHD2-_FUM21IX8_0Uvb4TbwCwfSh8GaBLZKNgSAseq5Pf_vnVWbt38loRvfYhGBNcXey-BFQF3aiG8v0E9SaH4D93UhueFotXplrGg2I2UP7VEkkL1vDhb0I2sO82zELKBxYWaadzyCtv8dobB_blxOy5lagy6L4w2iBBMsw")); + if (oidcProvider == null) { + throw new RuntimeException("Wrong provider"); + } + return oidcProvider; + } +} diff --git a/src/main/java/com/movelog/global/config/security/oidc/Provider.java b/src/main/java/com/movelog/global/config/security/oidc/Provider.java new file mode 100644 index 0000000..c4f38aa --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/oidc/Provider.java @@ -0,0 +1,7 @@ +package com.movelog.global.config.security.oidc; + +public enum Provider { + KAKAO, + GOOGLE, + NAVER +} diff --git a/src/main/java/com/movelog/global/config/security/token/CurrentUser.java b/src/main/java/com/movelog/global/config/security/token/CurrentUser.java new file mode 100644 index 0000000..7587681 --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/token/CurrentUser.java @@ -0,0 +1,12 @@ +package com.movelog.global.config.security.token; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.*; + +@Target({ElementType.PARAMETER, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal +public @interface CurrentUser { +} diff --git a/src/main/java/com/movelog/global/config/security/token/UserPrincipal.java b/src/main/java/com/movelog/global/config/security/token/UserPrincipal.java new file mode 100644 index 0000000..4ce9cd9 --- /dev/null +++ b/src/main/java/com/movelog/global/config/security/token/UserPrincipal.java @@ -0,0 +1,68 @@ +package com.movelog.global.config.security.token; + +import com.movelog.domain.user.domain.User; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Getter +public class UserPrincipal implements OAuth2User, UserDetails { + + private final Long id; + private final String email; + private final String username; + private final String role; + private Collection authorities; + + + public UserPrincipal(Long id, String email, String username, String role, Collection authorities) { + this.id = id; + this.email = email; + this.username = username; + this.role = role; + this.authorities = authorities; + } + + public static UserPrincipal createUser(User user) { + List authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getRole().toString())); + return new UserPrincipal( + user.getId(), + user.getEmail(), + user.getUsername(), + user.getRole(), + authorities + ); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public Map getAttributes() { + return null; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getName() { + return null; + } +} diff --git a/src/main/java/com/movelog/global/error/DefaultAuthenticationException.java b/src/main/java/com/movelog/global/exception/DefaultAuthenticationException.java similarity index 94% rename from src/main/java/com/movelog/global/error/DefaultAuthenticationException.java rename to src/main/java/com/movelog/global/exception/DefaultAuthenticationException.java index 834fd8e..a126016 100644 --- a/src/main/java/com/movelog/global/error/DefaultAuthenticationException.java +++ b/src/main/java/com/movelog/global/exception/DefaultAuthenticationException.java @@ -1,4 +1,5 @@ -package com.movelog.global.error; +package com.movelog.global.exception; + import com.movelog.global.payload.ErrorCode; import lombok.Getter; @@ -23,4 +24,4 @@ public DefaultAuthenticationException(ErrorCode errorCode) { this.errorCode = errorCode; } -} +} \ No newline at end of file diff --git a/src/main/java/com/movelog/global/error/DefaultException.java b/src/main/java/com/movelog/global/exception/DefaultException.java similarity index 91% rename from src/main/java/com/movelog/global/error/DefaultException.java rename to src/main/java/com/movelog/global/exception/DefaultException.java index 2d61c89..d53d418 100644 --- a/src/main/java/com/movelog/global/error/DefaultException.java +++ b/src/main/java/com/movelog/global/exception/DefaultException.java @@ -1,4 +1,4 @@ -package com.movelog.global.error; +package com.movelog.global.exception; import com.movelog.global.payload.ErrorCode; @@ -19,4 +19,4 @@ public DefaultException(ErrorCode errorCode, String message) { this.errorCode = errorCode; } -} +} \ No newline at end of file diff --git a/src/main/java/com/movelog/global/payload/CustomException.java b/src/main/java/com/movelog/global/payload/CustomException.java new file mode 100644 index 0000000..1524dc8 --- /dev/null +++ b/src/main/java/com/movelog/global/payload/CustomException.java @@ -0,0 +1,15 @@ +package com.movelog.global.payload; + +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/movelog/global/payload/ErrorCode.java b/src/main/java/com/movelog/global/payload/ErrorCode.java index bd27b73..0dca6fc 100644 --- a/src/main/java/com/movelog/global/payload/ErrorCode.java +++ b/src/main/java/com/movelog/global/payload/ErrorCode.java @@ -1,17 +1,20 @@ package com.movelog.global.payload; import lombok.Getter; +import org.springframework.http.HttpStatus; @Getter public enum ErrorCode { - INVALID_PARAMETER(400, null, "잘못된 요청 데이터 입니다."), + INVALID_PARAMETER(400, null, "잘못된 요청 데이터입니다."), INVALID_REPRESENTATION(400, null, "잘못된 표현 입니다."), INVALID_FILE_PATH(400, null, "잘못된 파일 경로 입니다."), INVALID_OPTIONAL_ISPRESENT(400, null, "해당 값이 존재하지 않습니다."), INVALID_CHECK(400, null, "해당 값이 유효하지 않습니다."), INVALID_AUTHENTICATION(400, null, "잘못된 인증입니다."); + + private final String code; private final String message; private final int status; @@ -21,5 +24,5 @@ public enum ErrorCode { this.message = message; this.code = code; } - } + diff --git a/src/main/java/com/movelog/global/payload/ErrorResponse.java b/src/main/java/com/movelog/global/payload/ErrorResponse.java index 6037889..75e859c 100644 --- a/src/main/java/com/movelog/global/payload/ErrorResponse.java +++ b/src/main/java/com/movelog/global/payload/ErrorResponse.java @@ -2,77 +2,22 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Builder; -import lombok.Data; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.*; import org.springframework.validation.FieldError; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -@Data +@Getter +@AllArgsConstructor public class ErrorResponse { - private LocalDateTime timestamp = LocalDateTime.now(); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private String errMsg; - private String message; - - private String code; - - @JsonProperty("class") - private String clazz; - - private int status; - - @JsonInclude(JsonInclude.Include.NON_NULL) - @JsonProperty("errors") - private List customFieldErrors = new ArrayList<>(); - - public ErrorResponse() {} - - @Builder - public ErrorResponse(String code, int status, String message, String clazz, List fieldErrors){ - this.code = code; - this.status = status; - this.message = message; - this.clazz = clazz; - //setFieldErrors(fieldErrors); - } - - public void setFieldErrors(List fieldErrors) { - if(fieldErrors != null){ - fieldErrors.forEach(error -> { - customFieldErrors.add(new CustomFieldError( - error.getField(), - error.getRejectedValue(), - error.getDefaultMessage() - )); - }); - } + public String convertToJson() throws JsonProcessingException { + return objectMapper.writeValueAsString(this); } - - public static class CustomFieldError { - - private String field; - private Object value; - private String reason; - - public CustomFieldError(String field, Object value, String reason) { - this.field = field; - this.value = value; - this.reason = reason; - } - - public String getField() { - return field; - } - - public Object getValue() { - return value; - } - - public String getReason() { - return reason; - } - } - }