From 88b23946814d40b1024fafe4af091b5dc417fc59 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:07:05 +0900 Subject: [PATCH 01/17] =?UTF-8?q?chore:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 12 +++++++++ docker-compose.yml | 16 +++++++++++- .../java/com/ongil/backend/Application.java | 2 ++ src/main/resources/application-local.yml | 2 +- src/main/resources/application.yml | 26 ++++++++++++++++++- 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 0e7fd79..2dec251 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,18 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // FeignClient + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.2' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 70fc4eb..06b8dfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,5 +12,19 @@ services: PROD_DB_URL: ${PROD_DB_URL} PROD_DB_USERNAME: ${PROD_DB_USERNAME} PROD_DB_PASSWORD: ${PROD_DB_PASSWORD} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 ports: - - "8080:8080" \ No newline at end of file + - "8080:8080" + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: ongil-redis + ports: + - "6379:6379" + restart: always + volumes: + - ./redis_data:/data + command: ["redis-server", "--appendonly", "yes"] \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/Application.java b/src/main/java/com/ongil/backend/Application.java index 462b0b4..6d8287d 100644 --- a/src/main/java/com/ongil/backend/Application.java +++ b/src/main/java/com/ongil/backend/Application.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing @SpringBootApplication +@EnableFeignClients public class Application { public static void main(String[] args) { diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 517eb01..68696ff 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -12,7 +12,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: create show-sql: true properties: hibernate: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0970e05..6691e8d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,6 +5,30 @@ spring: name: OnGil profiles: active: ${SPRING_PROFILES_ACTIVE:local} + data: + redis: + host: ${SPRING_DATA_REDIS_HOST:localhost} + port: ${SPRING_DATA_REDIS_PORT:6379} server: - port: 8080 \ No newline at end of file + port: 8080 + +jwt: + secret: ${JWT_SECRET} + access-expiration-ms: 3600000 + refresh-expiration-ms: 1209600000 + issuer: "ongil" + +kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + +google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URI} + authorization-grant-type: authorization_code + scope: email, profile \ No newline at end of file From 9d3d319a90b91617a9a7d262392493d648bc5b6c Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:08:06 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20jwt=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89,=20=EA=B2=80=EC=A6=9D,=20=EC=9D=B8=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/jwt/JwtAuthenticationFilter.java | 60 ++++++++++++ .../global/security/jwt/JwtTokenProvider.java | 97 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java diff --git a/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..d087476 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.ongil.backend.global.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +import com.ongil.backend.domain.user.repository.UserRepository; + +// 이후 모든 요청에서 JWT를 검증하고 인증 상태로 만들어줌 +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 1. 요청 헤더에서 토큰 추출 + String token = resolveToken(request); + + // 2. 토큰이 유효한지 검사 + if (token != null && jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getUserId(token); + + // 3. 스프링 시큐리티 인증 객체 생성 + List authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + + Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + + // 4. SecurityContext에 인증 정보 저장 (이 요청이 실행되는 동안만 유효) + SecurityContextHolder.getContext().setAuthentication(auth); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..9d17d39 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,97 @@ +package com.ongil.backend.global.security.jwt; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; + +// 토큰 발급, 검증 +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-expiration-ms}") + private long accessExpMs; + + @Value("${jwt.issuer}") + private String issuer; + + @Value("${jwt.refresh-expiration-ms}") + private long refreshExpMs; + + private Key key; + + @PostConstruct + void init() { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(Long userId) { + Date now = new Date(); + return Jwts.builder() + .setIssuer(issuer) + .setSubject(String.valueOf(userId)) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + accessExpMs)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken(Long userId) { + Date now = new Date(); + return Jwts.builder() + .setIssuer(issuer) + .setSubject(String.valueOf(userId)) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + refreshExpMs)) + .claim("type", "refresh") + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + // 토큰 유효성 검증 + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + public boolean validateRefreshToken(String token) { + try { + Claims claims = parseClaims(token); + return "refresh".equals(claims.get("type")); + } catch (Exception e) { + return false; + } + } + + public Long getUserId(String token) { + return Long.valueOf(parseClaims(token).getSubject()); + } + + public long getRefreshTokenExpireTime() { + return refreshExpMs; + } + + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .requireIssuer(issuer) + .build() + .parseClaimsJws(token) + .getBody(); + } +} \ No newline at end of file From 83a22be16b7f54216a66305ba31770bba3052d09 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:08:51 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC,=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 81 +++++++++++++++++++ .../domain/auth/converter/AuthConverter.java | 22 +++++ .../domain/auth/dto/response/AuthResDto.java | 35 ++++++++ .../domain/auth/service/AuthService.java | 58 +++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/service/AuthService.java diff --git a/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java b/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..31f3bc6 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java @@ -0,0 +1,81 @@ +package com.ongil.backend.domain.auth.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ongil.backend.domain.auth.dto.request.TokenRefreshReqDto; +import com.ongil.backend.domain.auth.dto.response.AuthResDto; +import com.ongil.backend.domain.auth.dto.response.TokenRefreshResDto; +import com.ongil.backend.domain.auth.service.AuthService; +import com.ongil.backend.domain.auth.service.GoogleLoginService; +import com.ongil.backend.domain.auth.service.KakaoLoginService; +import com.ongil.backend.global.common.dto.DataResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + private final KakaoLoginService kakaoLoginService; + private final GoogleLoginService googleLoginService; + + @PostMapping("/oauth/kakao") + @Operation(summary = "카카오 회원가입/로그인 API", description = "인가코드(code)로 카카오 토큰 교환 후, 우리 서비스 JWT 발급") + public ResponseEntity> kakaoLogin( + @Valid @RequestParam("code") @NotBlank String code + ) { + AuthResDto res = kakaoLoginService.kakaoLogin(code); + return ResponseEntity.ok(DataResponse.from(res)); + } + + @PostMapping("/oauth/google") + @Operation(summary = "구글 회원가입/로그인 API", description = "인가코드(code)로 구글 토큰 교환 후, 우리 서비스 JWT 발급") + public ResponseEntity> googleLogin( + @Valid @RequestParam("code") @NotBlank String code + ) { + AuthResDto res = googleLoginService.googleLogin(code); + return ResponseEntity.ok(DataResponse.from(res)); + } + + @PostMapping("/token/refresh") + @Operation(summary = "Access/Refresh Token 재발급 API", description = "만료된 accessToken을 refreshToken을 통해 재발급") + public ResponseEntity> refresh( + @Valid @RequestBody TokenRefreshReqDto request + ) { + TokenRefreshResDto res = authService.refreshAccessToken(request.refreshToken()); + return ResponseEntity.ok(DataResponse.from(res)); + } + + @PostMapping("/logout") + @Operation(summary = "로그아웃 API", description = "Redis에 저장된 리프레시 토큰을 삭제하여 로그아웃 처리") + public ResponseEntity> logout( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId + ) { + authService.logout(userId); + return ResponseEntity.ok(DataResponse.from("로그아웃 되었습니다.")); + } + + @DeleteMapping("/withdraw") + @Operation(summary = "회원 탈퇴 API", description = "계정 삭제 및 리프레시 토큰 파기") + public ResponseEntity> withdraw( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId + ) { + authService.withdraw(userId); + return ResponseEntity.ok(DataResponse.from("회원 탈퇴가 완료되었습니다.")); + } +} diff --git a/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java b/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java new file mode 100644 index 0000000..118243b --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java @@ -0,0 +1,22 @@ +package com.ongil.backend.domain.auth.converter; + +import com.ongil.backend.domain.auth.dto.response.AuthResDto; +import com.ongil.backend.domain.user.entity.User; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class AuthConverter { + + public static AuthResDto toResponse(User user, String accessToken, String refreshToken, + boolean isNewUser, long accessExpMs) { + return AuthResDto.builder() + .userId(user.getId()) + .accessToken(accessToken) + .refreshToken(refreshToken) + .LoginType(user.getLoginType()) + .isNewUser(isNewUser) + .expires_in((int) (accessExpMs / 1000)) // 초 단위 + .build(); + } +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java new file mode 100644 index 0000000..28c2321 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java @@ -0,0 +1,35 @@ +package com.ongil.backend.domain.auth.dto.response; + +import com.ongil.backend.domain.auth.entity.LoginType; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record AuthResDto( + + @Schema(description = "유저아이디") + Long userId, + + @Schema(description = "액세스토큰") + @NotNull + String accessToken, + + @Schema(description = "리프레시토큰") + @NotNull + String refreshToken, + + @Schema(description = "로그인 타입") + @NotNull + LoginType LoginType, + + @Schema(description = "회원가입 여부(첫 로그인 여부)") + @NotNull + Boolean isNewUser, + + @Schema(description = "액세스 토큰 만료 시간") + @NotNull + Integer expires_in +) { +} diff --git a/src/main/java/com/ongil/backend/domain/auth/service/AuthService.java b/src/main/java/com/ongil/backend/domain/auth/service/AuthService.java new file mode 100644 index 0000000..35bbabe --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/service/AuthService.java @@ -0,0 +1,58 @@ +package com.ongil.backend.domain.auth.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ongil.backend.domain.auth.dto.response.TokenRefreshResDto; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; +import com.ongil.backend.global.config.redis.RedisRefreshTokenStore; +import com.ongil.backend.global.security.jwt.JwtTokenProvider; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + private final RedisRefreshTokenStore refreshTokenStore; + private final UserRepository userRepository; + + public TokenRefreshResDto refreshAccessToken(String refreshTokenValue) { + + if (!jwtTokenProvider.validateRefreshToken(refreshTokenValue)) { + throw new AppException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + Long userId = jwtTokenProvider.getUserId(refreshTokenValue); + String savedToken = refreshTokenStore.getRefreshToken(String.valueOf(userId)); + + if (savedToken == null || !savedToken.equals(refreshTokenValue)) { + refreshTokenStore.removeRefreshToken(String.valueOf(userId)); + throw new AppException(ErrorCode.STOLEN_REFRESH_TOKEN); + } + + // 새 토큰 세트 생성 + String newAccessToken = jwtTokenProvider.createAccessToken(userId); + String newRefreshToken = jwtTokenProvider.createRefreshToken(userId); + + // Redis 업데이트 + refreshTokenStore.saveRefreshToken(String.valueOf(userId), newRefreshToken, jwtTokenProvider.getRefreshTokenExpireTime()); + + return new TokenRefreshResDto(newAccessToken, newRefreshToken); + } + + @Transactional + public void logout(Long userId) { + refreshTokenStore.removeRefreshToken(String.valueOf(userId)); + } + + @Transactional + public void withdraw(Long userId) { + refreshTokenStore.removeRefreshToken(String.valueOf(userId)); + userRepository.deleteById(userId); + } +} From 95749aba27b4bc7a32191deb18b5761b207c6e07 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:09:05 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8/=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/google/GoogleApiClient.java | 13 +++ .../auth/client/google/GoogleAuthClient.java | 19 ++++ .../auth/dto/response/GoogleTokenResDto.java | 10 +++ .../dto/response/GoogleUserInfoResDto.java | 8 ++ .../auth/service/GoogleLoginService.java | 86 +++++++++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 src/main/java/com/ongil/backend/domain/auth/client/google/GoogleApiClient.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleTokenResDto.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleUserInfoResDto.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java diff --git a/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleApiClient.java b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleApiClient.java new file mode 100644 index 0000000..94580eb --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleApiClient.java @@ -0,0 +1,13 @@ +package com.ongil.backend.domain.auth.client.google; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import com.ongil.backend.domain.auth.dto.response.GoogleUserInfoResDto; + +@FeignClient(name = "googleApiClient", url = "https://www.googleapis.com") +public interface GoogleApiClient { + @GetMapping("/oauth2/v3/userinfo") + GoogleUserInfoResDto getUserInfo(@RequestHeader("Authorization") String accessToken); +} diff --git a/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java new file mode 100644 index 0000000..844a2a8 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java @@ -0,0 +1,19 @@ +package com.ongil.backend.domain.auth.client.google; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ongil.backend.domain.auth.dto.response.GoogleTokenResDto; + +@FeignClient(name = "googleAuthClient", url = "https://oauth2.googleapis.com") +public interface GoogleAuthClient { + @PostMapping("/token") + GoogleTokenResDto getAccessToken( + @RequestParam("grant_type") String grantType, + @RequestParam("client_id") String clientId, + @RequestParam("client_secret") String clientSecret, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("code") String code + ); +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleTokenResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleTokenResDto.java new file mode 100644 index 0000000..ca9c918 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleTokenResDto.java @@ -0,0 +1,10 @@ +package com.ongil.backend.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenResDto( + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") Integer expiresIn, + @JsonProperty("token_type") String tokenType, + @JsonProperty("id_token") String idToken +) {} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleUserInfoResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleUserInfoResDto.java new file mode 100644 index 0000000..88f6f18 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleUserInfoResDto.java @@ -0,0 +1,8 @@ +package com.ongil.backend.domain.auth.dto.response; + +public record GoogleUserInfoResDto( + String sub, // 구글의 고유 식별자 (카카오의 id 역할) + String name, + String email, + String picture +) {} diff --git a/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java b/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java new file mode 100644 index 0000000..6d0e1a4 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java @@ -0,0 +1,86 @@ +package com.ongil.backend.domain.auth.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ongil.backend.domain.auth.client.google.GoogleApiClient; +import com.ongil.backend.domain.auth.client.google.GoogleAuthClient; +import com.ongil.backend.domain.auth.converter.AuthConverter; +import com.ongil.backend.domain.auth.dto.response.AuthResDto; +import com.ongil.backend.domain.auth.dto.response.GoogleTokenResDto; +import com.ongil.backend.domain.auth.dto.response.GoogleUserInfoResDto; +import com.ongil.backend.domain.auth.entity.LoginType; +import com.ongil.backend.domain.user.entity.User; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.config.redis.RedisRefreshTokenStore; +import com.ongil.backend.global.security.jwt.JwtTokenProvider; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class GoogleLoginService { + + @Value("${google.client-id}") + private String googleclientId; + @Value("${google.client-secret}") + private String googleclientSecret; + @Value("${google.redirect-uri}") + private String googleredirectUri; + @Value("${jwt.access-expiration-ms}") + private long accessExpMs; + + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final GoogleAuthClient googleAuthClient; + private final GoogleApiClient googleApiClient; + private final RedisRefreshTokenStore refreshTokenStore; + + public AuthResDto googleLogin(String code) { + String googleToken = getGoogleAccessToken(code); + + GoogleUserInfoResDto userInfo = getGoogleUserInfo(googleToken); + String socialId = userInfo.sub(); + + boolean isNewUser = !userRepository.existsByLoginTypeAndSocialId(LoginType.GOOGLE, socialId); + + User user = userRepository.findByLoginTypeAndSocialId(LoginType.GOOGLE, socialId) + .orElseGet(() -> userRepository.save( + User.builder() + .loginType(LoginType.GOOGLE) + .socialId(socialId) + .email(userInfo.email()) + .profileImg(userInfo.picture()) + .name(userInfo.name()) + .build() + )); + + String accessToken = jwtTokenProvider.createAccessToken(user.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + + refreshTokenStore.saveRefreshToken( + String.valueOf(user.getId()), + refreshToken, + jwtTokenProvider.getRefreshTokenExpireTime() + ); + + return AuthConverter.toResponse(user, accessToken, refreshToken, isNewUser, accessExpMs); + } + + private String getGoogleAccessToken(String code) { + GoogleTokenResDto token = googleAuthClient.getAccessToken( + "authorization_code", + googleclientId, + googleclientSecret, + googleredirectUri, + code + ); + return token.accessToken(); + } + + private GoogleUserInfoResDto getGoogleUserInfo(String accessToken) { + return googleApiClient.getUserInfo("Bearer " + accessToken); + } +} From 7d23adf125d8e501eea7a4ecaaceb21f440b1c93 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:09:12 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8/=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/kakao/KakaoApiClient.java | 15 +++ .../auth/client/kakao/KakaoAuthClient.java | 21 ++++ .../auth/dto/response/KakaoTokenResDto.java | 13 ++ .../auth/service/KakaoLoginService.java | 116 ++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoApiClient.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoAuthClient.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoTokenResDto.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java diff --git a/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoApiClient.java b/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoApiClient.java new file mode 100644 index 0000000..4d9ecf3 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoApiClient.java @@ -0,0 +1,15 @@ +package com.ongil.backend.domain.auth.client.kakao; + + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import com.ongil.backend.domain.auth.dto.response.KakaoUserInfoResDto; + +// 유저 정보/API용 (kapi.kakao.com) +@FeignClient(name = "kakaoApiClient", url = "https://kapi.kakao.com") +public interface KakaoApiClient { + @GetMapping("/v2/user/me") + KakaoUserInfoResDto getUserInfo(@RequestHeader("Authorization") String bearerToken); +} diff --git a/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoAuthClient.java b/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoAuthClient.java new file mode 100644 index 0000000..cd8922c --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoAuthClient.java @@ -0,0 +1,21 @@ +package com.ongil.backend.domain.auth.client.kakao; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ongil.backend.domain.auth.dto.response.KakaoTokenResDto; + +// 인증/토큰용 (kauth.kakao.com) +@FeignClient(name = "kakaoAuthClient", url = "https://kauth.kakao.com") +public interface KakaoAuthClient { + @PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + KakaoTokenResDto getAccessToken( + @RequestParam("grant_type") String grantType, + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("code") String code, + @RequestParam("client_secret") String clientSecret + ); +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoTokenResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoTokenResDto.java new file mode 100644 index 0000000..1ec7232 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoTokenResDto.java @@ -0,0 +1,13 @@ +package com.ongil.backend.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoTokenResDto( + @JsonProperty("access_token") String accessToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("expires_in") Integer expiresIn, + @JsonProperty("scope") String scope, + @JsonProperty("refresh_token_expires_in") Integer refreshTokenExpiresIn +) { +} diff --git a/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java b/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java new file mode 100644 index 0000000..b79cf8b --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java @@ -0,0 +1,116 @@ +package com.ongil.backend.domain.auth.service; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.ongil.backend.domain.auth.converter.AuthConverter; +import com.ongil.backend.domain.auth.dto.response.AuthResDto; +import com.ongil.backend.domain.auth.dto.response.KakaoTokenResDto; +import com.ongil.backend.domain.auth.dto.response.KakaoUserInfoResDto; +import com.ongil.backend.domain.auth.entity.LoginType; +import com.ongil.backend.domain.auth.client.kakao.KakaoApiClient; +import com.ongil.backend.domain.auth.client.kakao.KakaoAuthClient; +import com.ongil.backend.domain.user.entity.User; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.config.redis.RedisRefreshTokenStore; +import com.ongil.backend.global.security.jwt.JwtTokenProvider; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class KakaoLoginService { + + @Value("${kakao.client-id}") + private String kakaoclientId; + @Value("${kakao.client-secret}") + private String kakaoclientSecret; + @Value("${kakao.redirect-uri}") + private String kakaoredirectUri; + @Value("${jwt.access-expiration-ms}") + private long accessExpMs; + + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final KakaoAuthClient kakaoAuthClient; + private final KakaoApiClient kakaoApiClient; + private final RedisRefreshTokenStore refreshTokenStore; + + public AuthResDto kakaoLogin(String code) { + // 카카오 Access Token + String kakaoToken = getKakaoAccessToken(code); + + // 카카오 사용자 정보 + KakaoUserInfoResDto userInfo = getKakaoUserInfo(kakaoToken); + + String socialId = userInfo.id().toString(); + + // 신규 유저 확인 + boolean isNewUser = !userRepository.existsByLoginTypeAndSocialId(LoginType.KAKAO, socialId); + + User user = userRepository.findByLoginTypeAndSocialId(LoginType.KAKAO, socialId) + .orElseGet(() -> userRepository.save( + User.builder() + .loginType(LoginType.KAKAO) + .socialId(socialId) + .email(extractEmail(userInfo)) + .profileImg(extractProfileImg(userInfo)) + .name(extractNickname(userInfo)) + .build() + )); + + // JWT 발급 + String accessToken = jwtTokenProvider.createAccessToken(user.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + + refreshTokenStore.saveRefreshToken( + String.valueOf(user.getId()), + refreshToken, + jwtTokenProvider.getRefreshTokenExpireTime() + ); + + return AuthConverter.toResponse(user, accessToken, refreshToken, isNewUser, accessExpMs); + } + + // kakaoAuthClient를 사용하여 카카오로부터 access_token을 발급 + private String getKakaoAccessToken(String code) { + KakaoTokenResDto token = kakaoAuthClient.getAccessToken( + "authorization_code", + kakaoclientId, + kakaoredirectUri, + code, + kakaoclientSecret + ); + return token.accessToken(); + } + + // 카카오로부터 사용자 프로필 정보 추출 + private KakaoUserInfoResDto getKakaoUserInfo(String accessToken) { + return kakaoApiClient.getUserInfo("Bearer " + accessToken); + } + + private String extractNickname(KakaoUserInfoResDto userInfo) { + return Optional.ofNullable(userInfo.kakaoAccount()) + .map(KakaoUserInfoResDto.KakaoAccount::profile) + .map(KakaoUserInfoResDto.KakaoAccount.Profile::nickname) + .orElse("kakao_user_" + userInfo.id()); // 닉네임 없으면 기본값 + } + + private String extractEmail(KakaoUserInfoResDto userInfo) { + if (userInfo.kakaoAccount() != null && userInfo.kakaoAccount().email() != null) { + return userInfo.kakaoAccount().email(); + } + + // 이메일이 없을 시 임시 이메일 생성 (디비 제약조건) + return userInfo.id() + "@kakao.user"; + } + + private String extractProfileImg(KakaoUserInfoResDto userInfo) { + return Optional.ofNullable(userInfo.kakaoAccount()) + .map(KakaoUserInfoResDto.KakaoAccount::profile) + .map(KakaoUserInfoResDto.KakaoAccount.Profile::profileImg) + .orElse(null); + } +} From 81b2118978e3096a567527726de75054b800e44d Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:09:54 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=9C=84=ED=95=9C=20=EB=A0=88=EB=94=94=EC=8A=A4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/redis/RedisConfig.java | 30 ++++++++++++++ .../config/redis/RedisRefreshTokenStore.java | 41 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/main/java/com/ongil/backend/global/config/redis/RedisConfig.java create mode 100644 src/main/java/com/ongil/backend/global/config/redis/RedisRefreshTokenStore.java diff --git a/src/main/java/com/ongil/backend/global/config/redis/RedisConfig.java b/src/main/java/com/ongil/backend/global/config/redis/RedisConfig.java new file mode 100644 index 0000000..30f23a6 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/config/redis/RedisConfig.java @@ -0,0 +1,30 @@ +package com.ongil.backend.global.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public StringRedisTemplate stringRedisTemplate( + RedisConnectionFactory connectionFactory + ) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/src/main/java/com/ongil/backend/global/config/redis/RedisRefreshTokenStore.java b/src/main/java/com/ongil/backend/global/config/redis/RedisRefreshTokenStore.java new file mode 100644 index 0000000..034c5bf --- /dev/null +++ b/src/main/java/com/ongil/backend/global/config/redis/RedisRefreshTokenStore.java @@ -0,0 +1,41 @@ +package com.ongil.backend.global.config.redis; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class RedisRefreshTokenStore { + + private final StringRedisTemplate stringRedisTemplate; + private static final String KEY_PREFIX = "RT:"; + + /** + * 리프레시 토큰 저장 (RTR 전략: 기존 키가 있으면 덮어쓰기) + * @param userId 유저 식별자 + * @param refreshToken 발급된 리프레시 토큰 + * @param expiryTimeMillis 토큰의 만료 시간 (ms) + */ + public void saveRefreshToken(String userId, String refreshToken, long expiryTimeMillis) { + stringRedisTemplate.opsForValue().set( + KEY_PREFIX + userId, + refreshToken, + expiryTimeMillis, + TimeUnit.MILLISECONDS + ); + } + + // 저장된 리프레시 토큰 조회 + public String getRefreshToken(String userId) { + return stringRedisTemplate.opsForValue().get(KEY_PREFIX + userId); + } + + // 리프레시 토큰 삭제 + public void removeRefreshToken(String userId) { + stringRedisTemplate.delete(KEY_PREFIX + userId); + } +} From 42c86a3ee6128e42f54ecaabfbf4ca31fd22cd8b Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:10:22 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20dto=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/request/TokenRefreshReqDto.java | 11 +++++++++++ .../auth/dto/response/KakaoUserInfoResDto.java | 18 ++++++++++++++++++ .../auth/dto/response/TokenRefreshResDto.java | 7 +++++++ 3 files changed, 36 insertions(+) create mode 100644 src/main/java/com/ongil/backend/domain/auth/dto/request/TokenRefreshReqDto.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoUserInfoResDto.java create mode 100644 src/main/java/com/ongil/backend/domain/auth/dto/response/TokenRefreshResDto.java diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/request/TokenRefreshReqDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/request/TokenRefreshReqDto.java new file mode 100644 index 0000000..86af137 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/request/TokenRefreshReqDto.java @@ -0,0 +1,11 @@ +package com.ongil.backend.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record TokenRefreshReqDto( + @Schema(description = "리프레시토큰") + @NotNull + String refreshToken +) { +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoUserInfoResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoUserInfoResDto.java new file mode 100644 index 0000000..654d8b0 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoUserInfoResDto.java @@ -0,0 +1,18 @@ +package com.ongil.backend.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoUserInfoResDto( + Long id, + @JsonProperty("kakao_account") KakaoAccount kakaoAccount +) { + public record KakaoAccount( + String email, + Profile profile + ) { + public record Profile( + String nickname, + @JsonProperty("thumbnail_image_url") String profileImg + ) {} + } +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/TokenRefreshResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/TokenRefreshResDto.java new file mode 100644 index 0000000..b5ac99a --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/TokenRefreshResDto.java @@ -0,0 +1,7 @@ +package com.ongil.backend.domain.auth.dto.response; + +public record TokenRefreshResDto( + String accessToken, + String refreshToken +) { +} From 32b2920d02ca02d97c9fab9b147ff868cd55c23f Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:10:40 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 44 +++++++++++++++++++ .../domain/user/converter/UserConverter.java | 24 ++++++++++ .../user/dto/response/UserInfoResDto.java | 39 ++++++++++++++++ .../backend/domain/user/entity/User.java | 38 +++++----------- .../backend/domain/user/enums/UserRole.java | 13 ------ .../user/repository/UserRepository.java | 9 ++++ .../domain/user/service/UserService.java | 24 ++++++++++ 7 files changed, 151 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/ongil/backend/domain/user/controller/UserController.java create mode 100644 src/main/java/com/ongil/backend/domain/user/converter/UserConverter.java create mode 100644 src/main/java/com/ongil/backend/domain/user/dto/response/UserInfoResDto.java delete mode 100644 src/main/java/com/ongil/backend/domain/user/enums/UserRole.java create mode 100644 src/main/java/com/ongil/backend/domain/user/service/UserService.java diff --git a/src/main/java/com/ongil/backend/domain/user/controller/UserController.java b/src/main/java/com/ongil/backend/domain/user/controller/UserController.java new file mode 100644 index 0000000..dff5df2 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/user/controller/UserController.java @@ -0,0 +1,44 @@ +package com.ongil.backend.domain.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ongil.backend.domain.user.dto.response.UserInfoResDto; +import com.ongil.backend.domain.user.service.UserService; +import com.ongil.backend.global.common.dto.DataResponse; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/user") +public class UserController { + + private final UserService userService; + + @GetMapping("/me") + @Operation(summary = "내 정보 조회 API", description = "현재 로그인한 사용자 정보를 조회") + public ResponseEntity> getMyInfo( + @AuthenticationPrincipal Long userId + ) { + UserInfoResDto res = userService.getUserInfo(userId); + return ResponseEntity.ok(DataResponse.from(res)); + } + + @GetMapping("/{userId}") + @Operation(summary = "특정 사용자 정보 조회 API", description = "ID를 통해 특정 사용자의 정보를 조회") + public ResponseEntity> getUserInfo( + @PathVariable(name = "userId") Long userId + ) { + UserInfoResDto res = userService.getUserInfo(userId); + return ResponseEntity.ok(DataResponse.from(res)); + } + +} diff --git a/src/main/java/com/ongil/backend/domain/user/converter/UserConverter.java b/src/main/java/com/ongil/backend/domain/user/converter/UserConverter.java new file mode 100644 index 0000000..a5afab8 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/user/converter/UserConverter.java @@ -0,0 +1,24 @@ +package com.ongil.backend.domain.user.converter; + +import com.ongil.backend.domain.user.dto.response.UserInfoResDto; +import com.ongil.backend.domain.user.entity.User; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class UserConverter { + + public static UserInfoResDto toUserInfoResDto(User user) { + return UserInfoResDto.builder() + .userId(user.getId()) + .name(user.getName()) + .loginType(user.getLoginType()) + .phone(user.getPhone()) + .profileUrl(user.getProfileImg()) + .height(user.getHeight()) + .weight(user.getWeight()) + .usualSize(user.getUsualSize()) + .points(user.getPoints()) + .build(); + } +} diff --git a/src/main/java/com/ongil/backend/domain/user/dto/response/UserInfoResDto.java b/src/main/java/com/ongil/backend/domain/user/dto/response/UserInfoResDto.java new file mode 100644 index 0000000..968a67a --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/user/dto/response/UserInfoResDto.java @@ -0,0 +1,39 @@ +package com.ongil.backend.domain.user.dto.response; + +import com.ongil.backend.domain.auth.entity.LoginType; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record UserInfoResDto( + + @Schema(description = "유저 ID") + Long userId, + + @Schema(description = "이름") + String name, + + @Schema(description = "회원가입 경로") + LoginType loginType, + + @Schema(description = "프로필 이미지 URL") + String profileUrl, + + @Schema(description = "핸드폰 번호") + String phone, + + @Schema(description = "키") + Integer height, + + @Schema(description = "몸무게") + Integer weight, + + @Schema(description = "평소 착용 사이즈") + String usualSize, + + @Schema(description = "포인트") + Integer points +) {} + + diff --git a/src/main/java/com/ongil/backend/domain/user/entity/User.java b/src/main/java/com/ongil/backend/domain/user/entity/User.java index eb824a6..60c3263 100644 --- a/src/main/java/com/ongil/backend/domain/user/entity/User.java +++ b/src/main/java/com/ongil/backend/domain/user/entity/User.java @@ -3,7 +3,7 @@ import java.util.*; import com.ongil.backend.domain.address.entity.*; -import com.ongil.backend.domain.user.enums.*; +import com.ongil.backend.domain.auth.entity.LoginType; import com.ongil.backend.global.common.entity.BaseEntity; import jakarta.persistence.*; @@ -14,6 +14,8 @@ @Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter +@AllArgsConstructor +@Builder(toBuilder = true) public class User extends BaseEntity { @Id @@ -21,6 +23,13 @@ public class User extends BaseEntity { @Column(name = "user_id") private Long id; + @Enumerated(EnumType.STRING) + @Column(name = "login_type", nullable = false) + private LoginType loginType; + + @Column(name = "social_id", nullable = false) + private String socialId; + @Column(nullable = false, unique = true, length = 100) private String email; @@ -33,12 +42,6 @@ public class User extends BaseEntity { @Column(length = 20) private String phone; - @Column(name = "social_provider", length = 20) - private String socialProvider; - - @Column(name = "social_id", length = 100) - private String socialId; - private Integer height; private Integer weight; @@ -47,28 +50,9 @@ public class User extends BaseEntity { private String usualSize; @Column(nullable = false) + @Builder.Default private Integer points = 0; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private UserRole role = UserRole.USER; - @OneToMany(mappedBy = "user") private List
addresses = new ArrayList<>(); - - @Builder - public User(String email, String name, String profileImg, String phone, String socialProvider, - String socialId, Integer height, Integer weight, String usualSize, - UserRole role) { - this.email = email; - this.name = name; - this.profileImg = profileImg; - this.phone = phone; - this.socialProvider = socialProvider; - this.socialId = socialId; - this.height = height; - this.weight = weight; - this.usualSize = usualSize; - this.role = role; - } } \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/user/enums/UserRole.java b/src/main/java/com/ongil/backend/domain/user/enums/UserRole.java deleted file mode 100644 index 20b3775..0000000 --- a/src/main/java/com/ongil/backend/domain/user/enums/UserRole.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ongil.backend.domain.user.enums; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum UserRole { - USER("일반 사용자"), - ADMIN("관리자"); - - private final String description; -} diff --git a/src/main/java/com/ongil/backend/domain/user/repository/UserRepository.java b/src/main/java/com/ongil/backend/domain/user/repository/UserRepository.java index 1eaa739..35f8ec0 100644 --- a/src/main/java/com/ongil/backend/domain/user/repository/UserRepository.java +++ b/src/main/java/com/ongil/backend/domain/user/repository/UserRepository.java @@ -1,8 +1,17 @@ package com.ongil.backend.domain.user.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import com.ongil.backend.domain.auth.entity.LoginType; import com.ongil.backend.domain.user.entity.User; public interface UserRepository extends JpaRepository { + Optional findByLoginTypeAndSocialId( + LoginType loginType, + String socialId + ); + + boolean existsByLoginTypeAndSocialId(LoginType loginType, String socialId); } \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/user/service/UserService.java b/src/main/java/com/ongil/backend/domain/user/service/UserService.java new file mode 100644 index 0000000..e484a93 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/user/service/UserService.java @@ -0,0 +1,24 @@ +package com.ongil.backend.domain.user.service; + +import org.springframework.stereotype.Service; + +import com.ongil.backend.domain.user.converter.UserConverter; +import com.ongil.backend.domain.user.dto.response.UserInfoResDto; +import com.ongil.backend.domain.user.entity.User; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.EntityNotFoundException; +import com.ongil.backend.global.common.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + public UserInfoResDto getUserInfo(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); + return UserConverter.toUserInfoResDto(user); + } +} From 7fd055a4013545803fe31d14e7bcd92f29962d13 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:10:58 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=8A=A4=EC=9B=A8=EA=B1=B0,=20security=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/config/SecurityConfig.java | 36 ++++++++++--- .../backend/global/config/SwaggerConfig.java | 52 +++++++++++++++++-- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/ongil/backend/global/config/SecurityConfig.java b/src/main/java/com/ongil/backend/global/config/SecurityConfig.java index e754933..64bd35d 100644 --- a/src/main/java/com/ongil/backend/global/config/SecurityConfig.java +++ b/src/main/java/com/ongil/backend/global/config/SecurityConfig.java @@ -4,21 +4,41 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; 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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.ongil.backend.global.security.jwt.JwtAuthenticationFilter; + +import lombok.RequiredArgsConstructor; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) // csrf().disable() 신버전 문법 - .authorizeHttpRequests(auth -> auth - .requestMatchers("/ping").permitAll() // 오류 없이 허용 - .anyRequest().permitAll() // 임시로 전체 허용 - ) - .formLogin(form -> form.disable()); // 기본 /login 페이지 비활성화 + .csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(f -> f.disable()) + .httpBasic(h -> h.disable()) + + .authorizeHttpRequests(auth -> auth + .requestMatchers("/ping").permitAll() + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/h2-console/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/auth/oauth/kakao", "/auth/token/refresh").permitAll() + .anyRequest().authenticated() + ) + + // JWT 필터 적용 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/ongil/backend/global/config/SwaggerConfig.java b/src/main/java/com/ongil/backend/global/config/SwaggerConfig.java index 02d2c3c..04a3974 100644 --- a/src/main/java/com/ongil/backend/global/config/SwaggerConfig.java +++ b/src/main/java/com/ongil/backend/global/config/SwaggerConfig.java @@ -1,22 +1,66 @@ package com.ongil.backend.global.config; +import java.util.List; + +import org.springdoc.core.properties.SwaggerUiConfigProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.Environment; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import lombok.RequiredArgsConstructor; @Configuration +@RequiredArgsConstructor public class SwaggerConfig { + private final Environment env; + @Bean public OpenAPI openAPI() { + String profile = env.getActiveProfiles().length > 0 ? env.getActiveProfiles()[0] : "local"; + + SecurityScheme accessTokenAuth = new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList("accessTokenAuth"); + + Server server = new Server(); + if ("dev".equals(profile)) { + server.setUrl("http://3.38.199.67:8080"); + server.setDescription("운영 서버"); + } else { + server.setUrl("http://localhost:8080"); + server.setDescription("로컬 서버"); + } + return new OpenAPI() .info(new Info() .title("Ongil API Documentation") - .description("Ongil 백엔드 API 문서입니다.") - .version("v1.0.0")); - + .description("온길 프로젝트 API 명세서입니다.") + .version("v1.0.0")) + .components(new Components() + .addSecuritySchemes("accessTokenAuth", accessTokenAuth)) + .addSecurityItem(securityRequirement) + .servers(List.of(server)); } -} + // Authorize 정보 유지 + @Bean + @Primary + public SwaggerUiConfigProperties swaggerUiConfigProperties(SwaggerUiConfigProperties props) { + props.setPersistAuthorization(true); + return props; + } +} \ No newline at end of file From 337f9db925a428305a7992956878dead5530d7d8 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:11:04 +0900 Subject: [PATCH 10/17] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ongil/backend/global/common/exception/ErrorCode.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java index f488b2c..7e119ff 100644 --- a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java +++ b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java @@ -17,6 +17,13 @@ public enum ErrorCode { UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다.", "COMMON-005"), FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다.", "COMMON-006"), + // AUTH + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다.","AUTH-001"), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다.","AUTH-002"), + STOLEN_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "탈취된 토큰으로 의심됩니다. 다시 로그인해주세요.", "AUTH-003"), + + // USER + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다.","USER-001") ; private final HttpStatus httpStatus; From 334eb16edc100870c9ed9c533f278f061bf917a9 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 8 Jan 2026 14:50:07 +0900 Subject: [PATCH 11/17] =?UTF-8?q?docs:=20pr=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9ed2374..aa34d17 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,5 +3,9 @@ ## ✨ 상세 설명 +## 🛠️ 추후 리팩토링 및 고도화 계획 + +## 📸 스크린샷 (선택) + ## 💬 리뷰 요구사항 \ No newline at end of file From b354c5fe1028322fb1b0968d58719ffe740980e1 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 9 Jan 2026 00:33:03 +0900 Subject: [PATCH 12/17] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index 2dec251..4cd12ed 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,10 @@ repositories { mavenCentral() } +ext { + set('springCloudVersion', "2023.0.1") +} + dependencies { // Spring Boot Starters implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -33,7 +37,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Boot 3.3.5에 맞춰 최신화 권장 // Lombok compileOnly 'org.projectlombok:lombok' @@ -48,16 +52,21 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // FeignClient - implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.2' + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' // JWT - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' - implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' +} +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } } tasks.named('test') { From d5cd7a05213e4180fb70ace61eeeb046482a9330 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 9 Jan 2026 00:34:31 +0900 Subject: [PATCH 13/17] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ongil/backend/domain/auth/converter/AuthConverter.java | 2 +- .../com/ongil/backend/domain/auth/dto/response/AuthResDto.java | 2 +- .../ongil/backend/domain/auth/service/KakaoLoginService.java | 3 +-- .../backend/global/security/jwt/JwtAuthenticationFilter.java | 2 -- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java b/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java index 118243b..16259d5 100644 --- a/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java +++ b/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java @@ -14,7 +14,7 @@ public static AuthResDto toResponse(User user, String accessToken, String refres .userId(user.getId()) .accessToken(accessToken) .refreshToken(refreshToken) - .LoginType(user.getLoginType()) + .loginType(user.getLoginType()) .isNewUser(isNewUser) .expires_in((int) (accessExpMs / 1000)) // 초 단위 .build(); diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java index 28c2321..eaaafe4 100644 --- a/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java @@ -22,7 +22,7 @@ public record AuthResDto( @Schema(description = "로그인 타입") @NotNull - LoginType LoginType, + LoginType loginType, @Schema(description = "회원가입 여부(첫 로그인 여부)") @NotNull diff --git a/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java b/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java index b79cf8b..27367f9 100644 --- a/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java +++ b/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java @@ -95,14 +95,13 @@ private String extractNickname(KakaoUserInfoResDto userInfo) { return Optional.ofNullable(userInfo.kakaoAccount()) .map(KakaoUserInfoResDto.KakaoAccount::profile) .map(KakaoUserInfoResDto.KakaoAccount.Profile::nickname) - .orElse("kakao_user_" + userInfo.id()); // 닉네임 없으면 기본값 + .orElse("kakao_user_" + userInfo.id()); } private String extractEmail(KakaoUserInfoResDto userInfo) { if (userInfo.kakaoAccount() != null && userInfo.kakaoAccount().email() != null) { return userInfo.kakaoAccount().email(); } - // 이메일이 없을 시 임시 이메일 생성 (디비 제약조건) return userInfo.id() + "@kakao.user"; } diff --git a/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java index d087476..b2e60e1 100644 --- a/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -24,7 +24,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; - private final UserRepository userRepository; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -41,7 +40,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse List authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); // 4. SecurityContext에 인증 정보 저장 (이 요청이 실행되는 동안만 유효) SecurityContextHolder.getContext().setAuthentication(auth); From 0e5ae6b0ac0481450f2d244bd8bbf758faa2b39c Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 9 Jan 2026 00:39:33 +0900 Subject: [PATCH 14/17] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../auth/client/google/GoogleAuthClient.java | 3 +- .../auth/controller/AuthController.java | 3 +- .../auth/service/GoogleLoginService.java | 32 +++++++++++++---- .../backend/global/config/SecurityConfig.java | 1 + .../global/security/jwt/JwtTokenProvider.java | 35 +++++++++---------- 6 files changed, 48 insertions(+), 28 deletions(-) diff --git a/build.gradle b/build.gradle index 4cd12ed..f2c304d 100644 --- a/build.gradle +++ b/build.gradle @@ -37,7 +37,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Boot 3.3.5에 맞춰 최신화 권장 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Lombok compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java index 844a2a8..4137ae9 100644 --- a/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java +++ b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java @@ -1,6 +1,7 @@ package com.ongil.backend.domain.auth.client.google; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -8,7 +9,7 @@ @FeignClient(name = "googleAuthClient", url = "https://oauth2.googleapis.com") public interface GoogleAuthClient { - @PostMapping("/token") + @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) GoogleTokenResDto getAccessToken( @RequestParam("grant_type") String grantType, @RequestParam("client_id") String clientId, diff --git a/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java b/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java index 31f3bc6..c3d6235 100644 --- a/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java @@ -4,6 +4,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -43,7 +44,7 @@ public ResponseEntity> kakaoLogin( return ResponseEntity.ok(DataResponse.from(res)); } - @PostMapping("/oauth/google") + @GetMapping("/oauth/google") @Operation(summary = "구글 회원가입/로그인 API", description = "인가코드(code)로 구글 토큰 교환 후, 우리 서비스 JWT 발급") public ResponseEntity> googleLogin( @Valid @RequestParam("code") @NotBlank String code diff --git a/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java b/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java index 6d0e1a4..3902d5b 100644 --- a/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java +++ b/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java @@ -1,8 +1,9 @@ package com.ongil.backend.domain.auth.service; +import java.util.Optional; + import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import com.ongil.backend.domain.auth.client.google.GoogleApiClient; import com.ongil.backend.domain.auth.client.google.GoogleAuthClient; @@ -20,7 +21,6 @@ @Service @RequiredArgsConstructor -@Transactional public class GoogleLoginService { @Value("${google.client-id}") @@ -40,7 +40,6 @@ public class GoogleLoginService { public AuthResDto googleLogin(String code) { String googleToken = getGoogleAccessToken(code); - GoogleUserInfoResDto userInfo = getGoogleUserInfo(googleToken); String socialId = userInfo.sub(); @@ -51,9 +50,9 @@ public AuthResDto googleLogin(String code) { User.builder() .loginType(LoginType.GOOGLE) .socialId(socialId) - .email(userInfo.email()) - .profileImg(userInfo.picture()) - .name(userInfo.name()) + .email(extractEmail(userInfo)) + .profileImg(extractProfileImg(userInfo)) + .name(extractName(userInfo)) .build() )); @@ -83,4 +82,23 @@ private String getGoogleAccessToken(String code) { private GoogleUserInfoResDto getGoogleUserInfo(String accessToken) { return googleApiClient.getUserInfo("Bearer " + accessToken); } -} + + private String extractName(GoogleUserInfoResDto userInfo) { + return Optional.ofNullable(userInfo.name()) + .filter(name -> !name.isBlank()) + .orElse("google_user_" + userInfo.sub()); + } + + private String extractEmail(GoogleUserInfoResDto userInfo) { + if (userInfo.email() != null && !userInfo.email().isBlank()) { + return userInfo.email(); + } + return userInfo.sub() + "@google.user"; + } + + private String extractProfileImg(GoogleUserInfoResDto userInfo) { + return Optional.ofNullable(userInfo.picture()) + .filter(img -> !img.isBlank()) + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/global/config/SecurityConfig.java b/src/main/java/com/ongil/backend/global/config/SecurityConfig.java index 64bd35d..5b2345d 100644 --- a/src/main/java/com/ongil/backend/global/config/SecurityConfig.java +++ b/src/main/java/com/ongil/backend/global/config/SecurityConfig.java @@ -31,6 +31,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers("/ping").permitAll() + .requestMatchers("/auth/logout", "/auth/withdraw").authenticated() .requestMatchers("/auth/**").permitAll() .requestMatchers("/h2-console/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/auth/oauth/kakao", "/auth/token/refresh").permitAll() diff --git a/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java index 9d17d39..be47d82 100644 --- a/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java @@ -1,15 +1,15 @@ package com.ongil.backend.global.security.jwt; import java.nio.charset.StandardCharsets; -import java.security.Key; import java.util.Date; +import javax.crypto.SecretKey; + import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; @@ -29,7 +29,7 @@ public class JwtTokenProvider { @Value("${jwt.refresh-expiration-ms}") private long refreshExpMs; - private Key key; + private SecretKey key; @PostConstruct void init() { @@ -39,27 +39,26 @@ void init() { public String createAccessToken(Long userId) { Date now = new Date(); return Jwts.builder() - .setIssuer(issuer) - .setSubject(String.valueOf(userId)) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + accessExpMs)) - .signWith(key, SignatureAlgorithm.HS256) + .issuer(issuer) + .subject(String.valueOf(userId)) + .issuedAt(now) + .expiration(new Date(now.getTime() + accessExpMs)) + .signWith(key) .compact(); } public String createRefreshToken(Long userId) { Date now = new Date(); return Jwts.builder() - .setIssuer(issuer) - .setSubject(String.valueOf(userId)) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + refreshExpMs)) + .issuer(issuer) + .subject(String.valueOf(userId)) + .issuedAt(now) + .expiration(new Date(now.getTime() + refreshExpMs)) .claim("type", "refresh") - .signWith(key, SignatureAlgorithm.HS256) + .signWith(key) .compact(); } - // 토큰 유효성 검증 public boolean validateToken(String token) { try { parseClaims(token); @@ -87,11 +86,11 @@ public long getRefreshTokenExpireTime() { } private Claims parseClaims(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) + return Jwts.parser() + .verifyWith(key) .requireIssuer(issuer) .build() - .parseClaimsJws(token) - .getBody(); + .parseSignedClaims(token) + .getPayload(); } } \ No newline at end of file From caeaff730f4e3af36a86df6d500b31a5d44fa749 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 9 Jan 2026 00:40:15 +0900 Subject: [PATCH 15/17] =?UTF-8?q?feat:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4,=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ongil/backend/domain/user/entity/User.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/user/entity/User.java b/src/main/java/com/ongil/backend/domain/user/entity/User.java index 60c3263..4717e04 100644 --- a/src/main/java/com/ongil/backend/domain/user/entity/User.java +++ b/src/main/java/com/ongil/backend/domain/user/entity/User.java @@ -11,7 +11,15 @@ import lombok.*; @Entity -@Table(name = "users") +@Table( + name = "users", + indexes = { // 조회 성능 향상 + @Index(name = "idx_login_type_social_id", columnList = "login_type,social_id") + }, + uniqueConstraints = { // 중복 가입 방지 + @UniqueConstraint(name = "uk_login_type_social_id", columnNames = {"login_type", "social_id"}) + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @AllArgsConstructor @@ -53,6 +61,7 @@ public class User extends BaseEntity { @Builder.Default private Integer points = 0; - @OneToMany(mappedBy = "user") + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List
addresses = new ArrayList<>(); } \ No newline at end of file From d05fc99baaba3da2f7ea5cedbf0895a82f89d1a2 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 9 Jan 2026 01:06:57 +0900 Subject: [PATCH 16/17] =?UTF-8?q?feat:=20Cors=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/config/CorsConfig.java | 48 +++++++++++++++++++ .../backend/global/config/SecurityConfig.java | 17 +++---- .../security/jwt/JwtAuthenticationFilter.java | 2 - 3 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/ongil/backend/global/config/CorsConfig.java diff --git a/src/main/java/com/ongil/backend/global/config/CorsConfig.java b/src/main/java/com/ongil/backend/global/config/CorsConfig.java new file mode 100644 index 0000000..8e7cc99 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/config/CorsConfig.java @@ -0,0 +1,48 @@ +package com.ongil.backend.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + private static final List ALLOWED_ORIGINS = List.of( + "https://ongil-fe.vercel.app", + "http://localhost:3000", + "http://3.38.199.67:8080", + "http://localhost:8080" + ); + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(ALLOWED_ORIGINS.toArray(new String[0])) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .exposedHeaders("Authorization", "accessToken", "refreshToken") + .allowCredentials(true) + .maxAge(3600); + } + + // SecurityFilterChain에서 사용할 Bean + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(ALLOWED_ORIGINS); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Authorization", "accessToken", "refreshToken")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/global/config/SecurityConfig.java b/src/main/java/com/ongil/backend/global/config/SecurityConfig.java index 5b2345d..5c4ef1d 100644 --- a/src/main/java/com/ongil/backend/global/config/SecurityConfig.java +++ b/src/main/java/com/ongil/backend/global/config/SecurityConfig.java @@ -2,12 +2,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; import com.ongil.backend.global.security.jwt.JwtAuthenticationFilter; @@ -19,22 +20,22 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CorsConfigurationSource corsConfigurationSource; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) - .cors(Customizer.withDefaults()) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .csrf(AbstractHttpConfigurer::disable) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(f -> f.disable()) - .httpBasic(h -> h.disable()) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers("/ping").permitAll() + .requestMatchers("/ping", "/h2-console/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/auth/logout", "/auth/withdraw").authenticated() + .requestMatchers("/auth/oauth/kakao", "/auth/oauth/google", "/auth/token/refresh").permitAll() .requestMatchers("/auth/**").permitAll() - .requestMatchers("/h2-console/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() - .requestMatchers("/auth/oauth/kakao", "/auth/token/refresh").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java index b2e60e1..42150ee 100644 --- a/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -16,8 +16,6 @@ import java.io.IOException; import java.util.List; -import com.ongil.backend.domain.user.repository.UserRepository; - // 이후 모든 요청에서 JWT를 검증하고 인증 상태로 만들어줌 @Component @RequiredArgsConstructor From 3f327ebba855b28d2ae1e080f61cf94c5059f569 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 9 Jan 2026 01:27:38 +0900 Subject: [PATCH 17/17] =?UTF-8?q?chore:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../domain/auth/service/GoogleLoginService.java | 8 ++++++++ .../backend/domain/auth/service/KakaoLoginService.java | 10 +++++++++- .../com/ongil/backend/domain/user/entity/User.java | 2 +- .../backend/global/common/exception/ErrorCode.java | 1 + 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index f2c304d..909844d 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ repositories { } ext { - set('springCloudVersion', "2023.0.1") + set('springCloudVersion', "2023.0.2") } dependencies { diff --git a/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java b/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java index 3902d5b..c7e4d6a 100644 --- a/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java +++ b/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.ongil.backend.domain.auth.client.google.GoogleApiClient; import com.ongil.backend.domain.auth.client.google.GoogleAuthClient; @@ -14,6 +15,8 @@ import com.ongil.backend.domain.auth.entity.LoginType; import com.ongil.backend.domain.user.entity.User; import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; import com.ongil.backend.global.config.redis.RedisRefreshTokenStore; import com.ongil.backend.global.security.jwt.JwtTokenProvider; @@ -21,6 +24,7 @@ @Service @RequiredArgsConstructor +@Transactional public class GoogleLoginService { @Value("${google.client-id}") @@ -43,6 +47,10 @@ public AuthResDto googleLogin(String code) { GoogleUserInfoResDto userInfo = getGoogleUserInfo(googleToken); String socialId = userInfo.sub(); + if (socialId == null || socialId.isBlank()) { + throw new AppException(ErrorCode.INVALID_SOCIAL_USER_INFO); + } + boolean isNewUser = !userRepository.existsByLoginTypeAndSocialId(LoginType.GOOGLE, socialId); User user = userRepository.findByLoginTypeAndSocialId(LoginType.GOOGLE, socialId) diff --git a/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java b/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java index 27367f9..239366a 100644 --- a/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java +++ b/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.ongil.backend.domain.auth.converter.AuthConverter; import com.ongil.backend.domain.auth.dto.response.AuthResDto; @@ -14,6 +15,8 @@ import com.ongil.backend.domain.auth.client.kakao.KakaoAuthClient; import com.ongil.backend.domain.user.entity.User; import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; import com.ongil.backend.global.config.redis.RedisRefreshTokenStore; import com.ongil.backend.global.security.jwt.JwtTokenProvider; @@ -21,6 +24,7 @@ @Service @RequiredArgsConstructor +@Transactional public class KakaoLoginService { @Value("${kakao.client-id}") @@ -45,7 +49,11 @@ public AuthResDto kakaoLogin(String code) { // 카카오 사용자 정보 KakaoUserInfoResDto userInfo = getKakaoUserInfo(kakaoToken); - String socialId = userInfo.id().toString(); + String socialId = (userInfo.id() != null) ? userInfo.id().toString() : null; + + if (socialId == null || socialId.isBlank()) { + throw new AppException(ErrorCode.INVALID_SOCIAL_USER_INFO); + } // 신규 유저 확인 boolean isNewUser = !userRepository.existsByLoginTypeAndSocialId(LoginType.KAKAO, socialId); diff --git a/src/main/java/com/ongil/backend/domain/user/entity/User.java b/src/main/java/com/ongil/backend/domain/user/entity/User.java index 4717e04..3f742bb 100644 --- a/src/main/java/com/ongil/backend/domain/user/entity/User.java +++ b/src/main/java/com/ongil/backend/domain/user/entity/User.java @@ -61,7 +61,7 @@ public class User extends BaseEntity { @Builder.Default private Integer points = 0; - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List
addresses = new ArrayList<>(); } \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java index 7e119ff..421e01e 100644 --- a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java +++ b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다.","AUTH-001"), REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다.","AUTH-002"), STOLEN_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "탈취된 토큰으로 의심됩니다. 다시 로그인해주세요.", "AUTH-003"), + INVALID_SOCIAL_USER_INFO(HttpStatus.BAD_REQUEST, "사용자 정보가 올바르지 않습니다.", "AUTH-004"), // USER USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다.","USER-001")