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() ); 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/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/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 공급자(네이버) 로그인 흐름") 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()); + } +}