-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/api 버전 넘버링 적용 #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feat/api-\uBC84\uC804-\uB118\uBC84\uB9C1-\uC801\uC6A9"
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Comment on lines
+49
to
+52
|
||
| .anyRequest().authenticated() | ||
| ); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<LoginResponse> 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<Void> 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<LoginResponse> 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<Void> 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<Void> checkLogin() { | ||
| return ResponseEntity.ok().build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Void> 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<LoginResponse> 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()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제점:
/*/login/**패턴이/v0/login/oauth2/authorize/{provider}와/v0/login/oauth2/code/{provider}경로를 매칭하지 못합니다. Spring Security의 AntPathMatcher에서**는 0개 이상의 경로 세그먼트를 매칭하지만,/*/login/**는/v0/login다음에 바로 오는 경로만 매칭합니다.영향: OAuth2 로그인 엔드포인트(
/v0/login/oauth2/authorize/{provider},/v0/login/oauth2/code/{provider})가 인증 없이 접근 불가능하여 소셜 로그인이 작동하지 않습니다.수정 제안:
/*/login/**대신/*/login/oauth2/**패턴을 추가하거나, 더 구체적으로/v0/login/oauth2/**를 permitAll 목록에 추가해야 합니다.