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
121 changes: 101 additions & 20 deletions src/main/java/project/flipnote/auth/controller/OAuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,31 @@

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.auth.constants.OAuthConstants;
import project.flipnote.auth.exception.AuthErrorCode;
import project.flipnote.auth.model.AuthorizationRedirect;
import project.flipnote.auth.model.TokenPair;
import project.flipnote.auth.service.OAuthService;
import project.flipnote.common.config.ClientProperties;
import project.flipnote.common.exception.BizException;
import project.flipnote.common.security.dto.UserAuth;
import project.flipnote.common.security.jwt.JwtConstants;
import project.flipnote.common.security.jwt.JwtProperties;
import project.flipnote.common.util.CookieUtil;

@Slf4j
@RequiredArgsConstructor
Expand All @@ -30,14 +37,16 @@ public class OAuthController {

private final OAuthService oAuthService;
private final ClientProperties clientProperties;
private final JwtProperties jwtProperties;
private final CookieUtil cookieUtil;

@GetMapping("/oauth2/authorization/{provider}")
public ResponseEntity<Void> redirectToProviderAuthorization(
@PathVariable("provider") String provider,
HttpServletRequest request,
@AuthenticationPrincipal UserAuth userAuth
) {
AuthorizationRedirect authRedirect = oAuthService.getAuthorizationUri(provider, request, userAuth.userId());
AuthorizationRedirect authRedirect = oAuthService.getAuthorizationUri(provider, request, userAuth);

return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.SET_COOKIE, authRedirect.cookie().toString())
Expand All @@ -49,31 +58,103 @@ public ResponseEntity<Void> redirectToProviderAuthorization(
public ResponseEntity<Void> handleCallback(
@PathVariable("provider") String provider,
@RequestParam("code") String code,
@RequestParam("state") String state,
@RequestParam(name = "state", required = false) String state,
@CookieValue(OAuthConstants.VERIFIER_COOKIE_NAME) String codeVerifier,
HttpServletRequest request
) {
String redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_SUCCESS);
boolean isSocialLinkRequest = StringUtils.hasText(state);
if (isSocialLinkRequest) {
return handleSocialLink(provider, code, state, codeVerifier, request);
}
return handleSocialLogin(provider, code, codeVerifier, request);
}

private ResponseEntity<Void> handleSocialLink(
String provider,
String code,
String state,
String codeVerifier,
HttpServletRequest request
) {
URI location;
try {
oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request);
} catch (BizException exception) {
if (exception.getErrorCode() == AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) {
redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_CONFLICT);
} else {
redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_FAILURE);
}
log.warn("BizException handled: code={}, status={}, message={}",
exception.getErrorCode().getCode(),
exception.getErrorCode().getStatus(),
exception.getErrorCode().getMessage()
);
} catch (Exception exception) {
redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_FAILURE);
log.error("소셜 계정 연동 콜백 처리 중 예상치 못한 오류 발생. provider: {}, state: {}", provider, state, exception);
location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_SUCCESS);
} catch (BizException ex) {
location = resolveRedirectUrlForSocialLink(ex);
logBizException(ex);
} catch (Exception ex) {
location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_FAILURE);
log.error("소셜 계정 연동 콜백 처리 중 예상치 못한 오류 발생. provider: {}, state: {}", provider, state, ex);
}

return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(redirectUri))
.build();
return buildRedirectResponse(location, null);
}

private ResponseEntity<Void> handleSocialLogin(
String provider,
String code,
String codeVerifier,
HttpServletRequest request
) {
URI location;
ResponseCookie refreshTokenCookie = null;
try {
TokenPair tokenPair = oAuthService.socialLogin(provider, code, codeVerifier, request);
location = buildLoginSuccessRedirectUri(tokenPair.accessToken());
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

보안 우려: Access Token이 URL에 노출됩니다.

Access Token을 쿼리 파라미터로 전달하면 서버 로그, 브라우저 히스토리, Referer 헤더 등에 노출될 위험이 있습니다.

더 안전한 방법을 고려해보세요:

  • Fragment identifier (#) 사용
  • 임시 코드 발급 후 클라이언트에서 토큰 교환
  • Secure cookie 사용

🌐 Web query:

What are the best practices for passing JWT access tokens to client applications after OAuth login?

💡 Result:

The best practices for passing JWT access tokens to client applications after OAuth login focus on minimizing security risks while ensuring tokens are used as intended. Critical considerations involve secure transmission, proper storage, token content, and lifecycle management.

Key Best Practices:

  • Always send JWTs over HTTPS. Never transmit tokens over unsecured (HTTP) channels, as interception would allow attackers to use the token to access protected resources[2][4][7].

  • Deliver access tokens using the Authorization header. After login, provide the JWT to the client, which should then store it temporarily in memory or as securely as possible and send it back in the Authorization: Bearer <token> header with each API request[7][9].

  • Limit storage risk:

    • Web apps: Prefer storing JWTs in HttpOnly, Secure cookies to mitigate XSS exposure. Avoid localStorage and sessionStorage for access tokens, as XSS can expose these[1].
    • Mobile apps: Store tokens using secure platform-specific methods only if absolutely necessary and for very short duration—ideally, keep them in volatile memory when possible[6].
    • Single-page apps (SPA): Short-lived tokens in memory are safest, but refresh tokens and persistent storage require careful risk analysis and controls[1][6].
  • Use short-lived access tokens. Set a brief expiration time (commonly 5–15 minutes). Use refresh tokens—secured as tightly as possible—to obtain new access tokens without re-authenticating the user[1][2][3][6].

  • Minimize token content. Only include claims strictly necessary for identity and authentication (e.g., user_id, org_id). Do not include permissions, sensitive data, or information that changes frequently—authorization logic should be handled by backend or a dedicated authorization system[2][7].

  • Validate every token on every request.

    • Always check signature validity, expiration (exp claim), audience (aud), issuer (iss), and intended use of the token[1][3][7].
    • Never trust token claims without verification by the server[1][7].
  • Avoid passing tokens in URLs. Never place JWTs in query strings or URL fragments, as they can leak via logs, browser history, or referrer headers[1][10].

  • Rotate keys periodically. Update signing keys frequently and allow a short overlap period to prevent user lockout during key transitions[4].

  • Separate tokens by use. Prefer using different secrets/keys for access, refresh, and ID tokens, and make sure only the correct systems can sign and verify them[1][5].

Example Secure Flow:

  1. After OAuth login, the backend issues a JWT access token (plus a refresh token, if applicable).
  2. The backend transmits the JWT to the client via HTTPS.
  3. The client stores the access token in memory or a secure, short-lived store (e.g., an HttpOnly cookie for web apps).
  4. The client includes the JWT in the Authorization: Bearer <token> header for subsequent API requests.
  5. When the access token expires, the client uses the refresh token (kept securely) to obtain a new access token.
  6. The backend/authorization server validates each token on every request.

Caveats:

  • Storing tokens on the client always carries some risk. XSS attacks can expose tokens in localStorage/sessionStorage. Cookies help but may be vulnerable to CSRF if not configured with SameSite=Strict[1][6].
  • Never use JWTs as persistent sessions. Use them as short-lived proofs of authentication/authorization only[1][2].
  • If handling sensitive data or operating in environments with serious security requirements (e.g., banking apps), follow stricter controls such as avoiding direct JWT storage and using opaque tokens with backend introspection[6].

By following these practices, you minimize both the risk of token compromise and the potential damage if a token is stolen or misused.

Citations:


보안 우려: Access Token URL 노출 방지 필요

Access Token을 쿼리 파라미터로 전달하면 서버 로그, 브라우저 히스토리, Referer 헤더 등에 토큰이 노출될 수 있습니다. 아래와 같이 안전한 전달 방법으로 변경하세요.

수정 필요 위치

  • src/main/java/project/flipnote/auth/controller/OAuthController.java:104

권장 대체 방안

  • HttpOnly, Secure 속성의 쿠키에 토큰 저장
  • 클라이언트가 Authorization: Bearer <token> 헤더로 전송
  • OAuth 임시 인가 코드(authorization code) 발급 후 클라이언트에서 토큰 교환

예시 패치

- location = buildLoginSuccessRedirectUri(tokenPair.accessToken());
+ // 1) 쿠키에 저장하고 리디렉션 URI에는 토큰을 포함하지 않음
+ String redirectUri = buildLoginSuccessRedirectUri(null);
+ ResponseCookie cookie = ResponseCookie.from("access_token", tokenPair.accessToken())
+     .httpOnly(true).secure(true).path("/").maxAge(accessTokenTtl).build();
+ response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
+ location = redirectUri;

—or—

- location = buildLoginSuccessRedirectUri(tokenPair.accessToken());
+ // 2) URL fragment 방식을 사용해 쿼리 파라미터에 노출되지 않도록 변경
+ String fragment = "#access_token=" + tokenPair.accessToken();
+ location = buildLoginSuccessRedirectUri(fragment);

위 중 하나를 선택해 구현하시고, 클라이언트에서도 해당 방식에 맞춰 토큰을 안전하게 처리하도록 변경해 주세요.

🤖 Prompt for AI Agents
In src/main/java/project/flipnote/auth/controller/OAuthController.java at line
104, the access token is currently being included in the URL query parameters,
which exposes it to security risks. To fix this, remove the token from the URL
and instead store it securely in an HttpOnly, Secure cookie set by the server,
or implement an OAuth authorization code flow where the client exchanges a
temporary code for the token. Update the server code to set the token in a
secure cookie or issue an authorization code, and ensure the client is modified
to send the token in the Authorization header or handle the code exchange
accordingly.

refreshTokenCookie = createRefreshTokenCookie(tokenPair.refreshToken());
} catch (BizException ex) {
location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LOGIN_FAILURE);
logBizException(ex);
} catch (Exception ex) {
location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LOGIN_FAILURE);
log.error("소셜 계정 로그인 콜백 처리 중 예상치 못한 오류 발생. provider: {}", provider, ex);
}

return buildRedirectResponse(location, refreshTokenCookie);
}

private void logBizException(BizException ex) {
log.warn("BizException handled: code={}, status={}, message={}",
ex.getErrorCode().getCode(),
ex.getErrorCode().getStatus(),
ex.getErrorCode().getMessage()
);
}

private ResponseCookie createRefreshTokenCookie(String token) {
long expirationSeconds = jwtProperties.getRefreshTokenExpiration().toSeconds();
return cookieUtil.createCookie(
JwtConstants.REFRESH_TOKEN,
token,
Math.toIntExact(expirationSeconds)
);
}

private URI resolveRedirectUrlForSocialLink(BizException exception) {
if (exception.getErrorCode() == AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) {
return buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_CONFLICT);
}
return buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_FAILURE);
}

private URI buildRedirectUri(ClientProperties.PathKey pathKey) {
return URI.create(clientProperties.buildUrl(pathKey));
}

private URI buildLoginSuccessRedirectUri(String accessToken) {
return UriComponentsBuilder
.fromUriString(clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LOGIN_SUCCESS))
.queryParam("accessToken", accessToken)
.build(true)
.toUri();
}

private ResponseEntity<Void> buildRedirectResponse(URI location, ResponseCookie cookie) {
ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.FOUND).location(location);
if (cookie != null) {
builder.header(HttpHeaders.SET_COOKIE, cookie.toString());
}
return builder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ public enum AuthErrorCode implements ErrorCode {
ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.CONFLICT, "AUTH_008", "이미 유효한 비밀번호 재설정 링크가 존재합니다. 이메일을 확인해주세요."),
INVALID_PASSWORD_RESET_TOKEN(HttpStatus.NOT_FOUND, "AUTH_009", "비밀번호 재설정 링크가 유효하지 않거나 만료되었습니다."),
INVALID_SOCIAL_LINK_TOKEN(HttpStatus.NOT_FOUND, "AUTH_010", "소셜 계정 연동 토큰이 유효하지 않거나 만료되었습니다."),
ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_011", "이미 연동된 소셜 계정입니다.");
ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_011", "이미 연동된 소셜 계정입니다."),
NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_012", "가입되지 않은 소셜 계정입니다."),
INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 소셜 제공자입니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
57 changes: 46 additions & 11 deletions src/main/java/project/flipnote/auth/service/OAuthService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package project.flipnote.auth.service;

import java.util.Map;
import java.util.Optional;
import java.util.UUID;

import org.springframework.http.ResponseCookie;
Expand All @@ -13,13 +14,16 @@
import project.flipnote.auth.constants.OAuthConstants;
import project.flipnote.auth.exception.AuthErrorCode;
import project.flipnote.auth.model.AuthorizationRedirect;
import project.flipnote.auth.model.TokenPair;
import project.flipnote.auth.repository.SocialLinkTokenRedisRepository;
import project.flipnote.common.exception.BizException;
import project.flipnote.infra.oauth.model.OAuth2UserInfo;
import project.flipnote.common.config.OAuthProperties;
import project.flipnote.common.exception.BizException;
import project.flipnote.common.security.dto.UserAuth;
import project.flipnote.common.security.jwt.JwtComponent;
import project.flipnote.common.util.CookieUtil;
import project.flipnote.common.util.PkceUtil;
import project.flipnote.infra.oauth.OAuthApiClient;
import project.flipnote.infra.oauth.model.OAuth2UserInfo;
import project.flipnote.user.entity.UserOAuthLink;
import project.flipnote.user.repository.UserOAuthLinkRepository;
import project.flipnote.user.repository.UserRepository;
Expand All @@ -37,16 +41,19 @@ public class OAuthService {
private final SocialLinkTokenRedisRepository socialLinkTokenRedisRepository;
private final UserRepository userRepository;
private final UserOAuthLinkRepository userOAuthLinkRepository;
private final JwtComponent jwtComponent;

public AuthorizationRedirect getAuthorizationUri(String providerName, HttpServletRequest request, long userId) {
public AuthorizationRedirect getAuthorizationUri(
String providerName,
HttpServletRequest request,
UserAuth userAuth
) {
OAuthProperties.Provider provider = getProvider(providerName);

String codeVerifier = pkceUtil.generateCodeVerifier();
String codeChallenge = pkceUtil.generateCodeChallenge(codeVerifier);

String state = UUID.randomUUID().toString();
socialLinkTokenRedisRepository.saveToken(userId, state);

String state = generateStateForSocialLink(userAuth);
String authorizeUrl = oAuthApiClient.buildAuthorizeUri(request, provider, codeChallenge, state);
ResponseCookie cookie = cookieUtil.createCookie(
OAuthConstants.VERIFIER_COOKIE_NAME,
Expand All @@ -69,10 +76,7 @@ public void linkSocialAccount(
.orElseThrow(() -> new BizException(AuthErrorCode.INVALID_SOCIAL_LINK_TOKEN));
socialLinkTokenRedisRepository.deleteToken(state);

OAuthProperties.Provider provider = getProvider(providerName);
String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request);
Map<String, Object> userInfoAttributes = oAuthApiClient.requestUserInfo(provider, accessToken);
OAuth2UserInfo userInfo = oAuthApiClient.createUserInfo(providerName, userInfoAttributes);
OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request);

if (userOAuthLinkRepository.existsByUser_IdAndProviderId(userId, userInfo.getProviderId())) {
throw new BizException(AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT);
Expand All @@ -86,7 +90,38 @@ public void linkSocialAccount(
userOAuthLinkRepository.save(userOAuthLink);
}

public TokenPair socialLogin(String providerName, String code, String codeVerifier, HttpServletRequest request) {
OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request);

UserOAuthLink userOAuthLink = userOAuthLinkRepository.findByProviderAndProviderIdWithUser(
providerName, userInfo.getProviderId()
).orElseThrow(() -> new BizException(AuthErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT));

return jwtComponent.generateTokenPair(userOAuthLink.getUser());
}

private OAuth2UserInfo getOAuth2UserInfo(String providerName, String code, String codeVerifier,
HttpServletRequest request) {
OAuthProperties.Provider provider = getProvider(providerName);
String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request);
Map<String, Object> userInfoAttributes = oAuthApiClient.requestUserInfo(provider, accessToken);
return oAuthApiClient.createUserInfo(providerName, userInfoAttributes);
}

private OAuthProperties.Provider getProvider(String providerName) {
return oauthProperties.getProviders().get(providerName.toLowerCase());
return Optional.ofNullable(oauthProperties.getProviders().get(providerName.toLowerCase()))
.orElseThrow(() -> {
log.warn("지원하지 않는 OAuth Provider 입니다. provider: {}", providerName);
return new BizException(AuthErrorCode.INVALID_OAUTH_PROVIDER);
});
}

private String generateStateForSocialLink(UserAuth userAuth) {
if (userAuth == null) {
return null;
}
String state = UUID.randomUUID().toString();
socialLinkTokenRedisRepository.saveToken(userAuth.userId(), state);
return state;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ public enum PathKey {
PASSWORD_RESET,
SOCIAL_LINK_SUCCESS,
SOCIAL_LINK_FAILURE,
SOCIAL_LINK_CONFLICT
SOCIAL_LINK_CONFLICT,
SOCIAL_LOGIN_SUCCESS,
SOCIAL_LOGIN_FAILURE
}

private String url;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/*/auth/login", "/*/auth/email", "/*/auth/email/confirm"
).permitAll()
.requestMatchers(
HttpMethod.GET, "/oauth2/callback/{provider}"
HttpMethod.GET, "/oauth2/authorization/{provider}", "/oauth2/callback/{provider}"
).permitAll()
.requestMatchers(
"/v3/api-docs/**",
Expand Down
12 changes: 8 additions & 4 deletions src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,19 @@ public String buildAuthorizeUri(
String codeChallenge,
String state
) {
return UriComponentsBuilder.fromUriString(provider.getAuthorizationUri())
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(provider.getAuthorizationUri())
.queryParam("client_id", provider.getClientId())
.queryParam("redirect_uri", buildRedirectUri(request, provider.getRedirectUri()))
.queryParam("response_type", "code")
.queryParam("scope", String.join(" ", provider.getScope()))
.queryParam("code_challenge", codeChallenge)
.queryParam("code_challenge_method", "S256")
.queryParam("state", state)
.toUriString();
.queryParam("code_challenge_method", "S256");

if (state != null) {
builder.queryParam("state", state);
}

return builder.toUriString();
}

private String buildRedirectUri(HttpServletRequest request, String path) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package project.flipnote.user.repository;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import project.flipnote.user.entity.UserOAuthLink;

Expand All @@ -13,4 +16,17 @@ public interface UserOAuthLinkRepository extends JpaRepository<UserOAuthLink, Lo
List<UserOAuthLink> findByUser_Id(Long userId);

boolean existsByIdAndUser_Id(Long id, Long userId);

@Query("""
SELECT uol
FROM UserOAuthLink uol
JOIN FETCH uol.user
WHERE uol.provider = :provider
AND uol.providerId = :providerId
""")
Optional<UserOAuthLink> findByProviderAndProviderIdWithUser(
@Param("provider") String provider,
@Param("providerId") String providerId
);

}
2 changes: 2 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ app:
social-link-success: /social-link/success
social-link-failure: /social-link/failure
social-link-conflict: /social-link/conflict
social-login-success: /social-login/success
social-login-failure: /social-login/failure

oauth2:
providers:
Expand Down
Loading