From 201d23114921023536646e40a87800697432b692 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Wed, 31 Dec 2025 17:30:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=8F=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EB=B2=84=EC=A0=84=20=EB=84=98?= =?UTF-8?q?=EB=B2=84=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/member/MemberControllerV0.java | 137 +++++++++++++++++ .../interfaces/oauth2/Oauth2ControllerV0.java | 140 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 src/main/java/me/gg/pinit/interfaces/member/MemberControllerV0.java create mode 100644 src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2ControllerV0.java diff --git a/src/main/java/me/gg/pinit/interfaces/member/MemberControllerV0.java b/src/main/java/me/gg/pinit/interfaces/member/MemberControllerV0.java new file mode 100644 index 0000000..36b2172 --- /dev/null +++ b/src/main/java/me/gg/pinit/interfaces/member/MemberControllerV0.java @@ -0,0 +1,137 @@ +package me.gg.pinit.interfaces.member; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import me.gg.pinit.application.member.MemberService; +import me.gg.pinit.domain.member.Member; +import me.gg.pinit.infrastructure.jwt.JwtTokenProvider; +import me.gg.pinit.infrastructure.jwt.TokenCookieFactory; +import me.gg.pinit.interfaces.member.dto.LoginRequest; +import me.gg.pinit.interfaces.member.dto.LoginResponse; +import me.gg.pinit.interfaces.member.dto.SignupRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.Collections; + +@RestController +@RequestMapping("/v0") +@Tag(name = "회원/인증", description = "아이디/비밀번호 로그인 및 토큰 관리") +public class MemberControllerV0 { + private final MemberService memberService; + private final JwtTokenProvider jwtTokenProvider; + private final TokenCookieFactory tokenCookieFactory; + + public MemberControllerV0(MemberService memberService, JwtTokenProvider jwtTokenProvider, TokenCookieFactory tokenCookieFactory) { + this.memberService = memberService; + this.jwtTokenProvider = jwtTokenProvider; + this.tokenCookieFactory = tokenCookieFactory; + } + + @PostMapping("/login") + @Operation( + summary = "아이디/비밀번호 로그인", + description = "username, password를 받아 access token을 반환하고 refresh token은 httpOnly 쿠키로 설정합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "500", description = "자격 증명 오류 등 서버 오류") + }) + public ResponseEntity login(@RequestBody LoginRequest loginRequest) { + Member member = memberService.login(loginRequest.getUsername(), loginRequest.getPassword()); + + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId()); + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), Collections.emptyList()); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, tokenCookieFactory.refreshTokenCookie(refreshToken).toString()) + .body(new LoginResponse(accessToken)); + } + + @PostMapping("/signup") + @Operation( + summary = "회원가입", + description = "로컬 계정 회원가입을 수행합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "가입 완료"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity signup(@RequestBody SignupRequest signupRequest) { + memberService.signup(signupRequest.getUsername(), signupRequest.getPassword(), signupRequest.getNickname()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/refresh") + @Operation( + summary = "액세스 토큰 재발급", + description = "refresh_token 쿠키에 담긴 리프레시 토큰만 검증하여 새로운 access/refresh token을 발급합니다. 액세스 토큰이나 다른 값이 들어있을 경우 401을 반환합니다.", + parameters = { + @Parameter(name = "refresh_token", in = ParameterIn.COOKIE, description = "리프레시 토큰", required = true) + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "재발급 성공"), + @ApiResponse(responseCode = "401", description = "쿠키 없음 또는 토큰 검증 실패") + }) + public ResponseEntity refresh(HttpServletRequest request) { + if (request.getCookies() == null) { + return ResponseEntity.status(401).build(); + } + + String refreshToken = Arrays.stream(request.getCookies()) + .filter(cookie -> "refresh_token".equals(cookie.getName())) + .findFirst() + .map(Cookie::getValue) + .orElse(null); + + if (refreshToken == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) { + return ResponseEntity.status(401).build(); + } + + Long memberId = jwtTokenProvider.getMemberId(refreshToken); + + String newAccessToken = jwtTokenProvider.createAccessToken(memberId, Collections.emptyList()); + + + return ResponseEntity.ok() + .body(new LoginResponse(newAccessToken)); + } + + @PostMapping("/logout") + @Operation( + summary = "로그아웃", + description = "refresh_token 쿠키를 만료시켜 로그아웃 처리합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공") + }) + public ResponseEntity logout() { + ResponseCookie expiredCookie = tokenCookieFactory.deleteRefreshTokenCookie(); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, expiredCookie.toString()) + .build(); + } + + @GetMapping("/me") + @Operation( + summary = "로그인 확인", + description = "Bearer 토큰이 유효한지 확인합니다.", + security = { + @SecurityRequirement(name = "bearerAuth") + } + ) + public ResponseEntity checkLogin() { + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2ControllerV0.java b/src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2ControllerV0.java new file mode 100644 index 0000000..25c5a42 --- /dev/null +++ b/src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2ControllerV0.java @@ -0,0 +1,140 @@ +package me.gg.pinit.interfaces.oauth2; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import me.gg.pinit.application.oauth2.Oauth2ProviderMapper; +import me.gg.pinit.application.oauth2.Oauth2Service; +import me.gg.pinit.domain.member.Member; +import me.gg.pinit.domain.oidc.Oauth2Provider; +import me.gg.pinit.infrastructure.jwt.JwtTokenProvider; +import me.gg.pinit.infrastructure.jwt.TokenCookieFactory; +import me.gg.pinit.interfaces.member.dto.LoginResponse; +import me.gg.pinit.interfaces.oauth2.dto.OauthLoginSetting; +import me.gg.pinit.interfaces.oauth2.dto.SocialLoginResult; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Collections; + +@Slf4j +@RestController +@RequestMapping("/v0") +@Tag(name = "소셜 로그인", description = "외부 OAuth2 공급자(네이버) 로그인 흐름") +public class Oauth2ControllerV0 { + private final JwtTokenProvider jwtTokenProvider; + private final Oauth2Service oauth2Service; + private final Oauth2ProviderMapper oauth2ProviderMapper; + private final TokenCookieFactory tokenCookieFactory; + private final String oauthCallbackBaseUrl; + + public Oauth2ControllerV0(JwtTokenProvider jwtTokenProvider, + Oauth2Service oauth2Service, + Oauth2ProviderMapper oauth2ProviderMapper, + TokenCookieFactory tokenCookieFactory, + @Value("${app.frontend-base-url}") String oauthCallbackBaseUrl) { + this.jwtTokenProvider = jwtTokenProvider; + this.oauth2Service = oauth2Service; + this.oauth2ProviderMapper = oauth2ProviderMapper; + this.tokenCookieFactory = tokenCookieFactory; + this.oauthCallbackBaseUrl = oauthCallbackBaseUrl; + } + + @GetMapping("/login/oauth2/authorize/{provider}") + @Operation( + summary = "소셜 로그인 인가 요청", + description = "provider에 맞는 인가 URL로 302 리다이렉트합니다.", + parameters = { + @Parameter(name = "provider", in = ParameterIn.PATH, description = "소셜 로그인 공급자", example = "naver", required = true) + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "302", description = "외부 인가 페이지로 리다이렉트"), + @ApiResponse(responseCode = "500", description = "미지원 provider 등 서버 오류") + }) + public ResponseEntity authorize(@PathVariable String provider, HttpServletRequest request) { + HttpSession session = request.getSession(); + String sessionId = session.getId(); + String state = oauth2Service.generateState(sessionId); + + OauthLoginSetting loginSetting = buildOauthLoginSetting(state, provider, request); + String authorizationUri = UriComponentsBuilder.fromUri(oauth2Service.getAuthorizationUri(provider, state)) + .queryParam("response_type", loginSetting.getResponse_type()) + .queryParam("client_id", loginSetting.getClient_id()) + .queryParam("redirect_uri", loginSetting.getRedirect_uri()) + .queryParam("state", loginSetting.getState()) + .build() + .toUriString(); + + + return ResponseEntity.status(302) + .header(HttpHeaders.LOCATION, authorizationUri) + .build(); + } + + + @GetMapping("/login/oauth2/code/{provider}") + @Operation( + summary = "소셜 로그인 콜백", + description = "provider 콜백에서 code/state를 받아 로그인 처리 후 토큰을 반환합니다.", + parameters = { + @Parameter(name = "provider", in = ParameterIn.PATH, description = "소셜 로그인 공급자", example = "naver", required = true), + @Parameter(name = "code", in = ParameterIn.QUERY, description = "OAuth2 인가 코드"), + @Parameter(name = "state", in = ParameterIn.QUERY, description = "CSRF 방지용 state"), + @Parameter(name = "error", in = ParameterIn.QUERY, description = "provider 오류 코드"), + @Parameter(name = "error_description", in = ParameterIn.QUERY, description = "provider 오류 상세") + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "소셜 로그인 성공"), + @ApiResponse(responseCode = "400", description = "provider 오류 응답"), + @ApiResponse(responseCode = "500", description = "state 검증 실패, 토큰 교환 실패 등 서버 오류") + }) + public ResponseEntity socialLogin(@PathVariable String provider, @ModelAttribute SocialLoginResult socialLoginResult, HttpServletRequest request) { + if (socialLoginResult.getError() != null) { + return ResponseEntity.badRequest().build(); + } + HttpSession session = request.getSession(false); + String sessionId = session != null ? session.getId() : null; + Member member = oauth2Service.login(provider, sessionId, socialLoginResult.getCode(), socialLoginResult.getState()); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId()); + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), Collections.emptyList()); + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, tokenCookieFactory.refreshTokenCookie(refreshToken).toString()).body(new LoginResponse(accessToken)); + } + + private OauthLoginSetting buildOauthLoginSetting(String state, String provider, HttpServletRequest request) { + OauthLoginSetting loginSetting = new OauthLoginSetting(); + Oauth2Provider oauth2Provider = oauth2ProviderMapper.get(provider); + loginSetting.setResponse_type("code"); + loginSetting.setClient_id(oauth2Provider.getClientId()); + loginSetting.setRedirect_uri(resolveRedirectUri(oauth2Provider, provider, request)); + loginSetting.setState(state); + return loginSetting; + } + + private String resolveRedirectUri(Oauth2Provider provider, String providerString, HttpServletRequest request) { + String redirectUri = provider.getRedirectUri(); + String baseUrl = StringUtils.hasText(oauthCallbackBaseUrl) ? oauthCallbackBaseUrl : getBaseUrl(request); + String replace = redirectUri + .replace("{baseUrl}", baseUrl) + .replace("{registrationId}", providerString); + log.info("Resolved redirect URI: {}", replace); + return replace; + } + + private String getBaseUrl(HttpServletRequest request) { + String requestUrl = request.getRequestURL().toString(); + String requestUri = request.getRequestURI(); + return requestUrl.substring(0, requestUrl.length() - requestUri.length()); + } +} From bb8a1606c019e0b7becd5bea59f8933acf0a77e6 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Wed, 31 Dec 2025 17:30:52 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EA=B2=BD=EB=A1=9C=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=ED=97=88=EC=9A=A9=20=EA=B2=BD=EB=A1=9C=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 --- .../java/me/gg/pinit/authenticate/config/SecurityConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/me/gg/pinit/authenticate/config/SecurityConfig.java b/src/main/java/me/gg/pinit/authenticate/config/SecurityConfig.java index 959314d..7fe4c02 100644 --- a/src/main/java/me/gg/pinit/authenticate/config/SecurityConfig.java +++ b/src/main/java/me/gg/pinit/authenticate/config/SecurityConfig.java @@ -46,7 +46,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication (request, response, ex) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or missing token") )) .authorizeHttpRequests(auth -> auth - .requestMatchers("/actuator/health/liveness", "/actuator/health/readiness", "/login", "/signup", "/refresh", "/login/**", "/v3/**", "/swagger-ui/**", "/async-api/**").permitAll() + .requestMatchers("/actuator/health/liveness", "/actuator/health/readiness", + "/login", "/signup", "/refresh", "/login/**", + "/*/login", "/*/signup", "/*/refresh", "/*/login/**", + "/v3/**", "/swagger-ui/**", "/async-api/**").permitAll() .anyRequest().authenticated() ); From aba24a4be9f9f3c4efad2bd00208fa15d5105d44 Mon Sep 17 00:00:00 2001 From: GoGradually Date: Wed, 31 Dec 2025 17:46:13 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20MemberController=20=EB=B0=8F=20Oaut?= =?UTF-8?q?h2Controller=20=ED=81=B4=EB=9E=98=EC=8A=A4=EC=97=90=20@Deprecat?= =?UTF-8?q?ed=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/me/gg/pinit/interfaces/member/MemberController.java | 1 + .../java/me/gg/pinit/interfaces/oauth2/Oauth2Controller.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/me/gg/pinit/interfaces/member/MemberController.java b/src/main/java/me/gg/pinit/interfaces/member/MemberController.java index c4c2fc5..917eece 100644 --- a/src/main/java/me/gg/pinit/interfaces/member/MemberController.java +++ b/src/main/java/me/gg/pinit/interfaces/member/MemberController.java @@ -27,6 +27,7 @@ import java.util.Arrays; import java.util.Collections; +@Deprecated @RestController @Tag(name = "회원/인증", description = "아이디/비밀번호 로그인 및 토큰 관리") public class MemberController { diff --git a/src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2Controller.java b/src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2Controller.java index a77f316..37abdcc 100644 --- a/src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2Controller.java +++ b/src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2Controller.java @@ -30,6 +30,7 @@ import java.util.Collections; +@Deprecated @Slf4j @RestController @Tag(name = "소셜 로그인", description = "외부 OAuth2 공급자(네이버) 로그인 흐름")