diff --git a/README.md b/README.md index 8d830fa5..cdb6fbf8 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # 만들면서 배우는 Spring Security 2기 - OAuth 2.0 미션 -## 1단계 +## 1단계 ### 🚀 1-1단계 - OAuth 2.0 Login - [x] 깃헙 로그인 요청 시, 깃헙 로그인 페이지로 리다이렉트한다. -- [x] 깃헙에서 발급받은 승인 code를 이용하여 서버의 인증 처리를 진행한다. +- [x] 깃헙에서 발급받은 승인 code를 이용하여 서버의 인증 처리를 진행한다. - [x] 발급 받은 code로 깃헙 액세스 토큰을 발급한다. - [x] 리다이렉트된 `/login/oauth2/code/github` 에서 code값을 추출하여 인증 토큰 생성 (OAuth2LoginFilter 구현) - [x] code를 인증 토큰에서 추출하여 액세스 토큰 발급을 요청하는 AuthenticationProvider 구현 - [x] 발급 받은 깃헙 액세스 토큰으로 깃헙 사용자 리소스 조회를 요청한다. - [x] 조회한 사용자 리소스를 이용하여 로그인 후처리 작업을 진행한다. - - [x] 기존 회원인 경우 세션에 로그인 정보를 저장한 뒤 "/"으로 리다이렉트. - - [x] 신규 회원인 경우 회원 가입 처리 & 세션에 로그인 정보를 저장한 뒤 "/"으로 리다이렉트. + - [x] 기존 회원인 경우 세션에 로그인 정보를 저장한 뒤 "/"으로 리다이렉트. + - [x] 신규 회원인 경우 회원 가입 처리 & 세션에 로그인 정보를 저장한 뒤 "/"으로 리다이렉트. ### 🚀 1-2단계 - 리팩터링 & OAuth 2.0 Resource 연동 @@ -25,18 +25,17 @@ - [x] DefaultOAuth2UserService에서 각 OAuth2 제공자에 맞게 사용자 정보 조회 - [x] 구글 로그인 구현 - [x] GoogleLoginRedirectFilter - - [x] GoogleClient + - [x] GoogleClient - [x] 코드를 이용한 액세스 토큰 발급 - [x] 액세스 토큰을 이용한 사용자 정보 조회 - [x] 실제 UI로 통합 테스트 진행 -## [1단계 피드백](https://github.com/next-step/spring-security-oauth2/pull/19#pullrequestreview-2652743249) +## [1단계 피드백](https://github.com/next-step/spring-security-oauth2/pull/19#pullrequestreview-2652743249) - [OAuth2 provider에 대한 직접적인 정보를 프로덕션 코드에 미노출(어떤 플랫폼을 사용할지는 추상화)](https://github.com/next-step/spring-security-oauth2/pull/19#discussion_r1976646965) - `provider`가 추가될 때 프로덕션 코드도 변경되어야할지에 대한 고민 - [OAuth2는 프로토콜. 플랫폼별로 동일한 스펙을 가지고 있다. 즉, 플랫폼마다 각각의 구현체를 따로 둘 필요는 없음.](https://github.com/next-step/spring-security-oauth2/pull/19#discussion_r1976648478) - ### 피드백 적용 - [x] OAuth2 제공자가 추가되어도 프로덕션 코드는 변경 없도록 환경변수를 추상화하여 관리 @@ -44,12 +43,232 @@ ## 2단계 +### 2-1: 리다이렉트 필터 + +> 주요 클래스 +> - OAuth2AuthorizationRequestResolver +> - AuthorizationRequestRepository +> - OAuth2AuthorizationRequest +> - ClientRegistrationRepository + +- [x] ClientRegistrationRepository 구현 + - [x] 를 담는 일급 컬렉션 InMemoryClientRegistrationRepository 구현 + - [x] InMemoryClientRegistrationRepository 빈 등록 시 ClientPrlaperties 주입 받아 초기화 +- [x] OAuth2AuthorizationRequestResolver 구현 + - [x] OAuth2AuthorizationRequestResolver 빈 등록 시 ClientRegistrationRepository 주입 받아 초기화 +- [x] AuthorizationRequestRepository 구현 + - [x] HttpSession에 OAuth2AuthorizationRequest를 저장 -// ... +### 2-2: OAuth 인증 필터 +> 주요 클래스 +> - ClientRegistrationRepository +> - OAuth2AuthorizedClientRepository +> - AuthorizationRequestRepository +> - AuthenticationManager +> - HttpSessionSecurityContextRepository +> - Converter와 Converter -- [ ] 중복 코드 리팩터링 - - [ ] redirect URI를 상수로 관리 - - [ ] authorization URI를 환경변수로 관리 +- [x] OAuth2LoginAuthenticationFilter 구현 + - [x] ClientRegistrationRepository에서 ClientRegistration 조회 + - [x] AuthorizationRequestRepository에서 AuthorizationRequest 조회 + - [x] OAuth2LoginAuthenticationToken 생성 및 AuthenticationManager로 인증 시도 +- [x] OAuth2LoginAuthenticationProvider 구현 + - [x] OAuth2AuthorizationCodeAuthenticationToken 생성 + - [x] OAuth2AuthorizationCodeAuthenticationProvider로 인증 위임. + - [x] OAuth2UserService를 이용하여 사용자 정보 조회. + - [x] DefaultOAuth2UserService에서 사용자 정보 조회(Google Client, GitHub Client 삭제) +- [x] OAuth2AuthorizationCodeAuthenticationProvider 구현 + - [x] OAuth2AccessTokenResponseClient를 이용하여 액세스 토큰 발급. + - [x] OAuth2AccessTokenResponseClient 구현 +- [ ] 깨지는 HTTP Session 관련 테스트 수정 ## 2단계 피드백 + +--- + +# OAuth2 인증 플로우 참고 + +## OAuth2 인증 리다이렉트 + +```mermaid +sequenceDiagram +%% [participants] + actor u as User Browser + participant rf as OAuth2AuthorizationRequestRedirectFilter + participant rr as OAuth2AuthorizationRequestResolver + participant crr as ClientRegistrationRepository + participant ar as AuthorizationRequestRepository + +%% [start sequnce] + u ->> rf: GET /oauth2/authorization/{registrationId} + rf ->> rr: resolve(httpServletRequest) + rr ->> rr: resolves registrationId from request URI + rr ->> crr: findByRegistrationId(registrationId) + crr -->> rr: ClientRegistration + rr ->> rr: extract Redirect URI from ClientRegistration + rr -->> rf: OAuth2AuthorizationRequest
(contains OAuth2 Redirect URI) + rf ->> ar: saveAuthorizationRequest(authorizationRequest) + rf -->> u: returns 302 Found with Redirect URI
(sendRedirect(response)) + u ->> u: Redirects to OAuth2 Authorization Page + u ->> u: User authorizes with their account. +``` + +## OAuth2LoginAuthenticationFilter 플로우 + +```mermaid +sequenceDiagram + title OAuth2LoginAuthenticationFilter Flow + +%% [participants] + actor u as User Browser + participant af as OAuth2LoginAuthenticationFilter + participant ar as AuthorizationRequestRepository + participant crr as ClientRegistrationRepository + participant am as AuthenticationManager + + +%% [start sequnce] + u ->> u: Redirects to APP Server
after authorizing OAuth2 Page + u ->> af: GET /login/oauth2/code/{registrationId}?code={code} + +%% get authorizationRequest + note over af, ar: Find OAuth2AuthorizationRequest
which already saved by OAuth2AuthorizationRequestRedirectFilter + af ->> ar: removeAuthorizationRequest(req, res) + ar -->> af: OAuth2AuthorizationRequest + +%% get clientRegistration + af ->> af: extract registrationId from authorizationRequest + af ->> crr: findByRegistrationId(registartionId) + crr -->> af: ClientRegistration + af ->> af: generates authenticationRequest(=OAuth2LoginAuthenticationToken)
with clientRegistration, authorizationExchange + +%% attempts authentication + af ->> am: authenticate(authenticationRequest - OAuth2LoginAuthenticationToken) + am -->> af: Authentication + +%% process after successful authentication + af ->> af: If authentcated, save SecurityContext +``` + +```mermaid +sequenceDiagram + participant User + participant OAuth2LoginAuthenticationFilter + participant ClientRegistrationRepository + participant AuthorizationRequestRepository + participant AuthenticationManager + User ->> OAuth2LoginAuthenticationFilter: 요청 전송 (인증 코드 포함) + OAuth2LoginAuthenticationFilter ->> ClientRegistrationRepository: ClientRegistration 조회 + ClientRegistrationRepository -->> OAuth2LoginAuthenticationFilter: ClientRegistration 반환 + OAuth2LoginAuthenticationFilter ->> AuthorizationRequestRepository: AuthorizationRequest 제거 및 조회 + AuthorizationRequestRepository -->> OAuth2LoginAuthenticationFilter: AuthorizationRequest 반환 + OAuth2LoginAuthenticationFilter ->> AuthenticationManager: 인증 시도 (OAuth2LoginAuthenticationToken 전달) +``` + + + +### OAuth2LoginAuthenticationProvider 플로우 + +```mermaid +sequenceDiagram + participant AuthenticationManager + participant OAuth2LoginAuthenticationProvider + participant OAuth2AuthorizationCodeAuthenticationProvider + participant OAuth2AccessTokenResponseClient + participant OAuth2UserService (DefaultOAuth2UserService) + participant ResourceServer + AuthenticationManager ->> OAuth2LoginAuthenticationProvider: 인증 요청 (OAuth2LoginAuthenticationToken) + OAuth2LoginAuthenticationProvider ->> OAuth2AuthorizationCodeAuthenticationProvider: 인증 요청 (OAuth2AuthorizationCodeAuthenticationToken) + OAuth2AuthorizationCodeAuthenticationProvider ->> OAuth2AccessTokenResponseClient: 토큰 요청 (OAuth2AuthorizationCodeGrantRequest) + OAuth2AccessTokenResponseClient -->> OAuth2AuthorizationCodeAuthenticationProvider: OAuth2AccessTokenResponse 반환 + OAuth2AuthorizationCodeAuthenticationProvider ->> OAuth2UserService (DefaultOAuth2UserService): 사용자 정보 요청 (OAuth2UserRequest) + OAuth2UserService (DefaultOAuth2UserService) ->> ResourceServer: 사용자 정보 요청 (GET getUserInfoUri) + ResourceServer -->> OAuth2UserService (DefaultOAuth2UserService): 사용자 정보 응답 + OAuth2UserService (DefaultOAuth2UserService) -->> OAuth2AuthorizationCodeAuthenticationProvider: OAuth2User + +``` + +```mermaid +sequenceDiagram + title OAuth2LoginAuthenticationProvider authentication Flow + + %% participants + participant am as AuthenticationManager + participant lap as OAuth2LoginAuthenticationProvider
👉 Delegate autentication to codeAuthenticationProvider + participant acap as OAuth2AuthorizationCodeAuthenticationProvider
👉 Request Exchanging Code To AccessToken + participant atrc as OAuth2AccessTokenResponseClient
(DefaultAuthorizationCodeTokenResponseClient) + participant us as OAuth2UserService (DefaultOAuth2UserService) + + %% start sequences + am ->> lap: authenticate(authentication) + + %% OAuth2LoginAuthenticationProvider + lap ->> lap: Cast authentcation to OAuth2LoginAuthenticationToken + lap ->> lap: Genenrate 'OAuth2AuthorizationCodeAuthenticationToken'
contains clientRegisration & authorizationExchange + lap ->> acap: authenticate(autentication) + + %% OAuth2AuthorizationCodeAuthenticationProvider + acap ->> acap: Get OAuth2AuthorizationResponse
from authorizationExchange + acap ->> acap: Generate 'OAuth2AuthorizationCodeGrantRequest'
contains clientRegistration, authorizationExchange + acap ->> atrc: getTokenResponse(authorizationGrantRequest) + + %% OAuth2AccessTokenResponseClient + note over atrc, atrc: 🚀 Exchange Authorization Code to AccessToken
via call Authorization Server endpoint. + atrc ->> atrc: Convert authorizationCodeGrantRequest to RequestEntity + atrc ->> atrc: Exchange request to OAuth2AccessTokenResponse
via call Authorization Server endpoint. + atrc -->> acap: returns tokenResponse + + acap ->> acap: Generate authenticated OAuth2AuthorizationCodeAuthenticationToken + acap -->> lap: returns OAuth2AuthorizationCodeAuthenticationToken + + %% request load user + lap ->> lap: Extract AccessToken from OAuth2AuthorizationCodeAuthenticationToken
Generate OAuth2UserRequest with AccessToken + lap ->> us: loadUser(oauth2UserRequest) + + %% UserService + note over us, us: 🚀 Exchange AccessToken to UserInfo
via call Resource Server + us ->> us: Validate UserInfoEndPoints Exists. + us ->> us: Validate UserNameAttributeName Exists. + us ->> us: Convert userRequest to RequestEntity
& Get UserAttributes via call Resource Server + + note over us, us: 👨‍💻 Devleper will implement Member Sign up process
using CustomOAuth2UserService if needed. + + us ->> lap: returns DefaultOAuth2SUser + + %% End of OAuth2 authentication + lap -->> am: Authentication + note over am, am: OAuth2LoginAuthenticationFilter saves SecurityContext
+``` + + +## OAuth2 인증에서 state값 활용 플로우 + +```mermaid +sequenceDiagram + participant User + participant App + participant Repository + participant OAuth Provider + User ->> App: /oauth2/authorization/google + App ->> Repository: Generate & Save State + App ->> OAuth Provider: Redirect with State + OAuth Provider ->> User: Authentication + User ->> App: Callback with State + App ->> Repository: Validate State + alt Valid State + Repository ->> App: Success + App ->> User: Grant Access + else Invalid State + Repository ->> App: Failure + App ->> User: Access Denied + end +``` + +```mermaid +sequenceDiagram + 공격자 ->> 서버: 임의의 인증 요청 생성 + 공격자 ->> 피해자: 특정 URL 전달 (세션 ID 고정) + 피해자 ->> 서버: URL로 인증 시도 + 서버 ->> 공격자: 인증 결과 전송 +``` diff --git a/docs/Questions.md b/docs/Questions.md index a1840c54..61016fbb 100644 --- a/docs/Questions.md +++ b/docs/Questions.md @@ -1,11 +1,27 @@ -## 미션 3-1 질문 +## 미션 3-1 질문 ### 시스템에 저장하는 username은 어떻게 정할지? -### OAuth2를 이용한 로그인 시, 임의로 생성된 username으로 로그인이 가능하게 할 것인가? +### OAuth2를 이용한 로그인 시, 임의로 생성된 username으로 로그인이 가능하게 할 것인가? ### authentication 패키지와 provider 패키지 양방향 참조. -## 미션 3-2 질문 +--- + +## 미션 3-2 진행하며 생긴 궁금증 + +> PR 코멘트로 따로 질문드릴 예정입니다..! + +### Spring Security에서 어떤 필드는 주입 받고, 어떤 필드는 자체적으로 생성하는 이유? + +- OAuth2AuthorizationRequestRedirectFilter 에서, + - OAuth2AuthorizationRequestResolver는 주입받지 않고, clientRegistrationRepository만 주입받는 이유? +- OAuth2AuthorizationRequestResolver는 OAuth2AuthorizationRequestRedirectFilter만 쓰이고, clientRegistrationRepository는 다른 곳에서도 쓰이기 때문일 것으로 추측. + +### AuthorizationRequestRepository는 왜 필요한가? + +- state값 검증과 HttpSession에 OAuth2AuthorizationRequest를 저장하는 역할을 한다. + +### OAuth2LoginAuthenticationFilter // ... diff --git a/src/main/java/nextstep/app/SecurityConfig.java b/src/main/java/nextstep/app/SecurityConfig.java index 56f70435..6c3428ad 100644 --- a/src/main/java/nextstep/app/SecurityConfig.java +++ b/src/main/java/nextstep/app/SecurityConfig.java @@ -2,6 +2,8 @@ import nextstep.app.domain.Member; import nextstep.app.domain.MemberRepository; +import nextstep.oauth2.OAuth2ClientProperties; +import nextstep.oauth2.OAuth2ClientPropertiesMapper; import nextstep.security.access.AnyRequestMatcher; import nextstep.security.access.MvcRequestMatcher; import nextstep.security.access.RequestMatcherEntry; @@ -16,10 +18,12 @@ import nextstep.security.config.FilterChainProxy; import nextstep.security.config.SecurityFilterChain; import nextstep.security.context.SecurityContextHolderFilter; -import nextstep.security.oauth2.authentication.OAuth2AuthorizationRequestRedirectFilter; -import nextstep.security.oauth2.authentication.OAuth2LoginAuthenticationFilter; -import nextstep.security.oauth2.authentication.OAuth2UserService; -import nextstep.security.oauth2.provider.OAuth2ClientProperties; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.client.registration.ClientRegistrationRepository; +import nextstep.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import nextstep.security.oauth2.client.userinfo.OAuth2UserService; +import nextstep.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; +import nextstep.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import nextstep.security.userdetails.UserDetails; import nextstep.security.userdetails.UserDetailsService; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -54,12 +58,16 @@ public SecuredMethodInterceptor securedMethodInterceptor() { } @Bean - public SecurityFilterChain securityFilterChain(OAuth2ClientProperties securityOAuth2Properties, OAuth2UserService oAuth2UserService) { + public SecurityFilterChain securityFilterChain( + OAuth2ClientProperties securityOAuth2Properties, + OAuth2UserService oAuth2UserService, + ClientRegistrationRepository clientRegistrationRepository + ) { return new DefaultSecurityFilterChain( List.of( new SecurityContextHolderFilter(), - new OAuth2AuthorizationRequestRedirectFilter(securityOAuth2Properties), - new OAuth2LoginAuthenticationFilter(securityOAuth2Properties, oAuth2UserService), + new OAuth2AuthorizationRequestRedirectFilter(clientRegistrationRepository), + new OAuth2LoginAuthenticationFilter(oAuth2UserService, clientRegistrationRepository), new UsernamePasswordAuthenticationFilter(userDetailsService()), new BasicAuthenticationFilter(userDetailsService()), new AuthorizationFilter(requestAuthorizationManager()) @@ -108,4 +116,10 @@ public Set getAuthorities() { }; }; } + + @Bean + public InMemoryClientRegistrationRepository clientRegistrationRepository(OAuth2ClientProperties properties) { + List registrations = new OAuth2ClientPropertiesMapper(properties).asClientRegistrations(); + return new InMemoryClientRegistrationRepository(registrations); + } } diff --git a/src/main/java/nextstep/app/security/CustomOAuth2User.java b/src/main/java/nextstep/app/security/CustomOAuth2User.java index c557eb95..3584aa31 100644 --- a/src/main/java/nextstep/app/security/CustomOAuth2User.java +++ b/src/main/java/nextstep/app/security/CustomOAuth2User.java @@ -1,6 +1,6 @@ package nextstep.app.security; -import nextstep.security.oauth2.authentication.OAuth2User; +import nextstep.security.oauth2.core.user.OAuth2User; public class CustomOAuth2User implements OAuth2User { diff --git a/src/main/java/nextstep/app/security/CustomOAuth2UserService.java b/src/main/java/nextstep/app/security/CustomOAuth2UserService.java index 63518a28..f06cb040 100644 --- a/src/main/java/nextstep/app/security/CustomOAuth2UserService.java +++ b/src/main/java/nextstep/app/security/CustomOAuth2UserService.java @@ -2,9 +2,9 @@ import nextstep.app.domain.Member; import nextstep.app.domain.MemberRepository; -import nextstep.security.oauth2.authentication.DefaultOAuth2UserService; -import nextstep.security.oauth2.authentication.OAuth2User; -import nextstep.security.oauth2.authentication.OAuth2UserRequest; +import nextstep.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import nextstep.security.oauth2.client.userinfo.OAuth2UserRequest; +import nextstep.security.oauth2.core.user.OAuth2User; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; diff --git a/src/main/java/nextstep/security/oauth2/provider/OAuth2ClientProperties.java b/src/main/java/nextstep/oauth2/OAuth2ClientProperties.java similarity index 71% rename from src/main/java/nextstep/security/oauth2/provider/OAuth2ClientProperties.java rename to src/main/java/nextstep/oauth2/OAuth2ClientProperties.java index 8de3721c..81a8972b 100644 --- a/src/main/java/nextstep/security/oauth2/provider/OAuth2ClientProperties.java +++ b/src/main/java/nextstep/oauth2/OAuth2ClientProperties.java @@ -1,4 +1,4 @@ -package nextstep.security.oauth2.provider; +package nextstep.oauth2; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -23,14 +23,6 @@ public Map getRegistration() { return this.registration; } - public Registration getRegistrationById(String registrationId) { - Registration registration = this.registration.get(registrationId); - if (registration == null) { - throw new IllegalArgumentException("Cannot find Registration for registrationId=%s".formatted(registrationId)); - } - return registration; - } - public record Registration( String provider, String clientId, diff --git a/src/main/java/nextstep/oauth2/OAuth2ClientPropertiesMapper.java b/src/main/java/nextstep/oauth2/OAuth2ClientPropertiesMapper.java new file mode 100644 index 00000000..2522f4f7 --- /dev/null +++ b/src/main/java/nextstep/oauth2/OAuth2ClientPropertiesMapper.java @@ -0,0 +1,45 @@ +package nextstep.oauth2; + +import nextstep.oauth2.OAuth2ClientProperties.Provider; +import nextstep.oauth2.OAuth2ClientProperties.Registration; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.Map; + +public class OAuth2ClientPropertiesMapper { + + private final OAuth2ClientProperties properties; + + public OAuth2ClientPropertiesMapper(OAuth2ClientProperties properties) { + Assert.notNull(properties, "properties cannot be null"); + this.properties = properties; + } + + public List asClientRegistrations() { + Map registrations = properties.getRegistration(); + Map providers = properties.getProvider(); + + return registrations.keySet().stream() + .map(registrationId -> { + Registration registration = registrations.get(registrationId); + Provider provider = providers.get(registration.provider()); + + return new ClientRegistration( + registrationId, + registration.clientId(), + registration.clientSecret(), + registration.redirectUri(), + registration.scope(), + new ClientRegistration.ProviderDetails( + provider.authorizationUri(), + provider.tokenUri(), + provider.userInfoUri(), + provider.userNameAttribute() + ) + ); + }) + .toList(); + } +} diff --git a/src/main/java/nextstep/security/oauth2/authentication/ClientRegistration.java b/src/main/java/nextstep/security/oauth2/authentication/ClientRegistration.java deleted file mode 100644 index 9c43ae47..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/ClientRegistration.java +++ /dev/null @@ -1,40 +0,0 @@ -package nextstep.security.oauth2.authentication; - -import nextstep.security.oauth2.provider.OAuth2ClientProperties; - -public record ClientRegistration( - String registrationId, - String clientId, - String clientSecret, - ProviderDetails providerDetails -) { - - public static ClientRegistration of( - OAuth2ClientProperties.Registration registration, - OAuth2ClientProperties.Provider provider - ) { - return new ClientRegistration( - registration.provider(), - registration.clientId(), - registration.clientSecret(), - ProviderDetails.from(provider) - ); - } - - public record ProviderDetails( - String authorizationUri, - String tokenUri, - String userInfoUri, - String userNameAttribute - ) { - - public static ProviderDetails from(OAuth2ClientProperties.Provider provider) { - return new ProviderDetails( - provider.authorizationUri(), - provider.tokenUri(), - provider.userInfoUri(), - provider.userNameAttribute() - ); - } - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/CustomOAuth2User.java b/src/main/java/nextstep/security/oauth2/authentication/CustomOAuth2User.java deleted file mode 100644 index 9d97bdbb..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/CustomOAuth2User.java +++ /dev/null @@ -1,15 +0,0 @@ -package nextstep.security.oauth2.authentication; - -public class CustomOAuth2User implements OAuth2User { - - private final String username; - - public CustomOAuth2User(String username) { - this.username = username; - } - - @Override - public String getName() { - return this.username; - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/DefaultOAuth2UserService.java b/src/main/java/nextstep/security/oauth2/authentication/DefaultOAuth2UserService.java deleted file mode 100644 index 8c0f5b14..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/DefaultOAuth2UserService.java +++ /dev/null @@ -1,18 +0,0 @@ -package nextstep.security.oauth2.authentication; - -import nextstep.security.oauth2.provider.OAuth2ProviderClient; -import nextstep.security.oauth2.provider.OAuth2ProviderClientFactory; - -public class DefaultOAuth2UserService implements OAuth2UserService { - - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) { - OAuth2ProviderClient client = getProviderClient(userRequest); - return client.fetchUser(userRequest.registration(), userRequest.accessToken()); - } - - private OAuth2ProviderClient getProviderClient(OAuth2UserRequest userRequest) { - String providerName = userRequest.registration().registrationId(); - return OAuth2ProviderClientFactory.INSTANCE.getProviderClient(providerName); - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/GitHubLoginRedirectFilter.java b/src/main/java/nextstep/security/oauth2/authentication/GitHubLoginRedirectFilter.java deleted file mode 100644 index 56ca4a35..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/GitHubLoginRedirectFilter.java +++ /dev/null @@ -1,57 +0,0 @@ -package nextstep.security.oauth2.authentication; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import nextstep.security.access.MvcRequestMatcher; -import nextstep.security.access.RequestMatcher; -import nextstep.security.oauth2.provider.OAuth2ClientProperties; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpMethod; -import org.springframework.web.filter.GenericFilterBean; -import org.springframework.web.util.UriComponentsBuilder; - -import java.io.IOException; - -public class GitHubLoginRedirectFilter extends GenericFilterBean { - - private static final Logger logger = LoggerFactory.getLogger(GitHubLoginRedirectFilter.class); - - private final RequestMatcher matcher = new MvcRequestMatcher(HttpMethod.GET, "/oauth2/authorization/github"); - private final OAuth2ClientProperties oAuth2Properties; - - public GitHubLoginRedirectFilter(OAuth2ClientProperties oAuth2Properties) { - this.oAuth2Properties = oAuth2Properties; - } - - @Override - public void doFilter( - ServletRequest servletRequest, - ServletResponse servletResponse, - FilterChain chain - ) throws IOException, ServletException { - HttpServletResponse response = (HttpServletResponse) servletResponse; - - if (matcher.matches((HttpServletRequest) servletRequest)) { - response.setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY); - - String githubLoginUrl = UriComponentsBuilder.fromHttpUrl("https://github.com/login/oauth/authorize") - .queryParam("client_id", oAuth2Properties.getRegistration().get("github").clientId()) - .queryParam("response_type", "code") - .queryParam("scope", "read:user") - .queryParam("redirect_uri", "http://localhost:8080/login/oauth2/code/github") - .build() - .toUriString(); - - logger.info("Redirecting to GitHub login: {}", githubLoginUrl); - response.sendRedirect(githubLoginUrl); - return; - } - - chain.doFilter(servletRequest, servletResponse); - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/GoogleLoginRedirectFilter.java b/src/main/java/nextstep/security/oauth2/authentication/GoogleLoginRedirectFilter.java deleted file mode 100644 index 83e2497c..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/GoogleLoginRedirectFilter.java +++ /dev/null @@ -1,58 +0,0 @@ -package nextstep.security.oauth2.authentication; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import nextstep.security.access.MvcRequestMatcher; -import nextstep.security.access.RequestMatcher; -import nextstep.security.oauth2.provider.OAuth2ClientProperties; -import org.springframework.http.HttpMethod; -import org.springframework.web.filter.GenericFilterBean; -import org.springframework.web.util.UriComponentsBuilder; - -import java.io.IOException; - -public class GoogleLoginRedirectFilter extends GenericFilterBean { - - private final RequestMatcher matcher = new MvcRequestMatcher(HttpMethod.GET, "/oauth2/authorization/google"); // fixme: BASE URI + /google로 리팩토링 - - private final OAuth2ClientProperties oAuth2Properties; - - public GoogleLoginRedirectFilter(OAuth2ClientProperties oAuth2Properties) { - this.oAuth2Properties = oAuth2Properties; - } - - @Override - public void doFilter( - ServletRequest request, - ServletResponse response, - FilterChain chain - ) throws IOException, ServletException { - doFilterInternal((HttpServletRequest) request, (HttpServletResponse) response, chain); - } - - private void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain chain - ) throws ServletException, IOException { - if (!matcher.matches(request)) { - chain.doFilter(request, response); - return; - } - - String googleLoginUrl = UriComponentsBuilder.fromHttpUrl("https://accounts.google.com/o/oauth2/v2/auth") - .queryParam("client_id", oAuth2Properties.getRegistration().get("google").clientId()) - .queryParam("response_type", "code") - .queryParam("scope", "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email") - .queryParam("redirect_uri", "http://localhost:8080/login/oauth2/code/google") - .build() - .toUriString(); - - logger.info("Redirecting to Google login: %s".formatted(googleLoginUrl)); - response.sendRedirect(googleLoginUrl); - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthenticationProvider.java b/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthenticationProvider.java deleted file mode 100644 index e3a2d71f..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthenticationProvider.java +++ /dev/null @@ -1,45 +0,0 @@ -package nextstep.security.oauth2.authentication; - -import nextstep.security.authentication.Authentication; -import nextstep.security.authentication.AuthenticationException; -import nextstep.security.authentication.AuthenticationProvider; -import nextstep.security.oauth2.provider.OAuth2ProviderClient; -import nextstep.security.oauth2.provider.OAuth2ProviderClientFactory; - -public class OAuth2AuthenticationProvider implements AuthenticationProvider { - - private final OAuth2UserService userService; - - public OAuth2AuthenticationProvider(OAuth2UserService userService) { - this.userService = userService; - } - - @Override - public boolean supports(Class authentication) { - return OAuth2AuthenticationToken.class.isAssignableFrom(authentication); - } - - @Override - public Authentication authenticate(Authentication authentication) throws AuthenticationException { - OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; - - OAuth2AccessToken accessToken = exchangeCodeToAccessToken(token); - - OAuth2User oAuth2User = this.userService.loadUser(new OAuth2UserRequest( - token.getClientRegistration(), - accessToken - )); - - return OAuth2AuthenticationToken.authenticated(oAuth2User.getName()); - } - - private OAuth2AccessToken exchangeCodeToAccessToken(OAuth2AuthenticationToken token) { - OAuth2ProviderClient providerClient = getProviderClient(token); - return providerClient.fetchAccessToken(token.getClientRegistration(), token.getAuthorizationCode()); - } - - private OAuth2ProviderClient getProviderClient(OAuth2AuthenticationToken token) { - String providerName = token.getClientRegistration().registrationId(); - return OAuth2ProviderClientFactory.INSTANCE.getProviderClient(providerName); - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthenticationToken.java b/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthenticationToken.java deleted file mode 100644 index 1d860ed7..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthenticationToken.java +++ /dev/null @@ -1,61 +0,0 @@ -package nextstep.security.oauth2.authentication; - -import nextstep.security.authentication.Authentication; - -import java.util.Set; - -public class OAuth2AuthenticationToken implements Authentication { - - private final String principal; - private final ClientRegistration clientRegistration; - private final OAuth2AuthorizationCode authorizationCode; - private final boolean authenticated; - - private OAuth2AuthenticationToken( - String principal, - ClientRegistration clientRegistration, - OAuth2AuthorizationCode authorizationCode, - boolean authenticated - ) { - this.principal = principal; - this.clientRegistration = clientRegistration; - this.authorizationCode = authorizationCode; - this.authenticated = authenticated; - } - - public static OAuth2AuthenticationToken unauthenticated(ClientRegistration clientRegistration, OAuth2AuthorizationCode code) { - return new OAuth2AuthenticationToken(null, clientRegistration, code, false); - } - - public static OAuth2AuthenticationToken authenticated(String principal) { - return new OAuth2AuthenticationToken(principal, null, null, true); - } - - @Override - public boolean isAuthenticated() { - return this.authenticated; - } - - @Override - public Object getPrincipal() { - return this.principal; - } - - @Override - public Object getCredentials() { - return null; - } - - @Override - public Set getAuthorities() { - return null; // todo: fill authorities - } - - public ClientRegistration getClientRegistration() { - return clientRegistration; - } - - public OAuth2AuthorizationCode getAuthorizationCode() { - return authorizationCode; - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthorizationCode.java b/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthorizationCode.java deleted file mode 100644 index 059c815c..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthorizationCode.java +++ /dev/null @@ -1,12 +0,0 @@ -package nextstep.security.oauth2.authentication; - -import org.springframework.util.Assert; - -public record OAuth2AuthorizationCode( - String codeValue -) { - - public OAuth2AuthorizationCode { - Assert.hasText(codeValue, "codeValue cannot be empty"); - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthorizationRequestRedirectFilter.java b/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthorizationRequestRedirectFilter.java deleted file mode 100644 index 61e363b7..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AuthorizationRequestRedirectFilter.java +++ /dev/null @@ -1,72 +0,0 @@ -package nextstep.security.oauth2.authentication; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import nextstep.security.oauth2.provider.OAuth2ClientProperties; -import nextstep.security.oauth2.provider.OAuth2ClientProperties.Provider; -import nextstep.security.oauth2.provider.OAuth2ClientProperties.Registration; -import org.springframework.web.filter.GenericFilterBean; -import org.springframework.web.util.UriComponentsBuilder; - -import java.io.IOException; - -public class OAuth2AuthorizationRequestRedirectFilter extends GenericFilterBean { - - public static final String OAUTH2_LOGIN_REQUEST_URI_PREFIX = "/oauth2/authorization/"; - private final OAuth2ClientProperties oAuth2Properties; - - public OAuth2AuthorizationRequestRedirectFilter(OAuth2ClientProperties oAuth2Properties) { - this.oAuth2Properties = oAuth2Properties; - } - - @Override - public void doFilter( - ServletRequest request, - ServletResponse response, - FilterChain chain - ) throws IOException, ServletException { - doFilterInternal((HttpServletRequest) request, (HttpServletResponse) response, chain); - } - - private void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain chain - ) throws ServletException, IOException { - if (!matchesPattern(request)) { - chain.doFilter(request, response); - return; - } - - String registrationId = extractRegistrationId(request); - Registration registration = oAuth2Properties.getRegistrationById(registrationId); - Provider provider = oAuth2Properties.getProvider().get(registration.provider()); - - String redirectUrl = generateRedirectUrl(provider, registration); - - logger.info("Redirecting to %s login: %s".formatted(registration.provider(), redirectUrl)); - response.sendRedirect(redirectUrl); - } - - private String generateRedirectUrl(Provider provider, Registration registration) { - return UriComponentsBuilder.fromHttpUrl(provider.authorizationUri()) - .queryParam("client_id", registration.clientId()) - .queryParam("response_type", "code") - .queryParam("scope", String.join(" ", registration.scope())) - .queryParam("redirect_uri", registration.redirectUri()) - .build() - .toUriString(); - } - - private String extractRegistrationId(HttpServletRequest request) { - return request.getRequestURI().substring(OAUTH2_LOGIN_REQUEST_URI_PREFIX.length()); - } - - private boolean matchesPattern(HttpServletRequest request) { - return request.getRequestURI().startsWith(OAUTH2_LOGIN_REQUEST_URI_PREFIX); - } -} diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2UserRequest.java b/src/main/java/nextstep/security/oauth2/authentication/OAuth2UserRequest.java deleted file mode 100644 index 5b3ff4c4..00000000 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2UserRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package nextstep.security.oauth2.authentication; - -public record OAuth2UserRequest( - ClientRegistration registration, - OAuth2AccessToken accessToken -) { - -} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java new file mode 100644 index 00000000..cb2480a0 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java @@ -0,0 +1,40 @@ +package nextstep.security.oauth2.client.authentication; + +import nextstep.security.authentication.Authentication; +import nextstep.security.authentication.AuthenticationException; +import nextstep.security.authentication.AuthenticationProvider; +import nextstep.security.oauth2.client.endpoint.OAuth2AccessTokenResponse; +import nextstep.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import nextstep.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; + +public class OAuth2AuthorizationCodeAuthenticationProvider implements AuthenticationProvider { + + private final OAuth2AccessTokenResponseClient accessTokenResponseClient = new OAuth2AccessTokenResponseClient(); + + public OAuth2AuthorizationCodeAuthenticationProvider() { + } + + @Override + public boolean supports(Class authentication) { + return OAuth2AuthorizationCodeAuthenticationToken.class.isAssignableFrom(authentication); + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2AuthorizationCodeAuthenticationToken codeAuthenticationToken = + (OAuth2AuthorizationCodeAuthenticationToken) authentication; + + OAuth2AuthorizationCodeGrantRequest codeGrantRequest = new OAuth2AuthorizationCodeGrantRequest( + codeAuthenticationToken.getClientRegistration(), + codeAuthenticationToken.getAuthorizationExchange() + ); + + OAuth2AccessTokenResponse tokenResponse = accessTokenResponseClient.getTokenResponse(codeGrantRequest); + + return OAuth2AuthorizationCodeAuthenticationToken.authenticated( + tokenResponse.accessToken(), + codeAuthenticationToken.getClientRegistration(), + codeAuthenticationToken.getAuthorizationExchange() + ); + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthorizationCodeAuthenticationToken.java b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthorizationCodeAuthenticationToken.java new file mode 100644 index 00000000..b9823c1a --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthorizationCodeAuthenticationToken.java @@ -0,0 +1,75 @@ +package nextstep.security.oauth2.client.authentication; + +import nextstep.security.authentication.Authentication; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AccessToken; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; + +import java.util.Set; + +public class OAuth2AuthorizationCodeAuthenticationToken implements Authentication { + + private final OAuth2AccessToken accessToken; + private final ClientRegistration clientRegistration; + private final OAuth2AuthorizationExchange authorizationExchange; + private final boolean authenticated; + + public OAuth2AuthorizationCodeAuthenticationToken( + OAuth2AccessToken accessToken, + ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange, + boolean authenticated + ) { + this.accessToken = accessToken; + this.clientRegistration = clientRegistration; + this.authorizationExchange = authorizationExchange; + this.authenticated = authenticated; + } + + public static OAuth2AuthorizationCodeAuthenticationToken unauthenticated( + ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange + ) { + return new OAuth2AuthorizationCodeAuthenticationToken(null, clientRegistration, authorizationExchange, false); + } + + public static OAuth2AuthorizationCodeAuthenticationToken authenticated( + OAuth2AccessToken oAuth2AccessToken, + ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange + ) { + return new OAuth2AuthorizationCodeAuthenticationToken(oAuth2AccessToken, null, null, true); + } + + @Override + public Set getAuthorities() { + return null; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return null; + } + + @Override + public boolean isAuthenticated() { + return this.authenticated; + } + + public OAuth2AccessToken getAccessToken() { + return this.accessToken; + } + + public ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + + public OAuth2AuthorizationExchange getAuthorizationExchange() { + return this.authorizationExchange; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2LoginAuthenticationProvider.java b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2LoginAuthenticationProvider.java new file mode 100644 index 00000000..6ed02b31 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2LoginAuthenticationProvider.java @@ -0,0 +1,56 @@ +package nextstep.security.oauth2.client.authentication; + +import nextstep.security.authentication.Authentication; +import nextstep.security.authentication.AuthenticationException; +import nextstep.security.authentication.AuthenticationProvider; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.client.userinfo.OAuth2UserRequest; +import nextstep.security.oauth2.client.userinfo.OAuth2UserService; +import nextstep.security.oauth2.core.OAuth2AccessToken; +import nextstep.security.oauth2.core.user.OAuth2User; + +public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider { + + private final OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider; + private final OAuth2UserService userService; + + public OAuth2LoginAuthenticationProvider(OAuth2UserService userService) { + this.authorizationCodeAuthenticationProvider = new OAuth2AuthorizationCodeAuthenticationProvider(); + this.userService = userService; + } + + @Override + public boolean supports(Class authentication) { + return OAuth2LoginAuthenticationToken.class.isAssignableFrom(authentication); + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2LoginAuthenticationToken token = (OAuth2LoginAuthenticationToken) authentication; + + OAuth2AccessToken accessToken = exchangeToAccessToken(token); + + OAuth2User oAuth2User = loadUser(token.getClientRegistration(), accessToken); + + return OAuth2LoginAuthenticationToken.authenticated(oAuth2User); + } + + private OAuth2AccessToken exchangeToAccessToken(OAuth2LoginAuthenticationToken loginAuthenticationToken) { + Authentication unAuthenticated = OAuth2AuthorizationCodeAuthenticationToken.unauthenticated( + loginAuthenticationToken.getClientRegistration(), + loginAuthenticationToken.getAuthorizationExchange() + ); + + OAuth2AuthorizationCodeAuthenticationToken authenticated = + (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider.authenticate(unAuthenticated); + + return authenticated.getAccessToken(); + } + + private OAuth2User loadUser(ClientRegistration clientRegistration, OAuth2AccessToken accessToken) { + return this.userService.loadUser(new OAuth2UserRequest( + clientRegistration, + accessToken + )); + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2LoginAuthenticationToken.java b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2LoginAuthenticationToken.java new file mode 100644 index 00000000..a4fae2c9 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2LoginAuthenticationToken.java @@ -0,0 +1,67 @@ +package nextstep.security.oauth2.client.authentication; + +import nextstep.security.authentication.Authentication; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import nextstep.security.oauth2.core.user.OAuth2User; + +import java.util.Set; + +public class OAuth2LoginAuthenticationToken implements Authentication { + + private OAuth2User oauth2User; + private final ClientRegistration clientRegistration; + private final OAuth2AuthorizationExchange authorizationExchange; + private final boolean authenticated; + + private OAuth2LoginAuthenticationToken( + OAuth2User oauth2User, + ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange, + boolean authenticated + ) { + this.oauth2User = oauth2User; + this.clientRegistration = clientRegistration; + this.authorizationExchange = authorizationExchange; + this.authenticated = authenticated; + } + + public static OAuth2LoginAuthenticationToken unauthenticated( + ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange + ) { + return new OAuth2LoginAuthenticationToken(null, clientRegistration, authorizationExchange, false); + } + + public static OAuth2LoginAuthenticationToken authenticated(OAuth2User oAuth2User) { + return new OAuth2LoginAuthenticationToken(oAuth2User, null, null, true); + } + + @Override + public boolean isAuthenticated() { + return this.authenticated; + } + + @Override + public Object getPrincipal() { + return this.oauth2User; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Set getAuthorities() { + return null; // todo: fill authorities + } + + public ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + + public OAuth2AuthorizationExchange getAuthorizationExchange() { + return this.authorizationExchange; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AccessTokenResponse.java b/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AccessTokenResponse.java new file mode 100644 index 00000000..27606c9c --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AccessTokenResponse.java @@ -0,0 +1,9 @@ +package nextstep.security.oauth2.client.endpoint; + +import nextstep.security.oauth2.core.OAuth2AccessToken; + +public record OAuth2AccessTokenResponse( + OAuth2AccessToken accessToken +) { + +} diff --git a/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AccessTokenResponseClient.java b/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AccessTokenResponseClient.java new file mode 100644 index 00000000..9b327df2 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AccessTokenResponseClient.java @@ -0,0 +1,50 @@ +package nextstep.security.oauth2.client.endpoint; + +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AccessToken; +import nextstep.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Map; + +public class OAuth2AccessTokenResponseClient { + + private final RestOperations restOperations = new RestTemplate(); + + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest codeGrantRequest) { + RequestEntity requestEntity = convertToRequestEntity(codeGrantRequest); + return getResponse(requestEntity); + } + + private RequestEntity> convertToRequestEntity( + OAuth2AuthorizationCodeGrantRequest request + ) { + ClientRegistration registration = request.getRegistration(); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add(OAuth2ParameterNames.CODE, request.getAuthorizationExchange().authorizationResponse().code()); + params.add(OAuth2ParameterNames.CLIENT_ID, registration.clientId()); + params.add(OAuth2ParameterNames.CLIENT_SECRET, registration.clientSecret()); + params.add(OAuth2ParameterNames.REDIRECT_URI, registration.redirectUri()); + params.add(OAuth2ParameterNames.GRANT_TYPE, request.getAuthorizationGrantType()); + + return RequestEntity + .post(URI.create(registration.providerDetails().tokenUri())) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params); + } + + private OAuth2AccessTokenResponse getResponse(RequestEntity requestEntity) { + ResponseEntity results = restOperations.exchange(requestEntity, Map.class); + Map responseBody = results.getBody(); + String accessTokenValue = (String) responseBody.get(OAuth2ParameterNames.ACCESS_TOKEN); + return new OAuth2AccessTokenResponse(new OAuth2AccessToken(accessTokenValue)); + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequest.java b/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequest.java new file mode 100644 index 00000000..5d29dffa --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequest.java @@ -0,0 +1,31 @@ +package nextstep.security.oauth2.client.endpoint; + +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; + +public final class OAuth2AuthorizationCodeGrantRequest { + + private static final String AUTHORIZATION_CODE_GRANT_TYPE = "authorization_code"; + private final ClientRegistration registration; + private final OAuth2AuthorizationExchange authorizationExchange; + + public OAuth2AuthorizationCodeGrantRequest( + ClientRegistration registration, + OAuth2AuthorizationExchange authorizationExchange + ) { + this.registration = registration; + this.authorizationExchange = authorizationExchange; + } + + public String getAuthorizationGrantType() { + return AUTHORIZATION_CODE_GRANT_TYPE; + } + + public ClientRegistration getRegistration() { + return this.registration; + } + + public OAuth2AuthorizationExchange getAuthorizationExchange() { + return this.authorizationExchange; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/registration/ClientRegistration.java b/src/main/java/nextstep/security/oauth2/client/registration/ClientRegistration.java new file mode 100644 index 00000000..cb42e0a0 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/registration/ClientRegistration.java @@ -0,0 +1,22 @@ +package nextstep.security.oauth2.client.registration; + +import java.util.Set; + +public record ClientRegistration( + String registrationId, + String clientId, + String clientSecret, + String redirectUri, + Set scopes, + ProviderDetails providerDetails +) { + + public record ProviderDetails( + String authorizationUri, + String tokenUri, + String userInfoUri, + String userNameAttribute + ) { + + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/registration/ClientRegistrationRepository.java b/src/main/java/nextstep/security/oauth2/client/registration/ClientRegistrationRepository.java new file mode 100644 index 00000000..a11f75f6 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/registration/ClientRegistrationRepository.java @@ -0,0 +1,6 @@ +package nextstep.security.oauth2.client.registration; + +public interface ClientRegistrationRepository { + + ClientRegistration findByRegistrationId(String registrationId); +} diff --git a/src/main/java/nextstep/security/oauth2/client/registration/InMemoryClientRegistrationRepository.java b/src/main/java/nextstep/security/oauth2/client/registration/InMemoryClientRegistrationRepository.java new file mode 100644 index 00000000..e0406d61 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/registration/InMemoryClientRegistrationRepository.java @@ -0,0 +1,28 @@ +package nextstep.security.oauth2.client.registration; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class InMemoryClientRegistrationRepository implements ClientRegistrationRepository { + + private final Map registrations; // + + public InMemoryClientRegistrationRepository(List registrations) { + this.registrations = registrations.stream() + .collect(Collectors.toMap( + ClientRegistration::registrationId, + registration -> registration + )); + } + + @Override + @Nullable + public ClientRegistration findByRegistrationId(String registrationId) { + Assert.hasText(registrationId, "registrationId cannot be empty"); + return registrations.get(registrationId); + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/userinfo/DefaultOAuth2UserService.java b/src/main/java/nextstep/security/oauth2/client/userinfo/DefaultOAuth2UserService.java new file mode 100644 index 00000000..d9a01e4e --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/userinfo/DefaultOAuth2UserService.java @@ -0,0 +1,50 @@ +package nextstep.security.oauth2.client.userinfo; + +import nextstep.security.authentication.AuthenticationException; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.user.OAuth2User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Map; +import java.util.Objects; + +public class DefaultOAuth2UserService implements OAuth2UserService { + + private final Logger logger = LoggerFactory.getLogger(DefaultOAuth2UserService.class); + private final RestOperations restOperations = new RestTemplate(); + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + ClientRegistration.ProviderDetails provider = userRequest.registration().providerDetails(); + + RequestEntity request = RequestEntity + .get(URI.create(provider.userInfoUri())) + .headers(headers -> headers.setBearerAuth(userRequest.accessToken().value())) + .build(); + + logger.info("request: {}", request); + ResponseEntity> response = this.restOperations.exchange( + request, + new ParameterizedTypeReference<>() {} + ); + logger.info("response: {}", response); + + Map userInfo = Objects.requireNonNullElseGet(response.getBody(), () -> { + throw new AuthenticationException("Failed to get user info"); + }); + + String usernameAttributeName = provider.userNameAttribute(); + Object username = Objects.requireNonNullElseGet(userInfo.get(usernameAttributeName), () -> { + throw new AuthenticationException("Failed to get user identifier(=%s)".formatted(usernameAttributeName)); + }); + + return username::toString; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserRequest.java b/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserRequest.java new file mode 100644 index 00000000..2c33a29b --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserRequest.java @@ -0,0 +1,11 @@ +package nextstep.security.oauth2.client.userinfo; + +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AccessToken; + +public record OAuth2UserRequest( + ClientRegistration registration, + OAuth2AccessToken accessToken +) { + +} diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2UserService.java b/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserService.java similarity index 52% rename from src/main/java/nextstep/security/oauth2/authentication/OAuth2UserService.java rename to src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserService.java index 7b8dbea1..c0e3fc05 100644 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2UserService.java +++ b/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserService.java @@ -1,4 +1,6 @@ -package nextstep.security.oauth2.authentication; +package nextstep.security.oauth2.client.userinfo; + +import nextstep.security.oauth2.core.user.OAuth2User; @FunctionalInterface public interface OAuth2UserService { diff --git a/src/main/java/nextstep/security/oauth2/client/web/AuthorizationRequestRepository.java b/src/main/java/nextstep/security/oauth2/client/web/AuthorizationRequestRepository.java new file mode 100644 index 00000000..cdd3df26 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/AuthorizationRequestRepository.java @@ -0,0 +1,18 @@ +package nextstep.security.oauth2.client.web; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +public interface AuthorizationRequestRepository { + + OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request); + + void saveAuthorizationRequest( + OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response + ); + + OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request); +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java b/src/main/java/nextstep/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java new file mode 100644 index 00000000..85914d6b --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,63 @@ +package nextstep.security.oauth2.client.web; + +import jakarta.servlet.http.HttpServletRequest; +import nextstep.security.authentication.AuthenticationException; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.client.registration.ClientRegistrationRepository; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.lang.Nullable; +import org.springframework.web.util.UriComponentsBuilder; + +public class DefaultOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + + private final String authorizationRequestBaseUri; + private final ClientRegistrationRepository registrationRepository; + + public DefaultOAuth2AuthorizationRequestResolver( + String authorizationRequestBaseUri, + ClientRegistrationRepository registrationRepository + ) { + this.authorizationRequestBaseUri = authorizationRequestBaseUri; + this.registrationRepository = registrationRepository; + } + + @Override + @Nullable + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + String registrationId = resolveRegistrationId(request); + + if (registrationId == null) { + return null; + } + + return resolve(registrationId); + } + + @Nullable + private String resolveRegistrationId(HttpServletRequest request) { + String requestUri = request.getRequestURI(); + + if (requestUri.startsWith(authorizationRequestBaseUri)) { + return requestUri.substring(authorizationRequestBaseUri.length()); + } + + return null; + } + + private OAuth2AuthorizationRequest resolve(String registrationId) { + ClientRegistration registration = registrationRepository.findByRegistrationId(registrationId); + if (registration == null) { + throw new AuthenticationException("Cannot find ClientRegistration with registrationId: " + registrationId); + } + + String redirectUri = UriComponentsBuilder.fromHttpUrl(registration.providerDetails().authorizationUri()) + .queryParam("client_id", registration.clientId()) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", registration.scopes())) + .queryParam("redirect_uri", registration.redirectUri()) + .build() + .toUriString(); + + return new OAuth2AuthorizationRequest(registrationId, redirectUri); + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/HttpSessionOAuth2AuthorizationRequestRepository.java b/src/main/java/nextstep/security/oauth2/client/web/HttpSessionOAuth2AuthorizationRequestRepository.java new file mode 100644 index 00000000..27e1c0bf --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/HttpSessionOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,65 @@ +package nextstep.security.oauth2.client.web; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +public class HttpSessionOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository { + + private static final String DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME = + HttpSessionOAuth2AuthorizationRequestRepository.class.getName() + ".AUTHORIZATION_REQUEST"; + + private static HttpSessionOAuth2AuthorizationRequestRepository INSTANCE; + + private final String sessionAttributeName = DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME; + + public static HttpSessionOAuth2AuthorizationRequestRepository getInstance() { + // 싱글톤 Lazy Initialization 이유: INSTANCE가 DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME 보다 먼저 선언되면 sessionAttributeName이 null이 된다. + if (INSTANCE == null) { + INSTANCE = new HttpSessionOAuth2AuthorizationRequestRepository(); + } + return INSTANCE; + } + + @Override + @Nullable + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + Assert.notNull(request, "request cannot be null"); + + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + + return (OAuth2AuthorizationRequest) session.getAttribute(this.sessionAttributeName); + } + + @Override + public void saveAuthorizationRequest( + OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response + ) { + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); + + request.getSession().setAttribute(this.sessionAttributeName, authorizationRequest); + } + + @Override + @Nullable + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) { + Assert.notNull(request, "request cannot be null"); + + OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request); + if (authorizationRequest == null) { + return null; + } + + request.getSession().removeAttribute(this.sessionAttributeName); + return authorizationRequest; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationRequestRedirectFilter.java b/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationRequestRedirectFilter.java new file mode 100644 index 00000000..0f41c5ce --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationRequestRedirectFilter.java @@ -0,0 +1,63 @@ +package nextstep.security.oauth2.client.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import nextstep.security.oauth2.client.registration.ClientRegistrationRepository; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +public class OAuth2AuthorizationRequestRedirectFilter extends GenericFilterBean { + + public static final String OAUTH2_LOGIN_REQUEST_URI_PREFIX = "/oauth2/authorization/"; + private final OAuth2AuthorizationRequestResolver authorizationRequestResolver; + private final AuthorizationRequestRepository authorizationRequestRepository = HttpSessionOAuth2AuthorizationRequestRepository.getInstance(); + + public OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository clientRegistrationRepository) { + this.authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver( + OAUTH2_LOGIN_REQUEST_URI_PREFIX, + clientRegistrationRepository + ); + } + + @Override + public void doFilter( + ServletRequest request, + ServletResponse response, + FilterChain chain + ) throws IOException, ServletException { + doFilterInternal((HttpServletRequest) request, (HttpServletResponse) response, chain); + } + + private void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain + ) throws ServletException, IOException { + if (!matchesPattern(request)) { + chain.doFilter(request, response); + return; + } + + OAuth2AuthorizationRequest authorizationRequest = authorizationRequestResolver.resolve(request); + sendRedirectForAuthorization(authorizationRequest, request, response); + } + + private void sendRedirectForAuthorization( + OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response); + response.sendRedirect(authorizationRequest.redirectUri()); + } + + private boolean matchesPattern(HttpServletRequest request) { + return request.getRequestURI().startsWith(OAUTH2_LOGIN_REQUEST_URI_PREFIX); + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationRequestResolver.java b/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationRequestResolver.java new file mode 100644 index 00000000..e5861339 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationRequestResolver.java @@ -0,0 +1,9 @@ +package nextstep.security.oauth2.client.web; + +import jakarta.servlet.http.HttpServletRequest; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +public interface OAuth2AuthorizationRequestResolver { + + OAuth2AuthorizationRequest resolve(HttpServletRequest request); +} diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2LoginAuthenticationFilter.java b/src/main/java/nextstep/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java similarity index 53% rename from src/main/java/nextstep/security/oauth2/authentication/OAuth2LoginAuthenticationFilter.java rename to src/main/java/nextstep/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java index a4f63b30..f27a20a9 100644 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2LoginAuthenticationFilter.java +++ b/src/main/java/nextstep/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java @@ -1,4 +1,4 @@ -package nextstep.security.oauth2.authentication; +package nextstep.security.oauth2.client.web; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -13,7 +13,15 @@ import nextstep.security.context.HttpSessionSecurityContextRepository; import nextstep.security.context.SecurityContext; import nextstep.security.context.SecurityContextHolder; -import nextstep.security.oauth2.provider.OAuth2ClientProperties; +import nextstep.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider; +import nextstep.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.client.registration.ClientRegistrationRepository; +import nextstep.security.oauth2.client.userinfo.OAuth2UserService; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import nextstep.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import nextstep.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -23,21 +31,25 @@ import java.io.IOException; import java.util.List; -import static nextstep.security.oauth2.provider.Oauth2Constants.LOGIN_CALL_BACK_URI_PREFIX; - public class OAuth2LoginAuthenticationFilter extends GenericFilterBean { private static final Logger logger = LoggerFactory.getLogger(OAuth2LoginAuthenticationFilter.class); + private static final String LOGIN_CALL_BACK_URI = "/login/oauth2/code/"; + private final AuthenticationManager authenticationManager; private final HttpSessionSecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); - private final OAuth2ClientProperties oAuth2Properties; + private final ClientRegistrationRepository clientRegistrationRepository; + private final AuthorizationRequestRepository authorizationRequestRepository = HttpSessionOAuth2AuthorizationRequestRepository.getInstance(); - public OAuth2LoginAuthenticationFilter(OAuth2ClientProperties oAuth2Properties, OAuth2UserService userService) { - this.oAuth2Properties = oAuth2Properties; + public OAuth2LoginAuthenticationFilter( + OAuth2UserService userService, + ClientRegistrationRepository clientRegistrationRepository + ) { this.authenticationManager = new ProviderManager( - List.of(new OAuth2AuthenticationProvider(userService)) + List.of(new OAuth2LoginAuthenticationProvider(userService)) ); + this.clientRegistrationRepository = clientRegistrationRepository; } @Override @@ -59,15 +71,8 @@ private void doFilterInternal( return; } - String authorizationCode = request.getParameter("code"); - if (!StringUtils.hasText(authorizationCode)) { - logger.error("Authorization code is missing"); - response.sendError(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase()); - return; - } - try { - Authentication authResult = attemptAuthentication(request, new OAuth2AuthorizationCode(authorizationCode)); + Authentication authResult = attemptAuthentication(request); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authResult); @@ -85,37 +90,41 @@ private void doFilterInternal( } private boolean matchesPattern(HttpServletRequest request) { - return request.getRequestURI().startsWith(LOGIN_CALL_BACK_URI_PREFIX); + return request.getRequestURI().startsWith(LOGIN_CALL_BACK_URI); } - private Authentication attemptAuthentication(HttpServletRequest request, OAuth2AuthorizationCode code) { - Authentication authRequest = generateUnAuthenticatedToken(request, code); - return this.authenticationManager.authenticate(authRequest); - } - - private OAuth2AuthenticationToken generateUnAuthenticatedToken( - HttpServletRequest request, - OAuth2AuthorizationCode code - ) { - String providerName = getProvider(request); - OAuth2ClientProperties.Registration registration = oAuth2Properties.getRegistration().get(providerName); - if (registration == null) { - throw new AuthenticationException("Invalid registration: " + providerName); + private Authentication attemptAuthentication(HttpServletRequest request) { + OAuth2AuthorizationRequest authorizationRequest = + authorizationRequestRepository.removeAuthorizationRequest(request); + if (authorizationRequest == null) { + throw new AuthenticationException("Authorization request not found"); } - OAuth2ClientProperties.Provider provider = oAuth2Properties.getProvider().get(providerName); - if (provider == null) { - throw new AuthenticationException("Invalid provider: " + providerName); - } + OAuth2AuthorizationResponse authorizationResponse = convertToResponse(authorizationRequest, request); + + ClientRegistration registration = + clientRegistrationRepository.findByRegistrationId(authorizationRequest.registrationId()); + + Authentication authentication = OAuth2LoginAuthenticationToken.unauthenticated( + registration, + new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse) + ); - return OAuth2AuthenticationToken.unauthenticated(ClientRegistration.of(registration, provider), code); + return this.authenticationManager.authenticate(authentication); } - private String getProvider(HttpServletRequest request) { - String provider = request.getRequestURI().substring(LOGIN_CALL_BACK_URI_PREFIX.length()); - if (!StringUtils.hasText(provider)) { - throw new AuthenticationException("Cannot extract provider from request URI"); + private OAuth2AuthorizationResponse convertToResponse( + OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest httpRequest + ) { + String authorizationCode = httpRequest.getParameter(OAuth2ParameterNames.CODE); + if (!StringUtils.hasText(authorizationCode)) { + throw new AuthenticationException("Authorization code is missing"); } - return provider; + + return new OAuth2AuthorizationResponse( + authorizationRequest.redirectUri(), + authorizationCode + ); } } diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AccessToken.java b/src/main/java/nextstep/security/oauth2/core/OAuth2AccessToken.java similarity index 80% rename from src/main/java/nextstep/security/oauth2/authentication/OAuth2AccessToken.java rename to src/main/java/nextstep/security/oauth2/core/OAuth2AccessToken.java index 3535aedd..3778814b 100644 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2AccessToken.java +++ b/src/main/java/nextstep/security/oauth2/core/OAuth2AccessToken.java @@ -1,4 +1,4 @@ -package nextstep.security.oauth2.authentication; +package nextstep.security.oauth2.core; import org.springframework.util.Assert; diff --git a/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationExchange.java b/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationExchange.java new file mode 100644 index 00000000..1a2b6efb --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationExchange.java @@ -0,0 +1,8 @@ +package nextstep.security.oauth2.core.endpoint; + +public record OAuth2AuthorizationExchange( + OAuth2AuthorizationRequest authorizationRequest, + OAuth2AuthorizationResponse authorizationResponse +) { + +} diff --git a/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationRequest.java b/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationRequest.java new file mode 100644 index 00000000..15671bc7 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationRequest.java @@ -0,0 +1,8 @@ +package nextstep.security.oauth2.core.endpoint; + +public record OAuth2AuthorizationRequest( + String registrationId, + String redirectUri +) { + +} diff --git a/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationResponse.java b/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationResponse.java new file mode 100644 index 00000000..c21f3965 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2AuthorizationResponse.java @@ -0,0 +1,8 @@ +package nextstep.security.oauth2.core.endpoint; + +public record OAuth2AuthorizationResponse( + String redirectUri, + String code +) { + +} diff --git a/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2ParameterNames.java b/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2ParameterNames.java new file mode 100644 index 00000000..e0655e51 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/endpoint/OAuth2ParameterNames.java @@ -0,0 +1,14 @@ +package nextstep.security.oauth2.core.endpoint; + +public class OAuth2ParameterNames { + + private OAuth2ParameterNames() { + } + + public static final String CLIENT_ID = "client_id"; + public static final String CLIENT_SECRET = "client_secret"; + public static final String REDIRECT_URI = "redirect_uri"; + public static final String ACCESS_TOKEN = "access_token"; + public static final String CODE = "code"; + public static final String GRANT_TYPE = "grant_type"; +} diff --git a/src/main/java/nextstep/security/oauth2/authentication/OAuth2User.java b/src/main/java/nextstep/security/oauth2/core/user/OAuth2User.java similarity index 61% rename from src/main/java/nextstep/security/oauth2/authentication/OAuth2User.java rename to src/main/java/nextstep/security/oauth2/core/user/OAuth2User.java index 316d533c..b449bf85 100644 --- a/src/main/java/nextstep/security/oauth2/authentication/OAuth2User.java +++ b/src/main/java/nextstep/security/oauth2/core/user/OAuth2User.java @@ -1,4 +1,4 @@ -package nextstep.security.oauth2.authentication; +package nextstep.security.oauth2.core.user; @FunctionalInterface public interface OAuth2User { diff --git a/src/main/java/nextstep/security/oauth2/provider/GitHubClient.java b/src/main/java/nextstep/security/oauth2/provider/GitHubClient.java deleted file mode 100644 index 9564c0dc..00000000 --- a/src/main/java/nextstep/security/oauth2/provider/GitHubClient.java +++ /dev/null @@ -1,98 +0,0 @@ -package nextstep.security.oauth2.provider; - -import nextstep.security.authentication.AuthenticationException; -import nextstep.security.oauth2.authentication.ClientRegistration; -import nextstep.security.oauth2.authentication.OAuth2AccessToken; -import nextstep.security.oauth2.authentication.OAuth2AuthorizationCode; -import nextstep.security.oauth2.authentication.OAuth2User; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.*; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import static nextstep.security.oauth2.provider.Oauth2Constants.GITHUB; - -public class GitHubClient implements OAuth2ProviderClient { - - public static final GitHubClient INSTANCE = new GitHubClient(); - - private static final Logger logger = LoggerFactory.getLogger(GitHubClient.class); - - private final RestTemplate restTemplate = new RestTemplate(); - - private GitHubClient() { - } - - @Override - public String getProviderName() { - return GITHUB; - } - - @Override - public OAuth2AccessToken fetchAccessToken(ClientRegistration registration, OAuth2AuthorizationCode code) { - ResponseEntity responseEntity = callAccessTokenApi(registration, code); - - AccessTokenResponse tokenResponse = Objects.requireNonNullElseGet(responseEntity.getBody(), () -> { - throw new AuthenticationException("Failed to get access token"); - }); - - return new OAuth2AccessToken(tokenResponse.accessToken()); - } - - @Override - public OAuth2User fetchUser(ClientRegistration registration, OAuth2AccessToken accessToken) { - ClientRegistration.ProviderDetails provider = registration.providerDetails(); - - RequestEntity request = RequestEntity - .get(URI.create(provider.userInfoUri())) - .headers(headers -> headers.setBearerAuth(accessToken.value())) - .build(); - logger.info("request: {}", request); - - ResponseEntity> response = this.restTemplate.exchange( - request, - new ParameterizedTypeReference<>() {} - ); - logger.info("response: {}", response); - - Map userInfo = Objects.requireNonNullElseGet(response.getBody(), () -> { - throw new AuthenticationException("Failed to get user info"); - }); - - String usernameAttributeName = provider.userNameAttribute(); - Object username = Objects.requireNonNullElseGet(userInfo.get(usernameAttributeName), () -> { - throw new AuthenticationException("Failed to get user identifier(=%s)".formatted(usernameAttributeName)); - }); - - return username::toString; - } - - private ResponseEntity callAccessTokenApi(ClientRegistration registration, OAuth2AuthorizationCode code) { - ClientRegistration.ProviderDetails provider = registration.providerDetails(); - - URI accessTokenUrl = UriComponentsBuilder.fromHttpUrl(provider.tokenUri()) - .queryParam("client_id", registration.clientId()) - .queryParam("client_secret", registration.clientSecret()) - .queryParam("code", code.codeValue()) - .build() - .toUri(); - - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(List.of(MediaType.APPLICATION_JSON)); - HttpEntity requestEntity = new HttpEntity<>(headers); - - return this.restTemplate.exchange( - accessTokenUrl, - HttpMethod.POST, - requestEntity, - AccessTokenResponse.class - ); - } -} diff --git a/src/main/java/nextstep/security/oauth2/provider/GoogleClient.java b/src/main/java/nextstep/security/oauth2/provider/GoogleClient.java deleted file mode 100644 index 6a0c2cbf..00000000 --- a/src/main/java/nextstep/security/oauth2/provider/GoogleClient.java +++ /dev/null @@ -1,92 +0,0 @@ -package nextstep.security.oauth2.provider; - -import nextstep.security.authentication.AuthenticationException; -import nextstep.security.oauth2.authentication.ClientRegistration; -import nextstep.security.oauth2.authentication.OAuth2AccessToken; -import nextstep.security.oauth2.authentication.OAuth2AuthorizationCode; -import nextstep.security.oauth2.authentication.OAuth2User; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestOperations; -import org.springframework.web.client.RestTemplate; - -import java.net.URI; -import java.util.Map; -import java.util.Objects; - -import static nextstep.security.oauth2.provider.Oauth2Constants.GOOGLE; - -public class GoogleClient implements OAuth2ProviderClient { - - public static final GoogleClient INSTANCE = new GoogleClient(); - private static final Logger logger = LoggerFactory.getLogger(GoogleClient.class); - - private final RestOperations restOperations = new RestTemplate(); - - private GoogleClient() { - } - - @Override - public String getProviderName() { - return GOOGLE; - } - - @Override - public OAuth2AccessToken fetchAccessToken(ClientRegistration registration, OAuth2AuthorizationCode code) { - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("code", code.codeValue()); - params.add("client_id", registration.clientId()); - params.add("client_secret", registration.clientSecret()); - params.add("redirect_uri", "http://localhost:8080/login/oauth2/code/google"); - params.add("grant_type", "authorization_code"); - - RequestEntity> request = RequestEntity - .post(URI.create(registration.providerDetails().tokenUri())) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(params); - logger.info("request: {}", request); - - ResponseEntity response = restOperations.exchange(request, AccessTokenResponse.class); - logger.info("response: {}", response); - - AccessTokenResponse tokenResponse = Objects.requireNonNullElseGet(response.getBody(), () -> { - throw new AuthenticationException("Failed to get access token"); - }); - - return new OAuth2AccessToken(tokenResponse.accessToken()); - } - - @Override - public OAuth2User fetchUser(ClientRegistration registration, OAuth2AccessToken accessToken) { - ClientRegistration.ProviderDetails provider = registration.providerDetails(); - - RequestEntity request = RequestEntity - .get(URI.create(provider.userInfoUri())) - .headers(headers -> headers.setBearerAuth(accessToken.value())) - .build(); - logger.info("request: {}", request); - - ResponseEntity> response = this.restOperations.exchange( - request, - new ParameterizedTypeReference<>() {} - ); - logger.info("response: {}", response); - - Map userInfo = Objects.requireNonNullElseGet(response.getBody(), () -> { - throw new AuthenticationException("Failed to get user info"); - }); - - String usernameAttributeName = provider.userNameAttribute(); - Object username = Objects.requireNonNullElseGet(userInfo.get(usernameAttributeName), () -> { - throw new AuthenticationException("Failed to get user identifier(=%s)".formatted(usernameAttributeName)); - }); - - return username::toString; - } -} diff --git a/src/main/java/nextstep/security/oauth2/provider/OAuth2ProviderClient.java b/src/main/java/nextstep/security/oauth2/provider/OAuth2ProviderClient.java deleted file mode 100644 index 3641f2a8..00000000 --- a/src/main/java/nextstep/security/oauth2/provider/OAuth2ProviderClient.java +++ /dev/null @@ -1,15 +0,0 @@ -package nextstep.security.oauth2.provider; - -import nextstep.security.oauth2.authentication.ClientRegistration; -import nextstep.security.oauth2.authentication.OAuth2AccessToken; -import nextstep.security.oauth2.authentication.OAuth2AuthorizationCode; -import nextstep.security.oauth2.authentication.OAuth2User; - -public interface OAuth2ProviderClient { - - String getProviderName(); - - OAuth2AccessToken fetchAccessToken(ClientRegistration registration, OAuth2AuthorizationCode code); - - OAuth2User fetchUser(ClientRegistration registration, OAuth2AccessToken accessToken); -} diff --git a/src/main/java/nextstep/security/oauth2/provider/OAuth2ProviderClientFactory.java b/src/main/java/nextstep/security/oauth2/provider/OAuth2ProviderClientFactory.java deleted file mode 100644 index 417b96ef..00000000 --- a/src/main/java/nextstep/security/oauth2/provider/OAuth2ProviderClientFactory.java +++ /dev/null @@ -1,29 +0,0 @@ -package nextstep.security.oauth2.provider; - -import nextstep.security.authentication.AuthenticationException; -import org.springframework.lang.NonNull; - -import java.util.Map; - -public class OAuth2ProviderClientFactory { - - public static OAuth2ProviderClientFactory INSTANCE = new OAuth2ProviderClientFactory(); - - private final Map clients; - - private OAuth2ProviderClientFactory() { - this.clients = Map.of( - GoogleClient.INSTANCE.getProviderName(), GoogleClient.INSTANCE, - GitHubClient.INSTANCE.getProviderName(), GitHubClient.INSTANCE - ); - } - - @NonNull - public OAuth2ProviderClient getProviderClient(String providerName) { - OAuth2ProviderClient client = this.clients.get(providerName); - if (client == null) { - throw new AuthenticationException("Unsupported OAuth2Provider: %s".formatted(providerName)); - } - return client; - } -} diff --git a/src/main/java/nextstep/security/oauth2/provider/Oauth2Constants.java b/src/main/java/nextstep/security/oauth2/provider/Oauth2Constants.java deleted file mode 100644 index b3f94c8e..00000000 --- a/src/main/java/nextstep/security/oauth2/provider/Oauth2Constants.java +++ /dev/null @@ -1,11 +0,0 @@ -package nextstep.security.oauth2.provider; - -public class Oauth2Constants { - private Oauth2Constants() { - } - - public static final String GITHUB = "github"; - public static final String GOOGLE = "google"; - - public static final String LOGIN_CALL_BACK_URI_PREFIX = "/login/oauth2/code/"; -} diff --git a/src/test/java/nextstep/app/OAuth2AuthorizationRequestRedirectFilterTest.java b/src/test/java/nextstep/app/OAuth2AuthorizationRequestRedirectFilterTest.java index 43a06d5e..b3eec52f 100644 --- a/src/test/java/nextstep/app/OAuth2AuthorizationRequestRedirectFilterTest.java +++ b/src/test/java/nextstep/app/OAuth2AuthorizationRequestRedirectFilterTest.java @@ -1,7 +1,7 @@ package nextstep.app; import nextstep.app.testsupport.BaseIntegrationTestSupport; -import nextstep.security.oauth2.provider.OAuth2ClientProperties; +import nextstep.oauth2.OAuth2ClientProperties; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.MockMvc; diff --git a/src/test/java/nextstep/app/OAuth2LoginAuthenticationFilterTest.java b/src/test/java/nextstep/app/OAuth2LoginAuthenticationFilterTest.java index 3fcefe23..6f0ed81e 100644 --- a/src/test/java/nextstep/app/OAuth2LoginAuthenticationFilterTest.java +++ b/src/test/java/nextstep/app/OAuth2LoginAuthenticationFilterTest.java @@ -3,14 +3,16 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.client.WireMock; -import jakarta.servlet.http.HttpSession; +import nextstep.app.domain.MemberRepository; import nextstep.app.testsupport.BaseIntegrationTestSupport; import nextstep.security.context.HttpSessionSecurityContextRepository; import nextstep.security.context.SecurityContext; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; @@ -26,6 +28,9 @@ @AutoConfigureWireMock(port = 8089) class OAuth2LoginAuthenticationFilterTest extends BaseIntegrationTestSupport { + @Autowired + MemberRepository memberRepository; + @BeforeEach void setupMockServer() throws Exception { // GitHub @@ -39,44 +44,46 @@ void setupMockServer() throws Exception { @Test void redirectAndRequestGithubAccessToken() throws Exception { - String requestUri = "/login/oauth2/code/github?code=mock_code"; + MockHttpSession session = new MockHttpSession(); + + // 세션에 OAuth2AuthorizationRequest 저장 + mockMvc.perform(get("/oauth2/authorization/github").session(session)) + .andDo(print()) + .andExpect(status().is3xxRedirection()); - ResultActions result = mockMvc.perform(get(requestUri)) + // Authorization Endpoint 인증 후 인증 코드를 'mock_code'로 발급 받았다고 가정 + ResultActions result = mockMvc.perform(get("/login/oauth2/code/github?code=mock_code").session(session)) .andDo(print()); result .andExpect(status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("/")) - .andExpect(request -> { - HttpSession session = request.getRequest().getSession(); - assert session != null; - SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); - assertThat(context).isNotNull(); - assertThat(context.getAuthentication()).isNotNull(); - assertThat(context.getAuthentication().isAuthenticated()).isTrue(); - assertThat(context.getAuthentication().getPrincipal()).isEqualTo("github_999"); - }); + .andExpect(MockMvcResultMatchers.redirectedUrl("/")); + + SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + assertThat(context).isNotNull(); + assertThat(context.getAuthentication()).isNotNull(); + assertThat(context.getAuthentication().isAuthenticated()).isTrue(); } @Test void redirectAndRequestGoogleAccessToken() throws Exception { - String requestUri = "/login/oauth2/code/google?code=mock_code"; + MockHttpSession session = new MockHttpSession(); - ResultActions result = mockMvc.perform(get(requestUri)) + mockMvc.perform(get("/oauth2/authorization/google").session(session)) + .andDo(print()) + .andExpect(status().is3xxRedirection()); + + ResultActions result = mockMvc.perform(get("/login/oauth2/code/google?code=mock_code").session(session)) .andDo(print()); result .andExpect(status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("/")) // 로그인 성공 시 루트로 리다이렉트 - .andExpect(request -> { - HttpSession session = request.getRequest().getSession(); - assert session != null; - SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); - assertThat(context).isNotNull(); - assertThat(context.getAuthentication()).isNotNull(); - assertThat(context.getAuthentication().isAuthenticated()).isTrue(); - assertThat(context.getAuthentication().getPrincipal()).isEqualTo("google_google-identifier"); - }); + .andExpect(MockMvcResultMatchers.redirectedUrl("/")); // 로그인 성공 시 루트로 리다이렉트 + + SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + assertThat(context).isNotNull(); + assertThat(context.getAuthentication()).isNotNull(); + assertThat(context.getAuthentication().isAuthenticated()).isTrue(); } private void stubForGitHubAccessToken() throws JsonProcessingException { diff --git a/src/test/java/nextstep/app/support/SecurityOAuth2PropertiesTest.java b/src/test/java/nextstep/app/support/SecurityOAuth2PropertiesTest.java index f968b11f..76ae8e1e 100644 --- a/src/test/java/nextstep/app/support/SecurityOAuth2PropertiesTest.java +++ b/src/test/java/nextstep/app/support/SecurityOAuth2PropertiesTest.java @@ -1,7 +1,7 @@ package nextstep.app.support; import nextstep.app.testsupport.BaseIntegrationTestSupport; -import nextstep.security.oauth2.provider.OAuth2ClientProperties; +import nextstep.oauth2.OAuth2ClientProperties; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired;