Skip to content

Commit 76278e6

Browse files
authored
Merge pull request #10 from Pinit-Scheduler/feat/api-버전-넘버링-적용
Feat/api 버전 넘버링 적용
2 parents 37612f7 + aba24a4 commit 76278e6

5 files changed

Lines changed: 283 additions & 1 deletion

File tree

src/main/java/me/gg/pinit/authenticate/config/SecurityConfig.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication
4646
(request, response, ex) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or missing token")
4747
))
4848
.authorizeHttpRequests(auth -> auth
49-
.requestMatchers("/actuator/health/liveness", "/actuator/health/readiness", "/login", "/signup", "/refresh", "/login/**", "/v3/**", "/swagger-ui/**", "/async-api/**").permitAll()
49+
.requestMatchers("/actuator/health/liveness", "/actuator/health/readiness",
50+
"/login", "/signup", "/refresh", "/login/**",
51+
"/*/login", "/*/signup", "/*/refresh", "/*/login/**",
52+
"/v3/**", "/swagger-ui/**", "/async-api/**").permitAll()
5053
.anyRequest().authenticated()
5154
);
5255

src/main/java/me/gg/pinit/interfaces/member/MemberController.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.Arrays;
2828
import java.util.Collections;
2929

30+
@Deprecated
3031
@RestController
3132
@Tag(name = "회원/인증", description = "아이디/비밀번호 로그인 및 토큰 관리")
3233
public class MemberController {
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package me.gg.pinit.interfaces.member;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
5+
import io.swagger.v3.oas.annotations.enums.ParameterIn;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
7+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
8+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import jakarta.servlet.http.Cookie;
11+
import jakarta.servlet.http.HttpServletRequest;
12+
import me.gg.pinit.application.member.MemberService;
13+
import me.gg.pinit.domain.member.Member;
14+
import me.gg.pinit.infrastructure.jwt.JwtTokenProvider;
15+
import me.gg.pinit.infrastructure.jwt.TokenCookieFactory;
16+
import me.gg.pinit.interfaces.member.dto.LoginRequest;
17+
import me.gg.pinit.interfaces.member.dto.LoginResponse;
18+
import me.gg.pinit.interfaces.member.dto.SignupRequest;
19+
import org.springframework.http.HttpHeaders;
20+
import org.springframework.http.ResponseCookie;
21+
import org.springframework.http.ResponseEntity;
22+
import org.springframework.web.bind.annotation.*;
23+
24+
import java.util.Arrays;
25+
import java.util.Collections;
26+
27+
@RestController
28+
@RequestMapping("/v0")
29+
@Tag(name = "회원/인증", description = "아이디/비밀번호 로그인 및 토큰 관리")
30+
public class MemberControllerV0 {
31+
private final MemberService memberService;
32+
private final JwtTokenProvider jwtTokenProvider;
33+
private final TokenCookieFactory tokenCookieFactory;
34+
35+
public MemberControllerV0(MemberService memberService, JwtTokenProvider jwtTokenProvider, TokenCookieFactory tokenCookieFactory) {
36+
this.memberService = memberService;
37+
this.jwtTokenProvider = jwtTokenProvider;
38+
this.tokenCookieFactory = tokenCookieFactory;
39+
}
40+
41+
@PostMapping("/login")
42+
@Operation(
43+
summary = "아이디/비밀번호 로그인",
44+
description = "username, password를 받아 access token을 반환하고 refresh token은 httpOnly 쿠키로 설정합니다."
45+
)
46+
@ApiResponses({
47+
@ApiResponse(responseCode = "200", description = "로그인 성공"),
48+
@ApiResponse(responseCode = "500", description = "자격 증명 오류 등 서버 오류")
49+
})
50+
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
51+
Member member = memberService.login(loginRequest.getUsername(), loginRequest.getPassword());
52+
53+
String refreshToken = jwtTokenProvider.createRefreshToken(member.getId());
54+
String accessToken = jwtTokenProvider.createAccessToken(member.getId(), Collections.emptyList());
55+
56+
return ResponseEntity.ok()
57+
.header(HttpHeaders.SET_COOKIE, tokenCookieFactory.refreshTokenCookie(refreshToken).toString())
58+
.body(new LoginResponse(accessToken));
59+
}
60+
61+
@PostMapping("/signup")
62+
@Operation(
63+
summary = "회원가입",
64+
description = "로컬 계정 회원가입을 수행합니다."
65+
)
66+
@ApiResponses({
67+
@ApiResponse(responseCode = "200", description = "가입 완료"),
68+
@ApiResponse(responseCode = "500", description = "서버 오류")
69+
})
70+
public ResponseEntity<Void> signup(@RequestBody SignupRequest signupRequest) {
71+
memberService.signup(signupRequest.getUsername(), signupRequest.getPassword(), signupRequest.getNickname());
72+
return ResponseEntity.ok().build();
73+
}
74+
75+
@PostMapping("/refresh")
76+
@Operation(
77+
summary = "액세스 토큰 재발급",
78+
description = "refresh_token 쿠키에 담긴 리프레시 토큰만 검증하여 새로운 access/refresh token을 발급합니다. 액세스 토큰이나 다른 값이 들어있을 경우 401을 반환합니다.",
79+
parameters = {
80+
@Parameter(name = "refresh_token", in = ParameterIn.COOKIE, description = "리프레시 토큰", required = true)
81+
}
82+
)
83+
@ApiResponses({
84+
@ApiResponse(responseCode = "200", description = "재발급 성공"),
85+
@ApiResponse(responseCode = "401", description = "쿠키 없음 또는 토큰 검증 실패")
86+
})
87+
public ResponseEntity<LoginResponse> refresh(HttpServletRequest request) {
88+
if (request.getCookies() == null) {
89+
return ResponseEntity.status(401).build();
90+
}
91+
92+
String refreshToken = Arrays.stream(request.getCookies())
93+
.filter(cookie -> "refresh_token".equals(cookie.getName()))
94+
.findFirst()
95+
.map(Cookie::getValue)
96+
.orElse(null);
97+
98+
if (refreshToken == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) {
99+
return ResponseEntity.status(401).build();
100+
}
101+
102+
Long memberId = jwtTokenProvider.getMemberId(refreshToken);
103+
104+
String newAccessToken = jwtTokenProvider.createAccessToken(memberId, Collections.emptyList());
105+
106+
107+
return ResponseEntity.ok()
108+
.body(new LoginResponse(newAccessToken));
109+
}
110+
111+
@PostMapping("/logout")
112+
@Operation(
113+
summary = "로그아웃",
114+
description = "refresh_token 쿠키를 만료시켜 로그아웃 처리합니다."
115+
)
116+
@ApiResponses({
117+
@ApiResponse(responseCode = "200", description = "로그아웃 성공")
118+
})
119+
public ResponseEntity<Void> logout() {
120+
ResponseCookie expiredCookie = tokenCookieFactory.deleteRefreshTokenCookie();
121+
return ResponseEntity.ok()
122+
.header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
123+
.build();
124+
}
125+
126+
@GetMapping("/me")
127+
@Operation(
128+
summary = "로그인 확인",
129+
description = "Bearer 토큰이 유효한지 확인합니다.",
130+
security = {
131+
@SecurityRequirement(name = "bearerAuth")
132+
}
133+
)
134+
public ResponseEntity<Void> checkLogin() {
135+
return ResponseEntity.ok().build();
136+
}
137+
}

src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2Controller.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
import java.util.Collections;
3232

33+
@Deprecated
3334
@Slf4j
3435
@RestController
3536
@Tag(name = "소셜 로그인", description = "외부 OAuth2 공급자(네이버) 로그인 흐름")
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package me.gg.pinit.interfaces.oauth2;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
5+
import io.swagger.v3.oas.annotations.enums.ParameterIn;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
7+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.servlet.http.HttpServletRequest;
10+
import jakarta.servlet.http.HttpSession;
11+
import lombok.extern.slf4j.Slf4j;
12+
import me.gg.pinit.application.oauth2.Oauth2ProviderMapper;
13+
import me.gg.pinit.application.oauth2.Oauth2Service;
14+
import me.gg.pinit.domain.member.Member;
15+
import me.gg.pinit.domain.oidc.Oauth2Provider;
16+
import me.gg.pinit.infrastructure.jwt.JwtTokenProvider;
17+
import me.gg.pinit.infrastructure.jwt.TokenCookieFactory;
18+
import me.gg.pinit.interfaces.member.dto.LoginResponse;
19+
import me.gg.pinit.interfaces.oauth2.dto.OauthLoginSetting;
20+
import me.gg.pinit.interfaces.oauth2.dto.SocialLoginResult;
21+
import org.springframework.beans.factory.annotation.Value;
22+
import org.springframework.http.HttpHeaders;
23+
import org.springframework.http.ResponseEntity;
24+
import org.springframework.util.StringUtils;
25+
import org.springframework.web.bind.annotation.*;
26+
import org.springframework.web.util.UriComponentsBuilder;
27+
28+
import java.util.Collections;
29+
30+
@Slf4j
31+
@RestController
32+
@RequestMapping("/v0")
33+
@Tag(name = "소셜 로그인", description = "외부 OAuth2 공급자(네이버) 로그인 흐름")
34+
public class Oauth2ControllerV0 {
35+
private final JwtTokenProvider jwtTokenProvider;
36+
private final Oauth2Service oauth2Service;
37+
private final Oauth2ProviderMapper oauth2ProviderMapper;
38+
private final TokenCookieFactory tokenCookieFactory;
39+
private final String oauthCallbackBaseUrl;
40+
41+
public Oauth2ControllerV0(JwtTokenProvider jwtTokenProvider,
42+
Oauth2Service oauth2Service,
43+
Oauth2ProviderMapper oauth2ProviderMapper,
44+
TokenCookieFactory tokenCookieFactory,
45+
@Value("${app.frontend-base-url}") String oauthCallbackBaseUrl) {
46+
this.jwtTokenProvider = jwtTokenProvider;
47+
this.oauth2Service = oauth2Service;
48+
this.oauth2ProviderMapper = oauth2ProviderMapper;
49+
this.tokenCookieFactory = tokenCookieFactory;
50+
this.oauthCallbackBaseUrl = oauthCallbackBaseUrl;
51+
}
52+
53+
@GetMapping("/login/oauth2/authorize/{provider}")
54+
@Operation(
55+
summary = "소셜 로그인 인가 요청",
56+
description = "provider에 맞는 인가 URL로 302 리다이렉트합니다.",
57+
parameters = {
58+
@Parameter(name = "provider", in = ParameterIn.PATH, description = "소셜 로그인 공급자", example = "naver", required = true)
59+
}
60+
)
61+
@ApiResponses({
62+
@ApiResponse(responseCode = "302", description = "외부 인가 페이지로 리다이렉트"),
63+
@ApiResponse(responseCode = "500", description = "미지원 provider 등 서버 오류")
64+
})
65+
public ResponseEntity<Void> authorize(@PathVariable String provider, HttpServletRequest request) {
66+
HttpSession session = request.getSession();
67+
String sessionId = session.getId();
68+
String state = oauth2Service.generateState(sessionId);
69+
70+
OauthLoginSetting loginSetting = buildOauthLoginSetting(state, provider, request);
71+
String authorizationUri = UriComponentsBuilder.fromUri(oauth2Service.getAuthorizationUri(provider, state))
72+
.queryParam("response_type", loginSetting.getResponse_type())
73+
.queryParam("client_id", loginSetting.getClient_id())
74+
.queryParam("redirect_uri", loginSetting.getRedirect_uri())
75+
.queryParam("state", loginSetting.getState())
76+
.build()
77+
.toUriString();
78+
79+
80+
return ResponseEntity.status(302)
81+
.header(HttpHeaders.LOCATION, authorizationUri)
82+
.build();
83+
}
84+
85+
86+
@GetMapping("/login/oauth2/code/{provider}")
87+
@Operation(
88+
summary = "소셜 로그인 콜백",
89+
description = "provider 콜백에서 code/state를 받아 로그인 처리 후 토큰을 반환합니다.",
90+
parameters = {
91+
@Parameter(name = "provider", in = ParameterIn.PATH, description = "소셜 로그인 공급자", example = "naver", required = true),
92+
@Parameter(name = "code", in = ParameterIn.QUERY, description = "OAuth2 인가 코드"),
93+
@Parameter(name = "state", in = ParameterIn.QUERY, description = "CSRF 방지용 state"),
94+
@Parameter(name = "error", in = ParameterIn.QUERY, description = "provider 오류 코드"),
95+
@Parameter(name = "error_description", in = ParameterIn.QUERY, description = "provider 오류 상세")
96+
}
97+
)
98+
@ApiResponses({
99+
@ApiResponse(responseCode = "200", description = "소셜 로그인 성공"),
100+
@ApiResponse(responseCode = "400", description = "provider 오류 응답"),
101+
@ApiResponse(responseCode = "500", description = "state 검증 실패, 토큰 교환 실패 등 서버 오류")
102+
})
103+
public ResponseEntity<LoginResponse> socialLogin(@PathVariable String provider, @ModelAttribute SocialLoginResult socialLoginResult, HttpServletRequest request) {
104+
if (socialLoginResult.getError() != null) {
105+
return ResponseEntity.badRequest().build();
106+
}
107+
HttpSession session = request.getSession(false);
108+
String sessionId = session != null ? session.getId() : null;
109+
Member member = oauth2Service.login(provider, sessionId, socialLoginResult.getCode(), socialLoginResult.getState());
110+
String refreshToken = jwtTokenProvider.createRefreshToken(member.getId());
111+
String accessToken = jwtTokenProvider.createAccessToken(member.getId(), Collections.emptyList());
112+
return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, tokenCookieFactory.refreshTokenCookie(refreshToken).toString()).body(new LoginResponse(accessToken));
113+
}
114+
115+
private OauthLoginSetting buildOauthLoginSetting(String state, String provider, HttpServletRequest request) {
116+
OauthLoginSetting loginSetting = new OauthLoginSetting();
117+
Oauth2Provider oauth2Provider = oauth2ProviderMapper.get(provider);
118+
loginSetting.setResponse_type("code");
119+
loginSetting.setClient_id(oauth2Provider.getClientId());
120+
loginSetting.setRedirect_uri(resolveRedirectUri(oauth2Provider, provider, request));
121+
loginSetting.setState(state);
122+
return loginSetting;
123+
}
124+
125+
private String resolveRedirectUri(Oauth2Provider provider, String providerString, HttpServletRequest request) {
126+
String redirectUri = provider.getRedirectUri();
127+
String baseUrl = StringUtils.hasText(oauthCallbackBaseUrl) ? oauthCallbackBaseUrl : getBaseUrl(request);
128+
String replace = redirectUri
129+
.replace("{baseUrl}", baseUrl)
130+
.replace("{registrationId}", providerString);
131+
log.info("Resolved redirect URI: {}", replace);
132+
return replace;
133+
}
134+
135+
private String getBaseUrl(HttpServletRequest request) {
136+
String requestUrl = request.getRequestURL().toString();
137+
String requestUri = request.getRequestURI();
138+
return requestUrl.substring(0, requestUrl.length() - requestUri.length());
139+
}
140+
}

0 commit comments

Comments
 (0)