diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 0df152e..cc3d9bf 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -6,8 +6,18 @@ + + + + + + + + + + - + diff --git a/.idea/gradle.xml b/.idea/gradle.xml index b7d5a1d..40c5402 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -7,7 +7,7 @@ diff --git a/.idea/modules.xml b/.idea/modules.xml index 03631da..d92bb0f 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -3,6 +3,7 @@ + diff --git a/.idea/modules/1984157471/greatjourney.main.iml b/.idea/modules/1984157471/greatjourney.main.iml new file mode 100644 index 0000000..f4cd67e --- /dev/null +++ b/.idea/modules/1984157471/greatjourney.main.iml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/modules/backend.greatjourney.main.iml b/.idea/modules/backend.greatjourney.main.iml new file mode 100644 index 0000000..39e98ac --- /dev/null +++ b/.idea/modules/backend.greatjourney.main.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/greatjourney/.gitignore b/greatjourney/.gitignore index cbebd39..3fa5412 100644 --- a/greatjourney/.gitignore +++ b/greatjourney/.gitignore @@ -29,6 +29,7 @@ out/ application.properties +application.yml src/main/resources/application.properties diff --git a/greatjourney/build.gradle b/greatjourney/build.gradle index c9b1ca5..3e34ecf 100644 --- a/greatjourney/build.gradle +++ b/greatjourney/build.gradle @@ -18,6 +18,7 @@ repositories { } dependencies { + implementation 'org.springframework:spring-webmvc:6.1.11' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' runtimeOnly 'com.mysql:mysql-connector-j' @@ -31,10 +32,10 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' //jwt - 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' - + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // AWS SDK for S3 (v1) implementation 'com.amazonaws:aws-java-sdk-s3:1.12.565' @@ -52,7 +53,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' //swagger 관련 코드 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' //jsoup 관련 코드 implementation 'org.jsoup:jsoup:1.18.1' @@ -68,6 +69,12 @@ dependencies { implementation 'org.apache.commons:commons-csv:1.10.0' implementation 'org.apache.poi:poi-ooxml:5.2.5' + + //querydsl + 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" } tasks.named('test') { diff --git a/greatjourney/src/main/java/backend/greatjourney/GreatjourneyApplication.java b/greatjourney/src/main/java/backend/greatjourney/GreatjourneyApplication.java index 14607a7..9fef16b 100644 --- a/greatjourney/src/main/java/backend/greatjourney/GreatjourneyApplication.java +++ b/greatjourney/src/main/java/backend/greatjourney/GreatjourneyApplication.java @@ -5,8 +5,8 @@ import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@SpringBootApplication(exclude = SecurityAutoConfiguration.class) @EnableJpaAuditing +@SpringBootApplication public class GreatjourneyApplication { public static void main(String[] args) { diff --git a/greatjourney/src/main/java/backend/greatjourney/config/QueryDslConfig.java b/greatjourney/src/main/java/backend/greatjourney/config/QueryDslConfig.java new file mode 100644 index 0000000..70e31c3 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/config/QueryDslConfig.java @@ -0,0 +1,21 @@ +package backend.greatjourney.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/greatjourney/src/main/java/backend/greatjourney/config/SwaggerConfig.java b/greatjourney/src/main/java/backend/greatjourney/config/SwaggerConfig.java index 4e91ee3..dfd84e9 100644 --- a/greatjourney/src/main/java/backend/greatjourney/config/SwaggerConfig.java +++ b/greatjourney/src/main/java/backend/greatjourney/config/SwaggerConfig.java @@ -1,24 +1,40 @@ package backend.greatjourney.config; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; -import org.springframework.context.annotation.Bean; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +@Configuration public class SwaggerConfig { -// @Bean -// public OpenAPI api() { -// SecurityScheme apiKey = new SecurityScheme() -// .type(SecurityScheme.Type.APIKEY) -// .in(SecurityScheme.In.HEADER) -// .name("Authorization"); -// -// SecurityRequirement securityRequirement = new SecurityRequirement() -// .addList("Bearer Token"); -// -// return new OpenAPI() -// .components(new Components().addSecuritySchemes("Bearer Token", apiKey)) -// .addSecurityItem(securityRequirement); -// } -} + @Bean + public OpenAPI openAPI() { + SecurityRequirement securityRequirement = new SecurityRequirement().addList("BearerAuth"); + + return new OpenAPI() + .components(new Components()) + .info(apiInfo()) + .addSecurityItem(securityRequirement) + .schemaRequirement("BearerAuth", securityScheme()); + } + + private Info apiInfo() { + return new Info() + .title("어디로 API") + .description("대장정이팀 API 명세서입니다") + .version("1.0.0"); + } + + private SecurityScheme securityScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + } +} \ No newline at end of file diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/token/dto/TokenResponse.java b/greatjourney/src/main/java/backend/greatjourney/domain/token/dto/TokenResponse.java new file mode 100644 index 0000000..6307dbd --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/token/dto/TokenResponse.java @@ -0,0 +1,11 @@ +package backend.greatjourney.domain.token.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/token/entity/RefreshToken.java b/greatjourney/src/main/java/backend/greatjourney/domain/token/entity/RefreshToken.java new file mode 100644 index 0000000..9c64c86 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/token/entity/RefreshToken.java @@ -0,0 +1,31 @@ +package backend.greatjourney.domain.token.entity; + +import java.time.Instant; + +import org.springframework.web.bind.annotation.RequestParam; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class RefreshToken { + + @Id + private String tokenId; + private Long userId; + private String token; + private Instant expiryDate; + + @Builder + private RefreshToken(Long userId, String id, String token, Instant expiryDate) { + this.userId = userId; + this.tokenId = id; + this.token = token; + this.expiryDate = expiryDate; + } +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/token/repository/RefreshTokenRepository.java b/greatjourney/src/main/java/backend/greatjourney/domain/token/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..7f6ca68 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/token/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package backend.greatjourney.domain.token.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import backend.greatjourney.domain.token.entity.RefreshToken; + +public interface RefreshTokenRepository extends JpaRepository { + + Void deleteByToken(String token); + RefreshToken findByUserId(Long userId); +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtAuthenticationFilter.java b/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtAuthenticationFilter.java new file mode 100644 index 0000000..56e8d39 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtAuthenticationFilter.java @@ -0,0 +1,43 @@ +package backend.greatjourney.domain.token.service; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring("Bearer ".length()); + + if (jwtTokenProvider.validateToken(token)) { + UsernamePasswordAuthenticationToken authentication = + (UsernamePasswordAuthenticationToken) jwtTokenProvider.getAuthentication(token); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtAuthenticationManager.java b/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtAuthenticationManager.java new file mode 100644 index 0000000..541cbf5 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtAuthenticationManager.java @@ -0,0 +1,23 @@ +package backend.greatjourney.domain.token.service; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationManager implements AuthenticationManager { + private final JwtTokenProvider jwtTokenProvider; + + + public JwtAuthenticationManager(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + public Authentication authenticate(Authentication authentication) { + String token = authentication.getCredentials().toString(); + return jwtTokenProvider.getAuthentication(token); + } +} + + diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtTokenProvider.java b/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtTokenProvider.java new file mode 100644 index 0000000..8ca419a --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/token/service/JwtTokenProvider.java @@ -0,0 +1,165 @@ +package backend.greatjourney.domain.token.service; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Map; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import backend.greatjourney.domain.token.dto.TokenResponse; +import backend.greatjourney.domain.token.entity.RefreshToken; + +import backend.greatjourney.domain.token.repository.RefreshTokenRepository; +import backend.greatjourney.domain.user.entity.User; +import backend.greatjourney.domain.user.repository.UserRepository; +import backend.greatjourney.global.exception.CustomException; +import backend.greatjourney.global.exception.ErrorCode; +import backend.greatjourney.global.security.entitiy.CustomOAuth2User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.MacAlgorithm; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + + @Value("${spring.jwt.secret}") + private String secret; + + @Value("${spring.jwt.access-token-duration}") + private Duration accessTokenDuration; + + @Value("${spring.jwt.refresh-token-duration}") + private Duration refreshTokenDuration; + + private final MacAlgorithm alg = Jwts.SIG.HS512; + private SecretKey key; + + @PostConstruct + public void init() { + try { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new CustomException(ErrorCode.JWT_KEY_GENERATION_FAILED); + } + } + + public String createAccessToken(Long userId) { + try { + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessTokenDuration.toMillis()); + + return Jwts.builder() + .claims(Map.of("sub", userId)) + .issuedAt(now) + .expiration(expiry) + .signWith(key, alg) + .compact(); + } catch (Exception e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + public String createRefreshToken(Long userId) { + try { + Date now = new Date(); + Date expiry = new Date(now.getTime() + refreshTokenDuration.toMillis()); + + return Jwts.builder() + .claims(Map.of("sub", userId)) + .issuedAt(now) + .expiration(expiry) + .signWith(key, alg) + .compact(); + } catch (Exception e) { + throw new CustomException(ErrorCode.EXPIRED_REFRESH_TOKEN); + } + } + + + public TokenResponse createToken(Long userId) { + try { + String accessToken = createAccessToken(userId); + String refreshToken = createRefreshToken(userId); + + Instant expiryDate = Instant.now().plus(refreshTokenDuration); + + RefreshToken refreshTokenEntity = RefreshToken.builder() + .userId(userId) + .token(refreshToken) + .expiryDate(expiryDate) + .build(); + + refreshTokenRepository.save(refreshTokenEntity); + return new TokenResponse(accessToken,refreshToken); + + } catch (Exception e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + + public String getUserIdFromToken(String token) { + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } catch (JwtException e) { + throw new CustomException(ErrorCode.JWT_PARSE_FAILED); + } + } + + public Authentication getAuthentication(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + + String userId = claims.getSubject(); + Long realUserId = Long.parseLong(userId); + User user = userRepository.findById(realUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Map attributes = Map.of("userId", userId); + CustomOAuth2User principal = new CustomOAuth2User(attributes, userId, user.getUserRole().name()); + + return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); + + } catch (Exception e) { + throw new CustomException(ErrorCode.LOGIN_FAIL); + } + } + + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/controller/UserController.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/controller/UserController.java new file mode 100644 index 0000000..bee8c9e --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/controller/UserController.java @@ -0,0 +1,76 @@ +package backend.greatjourney.domain.user.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import backend.greatjourney.domain.user.dto.request.ChangeUserRequest; +import backend.greatjourney.domain.user.dto.request.KakaoLoginRequest; +import backend.greatjourney.domain.user.dto.request.SignUpRequest; +import backend.greatjourney.domain.user.entity.User; +import backend.greatjourney.domain.user.service.KakaoService; +import backend.greatjourney.domain.user.service.UserService; +import backend.greatjourney.global.exception.BaseResponse; +import backend.greatjourney.global.security.entitiy.CustomOAuth2User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/user") +@RequiredArgsConstructor +@Tag(name = "로그인 API", description = "로그인/로그아웃 API에 대한 설명입니다.") +public class UserController { + + private final UserService userService; + private final KakaoService kakaoService; + + //회원가입 + @Operation(summary = "회원가입 API") + @PostMapping("/signup") + public BaseResponse signUp(@RequestBody SignUpRequest request){ + return userService.signupUser(request); + } + + //회원탈퇴 + @Operation(summary = "회원탈퇴 API") + @DeleteMapping("/signout") + public BaseResponse singOut(@AuthenticationPrincipal CustomOAuth2User customOAuth2User){ + return userService.signOutUser(customOAuth2User); + } + + //카카오로그인 + @Operation(summary = "카카오로그인 API") + @PostMapping("/kakao") + public BaseResponse loginKakao(@RequestBody KakaoLoginRequest request){ + return BaseResponse.builder() + .isSuccess(true) + .code(200) + .message("로그인이 완료되었습니다.") + .data(kakaoService.loginWithKakao(request.accessToken())) + .build(); + } + + //로그아웃 + @Operation(summary = "로그아웃 API") + @PostMapping("/logout") + public BaseResponse logout(@AuthenticationPrincipal CustomOAuth2User customOAuth2User){ + return userService.logOutUser(customOAuth2User); + } + + + //회원정보수정 + @Operation(summary = "회원정보 수정 API") + @PatchMapping("/change") + public BaseResponse chageUserInfo(@AuthenticationPrincipal CustomOAuth2User customOAuth2User,@RequestBody + ChangeUserRequest request){ + return userService.changeUserInfo(customOAuth2User,request); + } + + + +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/properties/KakaoOAuthProperties.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/properties/KakaoOAuthProperties.java new file mode 100644 index 0000000..f492ca7 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/properties/KakaoOAuthProperties.java @@ -0,0 +1,16 @@ +package backend.greatjourney.domain.user.dto.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import lombok.Getter; +import lombok.Setter; + +@Configuration +@ConfigurationProperties(prefix = "kakao") +@Getter +@Setter +public class KakaoOAuthProperties { + private String clientId; + private String clientSecret; +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/ChangeUserRequest.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/ChangeUserRequest.java new file mode 100644 index 0000000..91fa5d8 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/ChangeUserRequest.java @@ -0,0 +1,4 @@ +package backend.greatjourney.domain.user.dto.request; + +public record ChangeUserRequest(String name) { +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/KakaoLoginRequest.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/KakaoLoginRequest.java new file mode 100644 index 0000000..49dfca4 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/KakaoLoginRequest.java @@ -0,0 +1,4 @@ +package backend.greatjourney.domain.user.dto.request; + +public record KakaoLoginRequest(String accessToken) { +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/SignUpRequest.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/SignUpRequest.java new file mode 100644 index 0000000..010df1c --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/request/SignUpRequest.java @@ -0,0 +1,5 @@ +package backend.greatjourney.domain.user.dto.request; + +public record SignUpRequest( + String email, String domain) { +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/response/KakaoUserResponse.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/response/KakaoUserResponse.java new file mode 100644 index 0000000..20b2111 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/dto/response/KakaoUserResponse.java @@ -0,0 +1,29 @@ +package backend.greatjourney.domain.user.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Getter; +import lombok.Setter; + +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +@Setter +public class KakaoUserResponse { + private Long id; + private KakaoAccount kakao_account; + private Properties properties; + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class KakaoAccount { + private String email; + } + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Properties { + private String nickname; + } +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/Domain.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/Domain.java new file mode 100644 index 0000000..6af4607 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/Domain.java @@ -0,0 +1,7 @@ +package backend.greatjourney.domain.user.entity; + +public enum Domain { + KAKAO, + GOOGLE, + NAVER +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/Status.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/Status.java new file mode 100644 index 0000000..95c931e --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/Status.java @@ -0,0 +1,7 @@ +package backend.greatjourney.domain.user.entity; + +public enum Status { + PENDING, + SUCCESS, + DELETED +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/User.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/User.java new file mode 100644 index 0000000..512068a --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/User.java @@ -0,0 +1,64 @@ +package backend.greatjourney.domain.user.entity; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Table(name = "User") +@NoArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + + @Setter + private String name; + + @Setter + private Status status; + + private Domain domain; + + private String email; + + private UserRole userRole; + + private Terms terms; + + + + @Builder + private User(Long id, Status status, Domain domain,String email,UserRole userRole, Terms terms,String name){ + this.userId = id; + this.status = status; + this.domain = domain; + this.name = name; + this.email = email; + this.userRole = userRole; + this.terms = terms; + } + + + @Embeddable + @Getter + @NoArgsConstructor + public static class Terms { + private boolean marketing; + + @Builder + private Terms(boolean marketing) { + this.marketing = marketing; + } + } + +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/UserRole.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/UserRole.java new file mode 100644 index 0000000..b4df7c4 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/entity/UserRole.java @@ -0,0 +1,6 @@ +package backend.greatjourney.domain.user.entity; + +public enum UserRole { + ROLE_USER, + ROLE_ADMIN +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/UserRepository.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..7c6f3f0 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/UserRepository.java @@ -0,0 +1,8 @@ +package backend.greatjourney.domain.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import backend.greatjourney.domain.user.entity.User; + +public interface UserRepository extends JpaRepository , UserRepositoryCustom { +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/UserRepositoryCustom.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/UserRepositoryCustom.java new file mode 100644 index 0000000..854959c --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/UserRepositoryCustom.java @@ -0,0 +1,11 @@ +package backend.greatjourney.domain.user.repository; + +import java.util.Optional; + +import backend.greatjourney.domain.user.entity.User; + +public interface UserRepositoryCustom { + + boolean existsByEmail(String email); + Optional findByUserId(Long userId); +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/impl/UserRepositoryImplement.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/impl/UserRepositoryImplement.java new file mode 100644 index 0000000..3f940a1 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/repository/impl/UserRepositoryImplement.java @@ -0,0 +1,40 @@ +package backend.greatjourney.domain.user.repository.impl; + +import java.util.Optional; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import backend.greatjourney.domain.user.entity.QUser; +import backend.greatjourney.domain.user.entity.User; +import backend.greatjourney.domain.user.repository.UserRepositoryCustom; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class UserRepositoryImplement implements UserRepositoryCustom { + private final JPAQueryFactory queryFactory; + QUser quesr = QUser.user; + + @Override + public boolean existsByEmail(String email){ + return queryFactory + .selectOne() + .from(quesr) + .where( + quesr.email.eq(email) + ) + .fetchFirst() != null; + } + + @Override + public Optional findByUserId(Long userId) { + return Optional.ofNullable( + queryFactory + .selectFrom(quesr) + .where(quesr.userId.eq(userId)) + .fetchOne() + ); + } + + + +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/service/KakaoService.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/service/KakaoService.java new file mode 100644 index 0000000..87db6c6 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/service/KakaoService.java @@ -0,0 +1,68 @@ +package backend.greatjourney.domain.user.service; + +import java.util.Map; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import aj.org.objectweb.asm.TypeReference; +import backend.greatjourney.domain.token.dto.TokenResponse; +import backend.greatjourney.domain.token.service.JwtTokenProvider; +import backend.greatjourney.domain.user.dto.properties.KakaoOAuthProperties; +import backend.greatjourney.domain.user.dto.response.KakaoUserResponse; +import backend.greatjourney.domain.user.entity.User; +import backend.greatjourney.global.exception.BaseException; +import backend.greatjourney.global.exception.CustomException; +import backend.greatjourney.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class KakaoService { + + private final RestTemplate restTemplate = new RestTemplate(); + private final KakaoOAuthProperties kakaoProps; + private final JwtTokenProvider jwtTokenProvider; + private final SignService signService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final String domain = "kakao"; + + public TokenResponse loginWithKakao(String accessToken) { + KakaoUserResponse userInfo = getUserInfo(accessToken); + User user = signService.saveUserKakao(userInfo, domain); + return jwtTokenProvider.createToken(user.getUserId()); + } + + + private KakaoUserResponse getUserInfo(String accessToken) { + String url = "https://kapi.kakao.com/v2/user/me"; + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity request = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + return objectMapper.readValue(response.getBody(), KakaoUserResponse.class); + } else { + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } + } catch (Exception e) { + throw new CustomException(ErrorCode.KAKAO_USER_ERROR); + } + } +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/service/SignService.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/service/SignService.java new file mode 100644 index 0000000..3beff9c --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/service/SignService.java @@ -0,0 +1,39 @@ +package backend.greatjourney.domain.user.service; + +import org.apache.xmlbeans.impl.store.DomImpl; +import org.springframework.stereotype.Service; + +import backend.greatjourney.domain.user.dto.response.KakaoUserResponse; +import backend.greatjourney.domain.user.entity.Domain; +import backend.greatjourney.domain.user.entity.Status; +import backend.greatjourney.domain.user.entity.User; +import backend.greatjourney.domain.user.entity.UserRole; +import backend.greatjourney.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class SignService { + + private final UserRepository userRepository; + + public User saveUserKakao(KakaoUserResponse userInfo, String domain){ + + Domain realDomain = Domain.valueOf(domain); + + User user = User.builder() + .userRole(UserRole.ROLE_USER) + .domain(realDomain) + .email(userInfo.getKakao_account().getEmail()) + .name(userInfo.getProperties().getNickname()) + .status(Status.PENDING) + .build(); + + return userRepository.save(user); + + } + + +} diff --git a/greatjourney/src/main/java/backend/greatjourney/domain/user/service/UserService.java b/greatjourney/src/main/java/backend/greatjourney/domain/user/service/UserService.java new file mode 100644 index 0000000..b5459c1 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/domain/user/service/UserService.java @@ -0,0 +1,93 @@ +package backend.greatjourney.domain.user.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import backend.greatjourney.domain.token.entity.RefreshToken; +import backend.greatjourney.domain.token.repository.RefreshTokenRepository; +import backend.greatjourney.domain.token.service.JwtTokenProvider; +import backend.greatjourney.domain.user.dto.request.ChangeUserRequest; +import backend.greatjourney.domain.user.dto.request.SignUpRequest; +import backend.greatjourney.domain.user.entity.Domain; +import backend.greatjourney.domain.user.entity.Status; +import backend.greatjourney.domain.user.entity.User; +import backend.greatjourney.domain.user.repository.UserRepository; +import backend.greatjourney.global.exception.BaseResponse; +import backend.greatjourney.global.exception.CustomException; +import backend.greatjourney.global.exception.ErrorCode; +import backend.greatjourney.global.security.entitiy.CustomOAuth2User; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider tokenProvider; + + @Transactional + public BaseResponse signupUser(SignUpRequest request){ + if(userRepository.existsByEmail(request.email())){ + throw new IllegalArgumentException("이미 존재하는 회원입니다."); + } + return new BaseResponse<>(true,"회원가입이 완료되었습니다",201,userRepository.save(User.builder() + .domain(Domain.valueOf(request.domain())) + .email(request.email()) + .status(Status.SUCCESS) + .build())); + } + + @Transactional + public BaseResponse logOutUser(CustomOAuth2User customOAuth2User){ + + Long userId = Long.parseLong(customOAuth2User.getUserId()); + RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId); + + return BaseResponse.builder() + .code(200) + .message("로그아웃이 완료되었습니다.") + .data(refreshTokenRepository.deleteByToken(refreshToken.getToken())) + .isSuccess(true) + .build(); + } + + + @Transactional + public BaseResponse signOutUser(CustomOAuth2User customOAuth2User){ + Long userId = Long.parseLong(customOAuth2User.getUserId()); + RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId); + + refreshTokenRepository.deleteByToken(refreshToken.getToken()); + User user = userRepository.findByUserId(userId) + .orElseThrow(()->new CustomException(ErrorCode.USER_NOT_FOUND)); + + user.setStatus(Status.DELETED); + userRepository.save(user); + + return BaseResponse.builder() + .isSuccess(true) + .code(200) + .message("회원탈퇴가 완료되었습니다.") + .data(null) + .build(); + } + + @Transactional + public BaseResponse changeUserInfo(CustomOAuth2User customOAuth2User, ChangeUserRequest request){ + Long userId = Long.parseLong(customOAuth2User.getUserId()); + User user = userRepository.findById(userId) + .orElseThrow(()->new CustomException(ErrorCode.USER_NOT_FOUND)); + + user.setName(request.name()); + userRepository.save(user); + return BaseResponse.builder() + .isSuccess(true) + .code(200) + .message("회원정보 수정이 완료되었습니다.") + .data(null) + .build(); + } + + +} diff --git a/greatjourney/src/main/java/backend/greatjourney/global/exception/BaseResponse.java b/greatjourney/src/main/java/backend/greatjourney/global/exception/BaseResponse.java index eb9e2c7..9270bff 100644 --- a/greatjourney/src/main/java/backend/greatjourney/global/exception/BaseResponse.java +++ b/greatjourney/src/main/java/backend/greatjourney/global/exception/BaseResponse.java @@ -20,7 +20,6 @@ public BaseResponse(boolean isSuccess, String message, int code, String data) { this.code = code; this.data = (T) data; } - @Builder public BaseResponse(boolean isSuccess, String message, int code, T data) { this.isSuccess = isSuccess; diff --git a/greatjourney/src/main/java/backend/greatjourney/global/exception/CustomException.java b/greatjourney/src/main/java/backend/greatjourney/global/exception/CustomException.java new file mode 100644 index 0000000..6a28549 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/global/exception/CustomException.java @@ -0,0 +1,14 @@ +package backend.greatjourney.global.exception; + +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/greatjourney/src/main/java/backend/greatjourney/global/exception/ErrorCode.java b/greatjourney/src/main/java/backend/greatjourney/global/exception/ErrorCode.java new file mode 100644 index 0000000..c9ae0bc --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/global/exception/ErrorCode.java @@ -0,0 +1,38 @@ +package backend.greatjourney.global.exception; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + LOGIN_FAIL(HttpStatus.BAD_REQUEST,400,"로그인에 오류가 발생하였습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,500,"서버에 오류가 발생하였습니다."), + JWT_KEY_GENERATION_FAILED(HttpStatus.BAD_REQUEST,400,"JWT 키 생성에 실패하였습니다."), + NO_REFRESH_TOKEN(UNAUTHORIZED,400, "리프레시 토큰이 없습니다."), + LOGOUT_ERROR(BAD_REQUEST,400,"로그아웃에 실패하였습니다."), + SIGNUP_ERROR(BAD_REQUEST,400,"회원가입에러입니다."), + EXPIRED_REFRESH_TOKEN(UNAUTHORIZED, 400,"만료된 토큰입니다."), + JWT_PARSE_FAILED(BAD_REQUEST,404,"토큰 파싱이 잘못되었습니다."), + + KAKAO_USER_ERROR(BAD_REQUEST,404,"카카오 유저 정보를 가져오지 못하였습니다."), + TOKEN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,500, "토큰을 제대로 생성하지 못하였습니다."), + + + USER_NOT_FOUND(BAD_REQUEST,404,"존재하지 않는 유저입니다."); + + + + private final HttpStatus status; + private final int code; + private final String message; + + + ErrorCode( HttpStatus status,int code, String message){ + this.code = code; + this.status = status; + this.message = message; + } +} diff --git a/greatjourney/src/main/java/backend/greatjourney/global/security/config/SecurityConfig.java b/greatjourney/src/main/java/backend/greatjourney/global/security/config/SecurityConfig.java new file mode 100644 index 0000000..8d8dae7 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/global/security/config/SecurityConfig.java @@ -0,0 +1,52 @@ +package backend.greatjourney.global.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import backend.greatjourney.domain.token.service.JwtAuthenticationFilter; +import backend.greatjourney.domain.token.service.JwtTokenProvider; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + + private final JwtTokenProvider jwtTokenProvider; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**","api/v1/","/v3/api-docs/**", + "/swagger-ui/**", // ✅ Swagger UI HTML/JS + "/swagger-ui.html", // ✅ Swagger 메인 HTML + "/webjars/**" ) // ✅ Swagger 리소스 + .permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(e -> e + .authenticationEntryPoint((request, response, authException) -> { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + }) + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + +} diff --git a/greatjourney/src/main/java/backend/greatjourney/global/security/entitiy/CustomOAuth2User.java b/greatjourney/src/main/java/backend/greatjourney/global/security/entitiy/CustomOAuth2User.java new file mode 100644 index 0000000..1b56e46 --- /dev/null +++ b/greatjourney/src/main/java/backend/greatjourney/global/security/entitiy/CustomOAuth2User.java @@ -0,0 +1,47 @@ +package backend.greatjourney.global.security.entitiy; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import lombok.Getter; + + +public class CustomOAuth2User implements OAuth2User { + + private final Map attributes; //여기에 인증서버로 부터 받은 사용자 정보가 담김 + + private final String uuid; + + @Getter + private final String role; + + public CustomOAuth2User(Map attributes, String uuid, String role) { + this.attributes = attributes; + this.uuid = uuid; + this.role = role; + } + + public String getUserId() { + return uuid; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return uuid; + } + + @Override + public Collection getAuthorities() { + return List.of(() -> role); + } + +} \ No newline at end of file