Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link

Copilot AI Dec 31, 2025

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 목록에 추가해야 합니다.

Suggested change
"/v3/**", "/swagger-ui/**", "/async-api/**").permitAll()
"/v0/login/oauth2/**", "/v3/**", "/swagger-ui/**", "/async-api/**").permitAll()

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +52
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: /logout 엔드포인트가 permitAll 목록에 누락되어 있습니다. 기존 코드에서는 /login, /signup, /refresh가 허용되었지만, /logout은 포함되지 않았습니다.

영향: 버전이 지정되지 않은 /logout 엔드포인트와 버전이 지정된 /v0/logout 엔드포인트 모두 인증이 필요하게 됩니다. 일반적으로 로그아웃은 인증 없이 접근 가능해야 하므로 이는 의도하지 않은 동작일 수 있습니다.

수정 제안: /logout/*/logout를 permitAll 목록에 추가하는 것을 검토해야 합니다. 로그아웃이 인증된 사용자만 수행할 수 있도록 하려는 의도라면, 이는 설계 의도일 수 있으므로 확인이 필요합니다.

Copilot uses AI. Check for mistakes.
.anyRequest().authenticated()
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.Arrays;
import java.util.Collections;

@Deprecated
@RestController
@Tag(name = "회원/인증", description = "아이디/비밀번호 로그인 및 토큰 관리")
public class MemberController {
Expand Down
137 changes: 137 additions & 0 deletions src/main/java/me/gg/pinit/interfaces/member/MemberControllerV0.java
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
Expand Up @@ -30,6 +30,7 @@

import java.util.Collections;

@Deprecated
@Slf4j
@RestController
@Tag(name = "소셜 로그인", description = "외부 OAuth2 공급자(네이버) 로그인 흐름")
Expand Down
140 changes: 140 additions & 0 deletions src/main/java/me/gg/pinit/interfaces/oauth2/Oauth2ControllerV0.java
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());
}
}