diff --git a/src/main/java/nextstep/app/SecurityConfig.java b/src/main/java/nextstep/app/SecurityConfig.java index 251b2e7..858f5ea 100644 --- a/src/main/java/nextstep/app/SecurityConfig.java +++ b/src/main/java/nextstep/app/SecurityConfig.java @@ -1,5 +1,10 @@ package nextstep.app; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import nextstep.app.domain.Member; import nextstep.app.domain.MemberRepository; import nextstep.app.infrastructure.InmemoryMemberRepository; @@ -9,19 +14,36 @@ import nextstep.security.access.hierarchicalroles.RoleHierarchy; import nextstep.security.access.hierarchicalroles.RoleHierarchyImpl; import nextstep.security.authentication.AuthenticationException; +import nextstep.security.authentication.AuthenticationManager; import nextstep.security.authentication.BasicAuthenticationFilter; -import nextstep.security.authentication.OAuth2AccessTokenClient; -import nextstep.security.authentication.OAuth2AuthenticationFilter; +import nextstep.security.authentication.DaoAuthenticationProvider; import nextstep.security.authentication.OAuth2ClientProperties; -import nextstep.security.authentication.OAuth2LoginRedirectFilter; -import nextstep.security.authentication.OAuth2UserInfoClient; +import nextstep.security.authentication.OAuth2ClientProperties.Provider; +import nextstep.security.authentication.OAuth2ClientProperties.Registration; +import nextstep.security.authentication.ProviderManager; import nextstep.security.authentication.UsernamePasswordAuthenticationFilter; -import nextstep.security.authorization.*; +import nextstep.security.authorization.AuthorityAuthorizationManager; +import nextstep.security.authorization.AuthorizationFilter; +import nextstep.security.authorization.AuthorizationManager; +import nextstep.security.authorization.PermitAllAuthorizationManager; +import nextstep.security.authorization.RequestAuthorizationManager; +import nextstep.security.authorization.SecuredMethodInterceptor; import nextstep.security.config.DefaultSecurityFilterChain; import nextstep.security.config.DelegatingFilterProxy; import nextstep.security.config.FilterChainProxy; import nextstep.security.config.SecurityFilterChain; import nextstep.security.context.SecurityContextHolderFilter; +import nextstep.security.oauth2.client.AuthorizationRequestRepository; +import nextstep.security.oauth2.client.HttpSessionOAuth2AuthorizationRequestRepository; +import nextstep.security.oauth2.client.OAuth2AuthorizationRequestRedirectFilter; +import nextstep.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.client.registration.ClientRegistrationRepository; +import nextstep.security.oauth2.client.registration.ClientRegistrationRepository.InMemoryClientRegistrationRepository; +import nextstep.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import nextstep.security.oauth2.client.userinfo.OAuth2UserService; +import nextstep.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository; +import nextstep.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import nextstep.security.userdetails.UserDetails; import nextstep.security.userdetails.UserDetailsService; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -30,34 +52,22 @@ import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.http.HttpMethod; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - @Configuration @EnableAspectJAutoProxy @EnableConfigurationProperties(OAuth2ClientProperties.class) public class SecurityConfig { - private final MemberRepository memberRepository; - private final OAuth2ClientProperties oAuth2ClientProperties; + private final MemberRepository memberRepository; + private final OAuth2UserService oAuth2UserService; + private final OAuth2ClientProperties oAuth2ClientProperties; public SecurityConfig(MemberRepository memberRepository, OAuth2ClientProperties oAuth2ClientProperties) { this.memberRepository = memberRepository; + this.oAuth2UserService = new DefaultOAuth2UserService(memberRepository); this.oAuth2ClientProperties = oAuth2ClientProperties; } - @Bean - public OAuth2AccessTokenClient oAuth2AccessTokenClient() { - return new OAuth2AccessTokenClient(); - } - - @Bean - public OAuth2UserInfoClient oAuth2UserInfoClient() { - return new OAuth2UserInfoClient(); - } - @Bean public MemberRepository memberRepository() { return new InmemoryMemberRepository(); @@ -73,11 +83,29 @@ public FilterChainProxy filterChainProxy(List securityFilte return new FilterChainProxy(securityFilterChains); } + @Bean + public ClientRegistrationRepository clientRegistrationRepository() { + Map registrations = getClientRegistrations(oAuth2ClientProperties); + return new InMemoryClientRegistrationRepository(registrations); + } + @Bean public SecuredMethodInterceptor securedMethodInterceptor() { return new SecuredMethodInterceptor(); } + @Bean + public AuthenticationManager authenticationManager() { + return new ProviderManager(List.of( + new DaoAuthenticationProvider(userDetailsService()), + new OAuth2LoginAuthenticationProvider(oAuth2UserService))); + } + + @Bean + public AuthorizationRequestRepository authorizationRequestRepository() { + return new HttpSessionOAuth2AuthorizationRequestRepository(); + } + @Bean public SecurityFilterChain securityFilterChain() { return new DefaultSecurityFilterChain( @@ -85,8 +113,9 @@ public SecurityFilterChain securityFilterChain() { new SecurityContextHolderFilter(), new UsernamePasswordAuthenticationFilter(userDetailsService()), new BasicAuthenticationFilter(userDetailsService()), - new OAuth2LoginRedirectFilter(oAuth2ClientProperties), - new OAuth2AuthenticationFilter(oAuth2ClientProperties, memberRepository, oAuth2AccessTokenClient(), oAuth2UserInfoClient()), + new OAuth2AuthorizationRequestRedirectFilter(clientRegistrationRepository(), authorizationRequestRepository()), + new OAuth2LoginAuthenticationFilter(clientRegistrationRepository(), + new HttpSessionOAuth2AuthorizedClientRepository(), authenticationManager()), new AuthorizationFilter(requestAuthorizationManager()) ) ); @@ -102,9 +131,12 @@ public RoleHierarchy roleHierarchy() { @Bean public RequestAuthorizationManager requestAuthorizationManager() { List> mappings = new ArrayList<>(); - mappings.add(new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/members"), new AuthorityAuthorizationManager(roleHierarchy(), "ADMIN"))); - mappings.add(new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/members/me"), new AuthorityAuthorizationManager(roleHierarchy(), "USER"))); - mappings.add(new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/search"), new PermitAllAuthorizationManager())); + mappings.add(new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/members"), + new AuthorityAuthorizationManager(roleHierarchy(), "ADMIN"))); + mappings.add(new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/members/me"), + new AuthorityAuthorizationManager(roleHierarchy(), "USER"))); + mappings.add(new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/search"), + new PermitAllAuthorizationManager())); mappings.add(new RequestMatcherEntry<>(AnyRequestMatcher.INSTANCE, new PermitAllAuthorizationManager())); return new RequestAuthorizationManager(mappings); } @@ -133,4 +165,32 @@ public Set getAuthorities() { }; }; } + + private Map getClientRegistrations(OAuth2ClientProperties oAuth2ClientProperties) { + return oAuth2ClientProperties.getRegistrations().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, + entry -> toClientRegistration(entry.getKey(), entry.getValue()))); + } + + private ClientRegistration toClientRegistration(String registrationId, + Registration registration) { + Provider provider = oAuth2ClientProperties.getProviders().get(registration.provider()); + return new ClientRegistration( + registrationId, + registration.clientId(), + registration.clientSecret(), + registration.redirectUri(), + Set.copyOf(registration.scope()), + registration.authorizationGrantType(), + provider.name(), + new ClientRegistration.ProviderDetails( + provider.authorizationUri(), + provider.tokenUri(), + new ClientRegistration.UserInfoEndpoint( + provider.userInfoUri(), + provider.userNameAttributeName() + ) + ) + ); + } } diff --git a/src/main/java/nextstep/app/ui/LoginController.java b/src/main/java/nextstep/app/ui/LoginController.java index 25d905b..9fda8ac 100644 --- a/src/main/java/nextstep/app/ui/LoginController.java +++ b/src/main/java/nextstep/app/ui/LoginController.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpSession; import nextstep.security.authentication.Authentication; import nextstep.security.context.SecurityContextHolder; +import nextstep.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -32,10 +33,10 @@ private String extractUsername(Authentication authentication) { if (authentication.getPrincipal() instanceof String) { return (String) authentication.getPrincipal(); } -// if (authentication.getPrincipal() instanceof OAuth2User) { -// String userNameAttributeName = ((OAuth2User) authentication.getPrincipal()).getUserNameAttributeName(); -// return (String) ((OAuth2User) authentication.getPrincipal()).getAttributes().get(userNameAttributeName); -// } + if (authentication.getPrincipal() instanceof OAuth2User) { + String userNameAttributeName = ((OAuth2User) authentication.getPrincipal()).getUserNameAttributeName(); + return (String) ((OAuth2User) authentication.getPrincipal()).getAttributes().get(userNameAttributeName); + } return ""; } @@ -54,4 +55,4 @@ public String logout(HttpServletRequest request, HttpServletResponse response) { return "redirect:/"; } -} \ No newline at end of file +} diff --git a/src/main/java/nextstep/security/authentication/GitHubUserInfo.java b/src/main/java/nextstep/security/authentication/GitHubUserInfo.java deleted file mode 100644 index 7f00cc7..0000000 --- a/src/main/java/nextstep/security/authentication/GitHubUserInfo.java +++ /dev/null @@ -1,36 +0,0 @@ -package nextstep.security.authentication; - -public class GitHubUserInfo implements OAuth2UserInfo { - private final String id; - private final String name; - private final String email; - private final String avatar_url; - - public GitHubUserInfo(String id, String name, String email, String avatarUrl) { - this.id = id; - this.name = name; - this.email = email; - this.avatar_url = avatarUrl; - } - - // Getter, Setter - @Override - public String getId() { - return id; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getEmail() { - return email; - } - - @Override - public String getPictureUrl() { - return avatar_url; - } -} diff --git a/src/main/java/nextstep/security/authentication/GoogleUserInfo.java b/src/main/java/nextstep/security/authentication/GoogleUserInfo.java deleted file mode 100644 index 8fd95dc..0000000 --- a/src/main/java/nextstep/security/authentication/GoogleUserInfo.java +++ /dev/null @@ -1,35 +0,0 @@ -package nextstep.security.authentication; - -public class GoogleUserInfo implements OAuth2UserInfo { - private final String id; - private final String email; - private final String name; - private final String picture; - - public GoogleUserInfo(String id, String name, String email, String picture) { - this.id = id; - this.email = email; - this.name = name; - this.picture = picture; - } - - @Override - public String getId() { - return id; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getEmail() { - return email; - } - - @Override - public String getPictureUrl() { - return picture; - } -} diff --git a/src/main/java/nextstep/security/authentication/OAuth2AccessTokenClient.java b/src/main/java/nextstep/security/authentication/OAuth2AccessTokenClient.java deleted file mode 100644 index ddf6bf2..0000000 --- a/src/main/java/nextstep/security/authentication/OAuth2AccessTokenClient.java +++ /dev/null @@ -1,40 +0,0 @@ -package nextstep.security.authentication; - -import static nextstep.security.authentication.OAuth2ClientProperties.*; - -import java.util.HashMap; -import java.util.Map; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.client.RestTemplate; - -public class OAuth2AccessTokenClient { - - private final RestTemplate restTemplate = new RestTemplate(); - - public String getAccessToken(String code, Registration registration, Provider provider) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - Map params = new HashMap<>(); - params.put("code", code); - params.put("client_id", registration.clientId()); - params.put("client_secret", registration.clientSecret()); - params.put("redirect_uri", registration.redirectUri()); - params.put("grant_type", registration.authorizationGrantType()); - - HttpEntity> request = new HttpEntity<>(params, headers); - - ResponseEntity response = restTemplate.exchange( - provider.accessTokenUri(), - HttpMethod.POST, - request, - Map.class - ); - - return response.getBody().get("access_token").toString(); - } -} diff --git a/src/main/java/nextstep/security/authentication/OAuth2AuthenticationFilter.java b/src/main/java/nextstep/security/authentication/OAuth2AuthenticationFilter.java deleted file mode 100644 index 4e503d2..0000000 --- a/src/main/java/nextstep/security/authentication/OAuth2AuthenticationFilter.java +++ /dev/null @@ -1,95 +0,0 @@ -package nextstep.security.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 java.io.IOException; -import java.util.Set; -import nextstep.app.domain.Member; -import nextstep.app.domain.MemberRepository; -import nextstep.security.authentication.OAuth2ClientProperties.Provider; -import nextstep.security.authentication.OAuth2ClientProperties.Registration; -import nextstep.security.context.HttpSessionSecurityContextRepository; -import nextstep.security.context.SecurityContextHolder; -import org.springframework.http.HttpMethod; -import org.springframework.web.filter.GenericFilterBean; - -public class OAuth2AuthenticationFilter extends GenericFilterBean { - - private static final String REQUEST_URI = "/login/oauth2/code"; - - private final OAuth2ClientProperties oAuth2ClientProperties; - private final OAuth2AccessTokenClient oAuth2AccessTokenClient; - private final MemberRepository memberRepository; - private final OAuth2UserInfoClient oAuth2UserInfoClient; - - public OAuth2AuthenticationFilter(OAuth2ClientProperties oAuth2ClientProperties, - MemberRepository memberRepository, - OAuth2AccessTokenClient oAuth2AccessTokenClient, - OAuth2UserInfoClient oAuth2UserInfoClient) { - this.oAuth2AccessTokenClient = oAuth2AccessTokenClient; - this.oAuth2UserInfoClient = oAuth2UserInfoClient; - this.oAuth2ClientProperties = oAuth2ClientProperties; - this.memberRepository = memberRepository; - } - - @Override - public void doFilter(ServletRequest request, ServletResponse response, - FilterChain filterChain) throws IOException, ServletException { - HttpServletRequest httpRequest = (HttpServletRequest) request; - HttpServletResponse httpResponse = (HttpServletResponse) response; - - String requestURI = httpRequest.getRequestURI(); - String method = httpRequest.getMethod(); - - String registrationSource = null; - if (requestURI.startsWith(REQUEST_URI)) { - registrationSource = getRegistrationId(requestURI); - } - - if (registrationSource != null && method.equals(HttpMethod.GET.name())) { - Registration registration = oAuth2ClientProperties.getRegistrations().get(registrationSource); - Provider provider = oAuth2ClientProperties.getProviders().get(registrationSource); - - String code = httpRequest.getParameter("code"); - - String accessToken = oAuth2AccessTokenClient.getAccessToken(code, registration, provider); - - OAuth2UserInfo userInfo = oAuth2UserInfoClient.getUserInfo(accessToken, provider); - - String email = userInfo.getEmail(); - Member member = memberRepository.findByEmail(email) - .orElse(new Member( - email, "", userInfo.getName(), userInfo.getPictureUrl(), Set.of("USER") - )); - - UsernamePasswordAuthenticationToken authentication = - UsernamePasswordAuthenticationToken.authenticated( - member.getEmail(), null, member.getRoles()); - - SecurityContextHolder.getContext().setAuthentication(authentication); - - httpRequest.getSession(true).setAttribute( - HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, - SecurityContextHolder.getContext() - ); - - httpResponse.sendRedirect("/"); - return; - } - - filterChain.doFilter(request, response); - } - - private String getRegistrationId(String requestURI) { - for (String registrationId : oAuth2ClientProperties.getRegistrations().keySet()) { - if (requestURI.contains(registrationId)) { - return registrationId; - } - } - return null; - } -} diff --git a/src/main/java/nextstep/security/authentication/OAuth2ClientProperties.java b/src/main/java/nextstep/security/authentication/OAuth2ClientProperties.java index 9496e7a..752f460 100644 --- a/src/main/java/nextstep/security/authentication/OAuth2ClientProperties.java +++ b/src/main/java/nextstep/security/authentication/OAuth2ClientProperties.java @@ -41,7 +41,8 @@ public record Provider( String authorizationUri, String tokenUri, String userInfoUri, - String accessTokenUri + String accessTokenUri, + String userNameAttributeName ) { } } diff --git a/src/main/java/nextstep/security/authentication/OAuth2LoginRedirectFilter.java b/src/main/java/nextstep/security/authentication/OAuth2LoginRedirectFilter.java deleted file mode 100644 index 1369a2c..0000000 --- a/src/main/java/nextstep/security/authentication/OAuth2LoginRedirectFilter.java +++ /dev/null @@ -1,71 +0,0 @@ -package nextstep.security.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 java.io.IOException; -import java.util.Map; -import nextstep.security.authentication.OAuth2ClientProperties.Provider; -import nextstep.security.authentication.OAuth2ClientProperties.Registration; -import org.springframework.http.HttpMethod; -import org.springframework.web.filter.GenericFilterBean; -import org.springframework.web.util.UriComponentsBuilder; - -public class OAuth2LoginRedirectFilter extends GenericFilterBean { - - private static final String REQUEST_URI = "/oauth2/authorization"; - - private final Map registrations; - private final Map providers; - - public OAuth2LoginRedirectFilter(OAuth2ClientProperties properties) { - this.registrations = properties.getRegistrations(); - this.providers = properties.getProviders(); - } - - @Override - public void doFilter(ServletRequest request, - ServletResponse response, - FilterChain chain) throws IOException, ServletException { - HttpServletRequest httpServletRequest = (HttpServletRequest) request; - HttpServletResponse httpServletResponse = (HttpServletResponse) response; - - String requestURI = httpServletRequest.getRequestURI(); - String method = httpServletRequest.getMethod(); - - String registrationSource = null; - if (requestURI.startsWith(REQUEST_URI)) { - registrationSource = getRegistrationId(requestURI); - } - - if (registrationSource != null && method.equals(HttpMethod.GET.name())) { - Registration registration = registrations.get(registrationSource); - Provider provider = providers.get(registrationSource); - - String redirectUri = UriComponentsBuilder.fromHttpUrl(provider.authorizationUri()) - .queryParam("client_id", registration.clientId()) - .queryParam("scope", String.join(" ", registration.scope())) - .queryParam("response_type", "code") - .queryParam("redirect_uri", registration.redirectUri()) - .build() - .toUriString(); - - httpServletResponse.sendRedirect(redirectUri); - return; - } - - chain.doFilter(request, response); - } - - private String getRegistrationId(String requestURI) { - for (String registrationId : registrations.keySet()) { - if (requestURI.contains(registrationId)) { - return registrationId; - } - } - return null; - } -} diff --git a/src/main/java/nextstep/security/authentication/OAuth2UserInfo.java b/src/main/java/nextstep/security/authentication/OAuth2UserInfo.java deleted file mode 100644 index d5648f7..0000000 --- a/src/main/java/nextstep/security/authentication/OAuth2UserInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package nextstep.security.authentication; - -public interface OAuth2UserInfo { - String getId(); - - String getName(); - - String getEmail(); - - String getPictureUrl(); -} diff --git a/src/main/java/nextstep/security/authentication/OAuth2UserInfoClient.java b/src/main/java/nextstep/security/authentication/OAuth2UserInfoClient.java deleted file mode 100644 index 1f21e85..0000000 --- a/src/main/java/nextstep/security/authentication/OAuth2UserInfoClient.java +++ /dev/null @@ -1,27 +0,0 @@ -package nextstep.security.authentication; - -import java.util.Map; -import nextstep.security.authentication.OAuth2ClientProperties.Provider; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.web.client.RestTemplate; - -public class OAuth2UserInfoClient { - - private final RestTemplate restTemplate = new RestTemplate(); - - public OAuth2UserInfo getUserInfo(String accessToken, Provider provider) { - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.add("Authorization", "Bearer " + accessToken); - - HttpEntity request = new HttpEntity<>(httpHeaders); - ResponseEntity response = restTemplate.exchange(provider.userInfoUri(), HttpMethod.GET, - request, Map.class); - - Map attributes = response.getBody(); - - return OAuth2UserInfoFactory.getOAuth2UserInfo(provider.name(), attributes); - } -} diff --git a/src/main/java/nextstep/security/authentication/OAuth2UserInfoFactory.java b/src/main/java/nextstep/security/authentication/OAuth2UserInfoFactory.java deleted file mode 100644 index 060be53..0000000 --- a/src/main/java/nextstep/security/authentication/OAuth2UserInfoFactory.java +++ /dev/null @@ -1,24 +0,0 @@ -package nextstep.security.authentication; - -import java.util.Map; - -public class OAuth2UserInfoFactory { - public static OAuth2UserInfo getOAuth2UserInfo(String providerName, Map attributes) { - if ("google".equalsIgnoreCase(providerName)) { - return new GoogleUserInfo( - (String) attributes.get("id"), - (String) attributes.get("name"), - (String) attributes.get("email"), - (String) attributes.get("picture") - ); - } else if ("github".equalsIgnoreCase(providerName)) { - return new GitHubUserInfo( - (String) attributes.get("id"), - (String) attributes.get("name"), - (String) attributes.get("email"), - (String) attributes.get("avatar_url") - ); - } - throw new IllegalArgumentException("Unsupported provider: " + providerName); - } -} diff --git a/src/main/java/nextstep/security/oauth2/OAuth2AccessTokenResponseClient.java b/src/main/java/nextstep/security/oauth2/OAuth2AccessTokenResponseClient.java new file mode 100644 index 0000000..a28378c --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/OAuth2AccessTokenResponseClient.java @@ -0,0 +1,74 @@ +package nextstep.security.oauth2; + + +import java.net.URI; +import java.util.Map; +import nextstep.security.oauth2.client.OAuth2AuthorizationCodeGrantRequest; +import nextstep.security.oauth2.client.authentication.OAuth2AccessTokenResponse; +import nextstep.security.oauth2.core.OAuth2AccessToken; +import nextstep.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +public class OAuth2AccessTokenResponseClient { + + private final RestTemplate restOperations = new RestTemplate(); + + private static MultiValueMap getStringStringMultiValueMap( + OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest, String clientId) { + String clientSecret = authorizationCodeGrantRequest.clientRegistration().clientSecret(); + String redirectUri = authorizationCodeGrantRequest.authorizationExchange().authorizationRequest() + .redirectUri(); + String authorizationCode = authorizationCodeGrantRequest.authorizationExchange().authorizationResponse().code(); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("code", authorizationCode); + body.add("redirect_uri", redirectUri); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + return body; + } + + public OAuth2AccessTokenResponse getTokenResponse( + OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + + RequestEntity request = convertRequestEntity(authorizationCodeGrantRequest); + OAuth2AccessTokenResponse tokenResponse = getResponse(request); + Assert.notNull(tokenResponse, + "The authorization server responded to this Authorization Code grant request with an empty body; as such, it cannot be materialized into an OAuth2AccessTokenResponse instance. Please check the HTTP response code in your server logs for more details."); + return tokenResponse; + } + + private RequestEntity convertRequestEntity(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + String tokenUri = authorizationCodeGrantRequest.clientRegistration().providerDetails().tokenUri(); + String clientId = authorizationCodeGrantRequest.clientRegistration().clientId(); + MultiValueMap body = getStringStringMultiValueMap( + authorizationCodeGrantRequest, clientId); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + URI uri = UriComponentsBuilder.fromUriString(tokenUri).build().toUri(); + + return new RequestEntity<>(body, headers, HttpMethod.POST, uri); + } + + private OAuth2AccessTokenResponse getResponse(RequestEntity request) { + try { + ResponseEntity results = this.restOperations.exchange(request, Map.class); + String accessToken = (String) results.getBody().get("access_token"); + return new OAuth2AccessTokenResponse(new OAuth2AccessToken(accessToken)); + } catch (Exception e) { + throw new OAuth2AuthenticationException(); + } + } +} + diff --git a/src/main/java/nextstep/security/oauth2/client/AuthorizationRequestRepository.java b/src/main/java/nextstep/security/oauth2/client/AuthorizationRequestRepository.java new file mode 100644 index 0000000..7482b06 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/AuthorizationRequestRepository.java @@ -0,0 +1,13 @@ +package nextstep.security.oauth2.client; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import nextstep.security.oauth2.core.OAuth2AuthorizationRequest; + +public interface AuthorizationRequestRepository { + OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request); + + void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response); + + OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response); +} diff --git a/src/main/java/nextstep/security/oauth2/client/DefaultOAuth2AuthorizationRequestResolver.java b/src/main/java/nextstep/security/oauth2/client/DefaultOAuth2AuthorizationRequestResolver.java new file mode 100644 index 0000000..550550a --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/DefaultOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,60 @@ +package nextstep.security.oauth2.client; + +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.OAuth2AuthorizationRequest; +import org.springframework.web.util.UriComponentsBuilder; + +public class DefaultOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + private final ClientRegistrationRepository clientRegistrationRepository; + private final String authorizationRequestBaseUri; + + public DefaultOAuth2AuthorizationRequestResolver( + ClientRegistrationRepository clientRegistrationRepository, + String authorizationRequestBaseUri) { + this.clientRegistrationRepository = clientRegistrationRepository; + this.authorizationRequestBaseUri = authorizationRequestBaseUri; + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + String registrationId = resolveRegistrationId(request); + if (registrationId == null) { + return null; + } + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); + + if (clientRegistration == null) { + throw new AuthenticationException("Invalid Client Registration with Id: " + registrationId); + } + + String authorizationRequestUri = UriComponentsBuilder + .fromHttpUrl(clientRegistration.providerDetails().authorizationUri()) + .queryParam("client_id", clientRegistration.clientId()) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", clientRegistration.scope())) + .queryParam("redirect_uri", clientRegistration.redirectUri()) + .build() + .toUriString(); + + return new OAuth2AuthorizationRequest( + clientRegistration.providerDetails().authorizationUri(), + clientRegistration.clientId(), + clientRegistration.redirectUri(), + clientRegistration.scope(), + authorizationRequestUri + ); + } + + private String resolveRegistrationId(HttpServletRequest request) { + String uri = request.getRequestURI(); + + if (uri.startsWith(authorizationRequestBaseUri)) { + return uri.substring(authorizationRequestBaseUri.length()); + } + + return null; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/HttpSessionOAuth2AuthorizationRequestRepository.java b/src/main/java/nextstep/security/oauth2/client/HttpSessionOAuth2AuthorizationRequestRepository.java new file mode 100644 index 0000000..36ed1d6 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/HttpSessionOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,50 @@ +package nextstep.security.oauth2.client; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import nextstep.security.oauth2.core.OAuth2AuthorizationRequest; +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 final String sessionAttributeName = DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME; + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + Assert.notNull(request, "request cannot be null"); + return getAuthorizationRequest(request); + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, + HttpServletResponse response) { + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); + if (authorizationRequest == null) { + removeAuthorizationRequest(request, response); + return; + } + request.getSession().setAttribute(this.sessionAttributeName, authorizationRequest); + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, + HttpServletResponse response) { + Assert.notNull(response, "response cannot be null"); + OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request); + if (authorizationRequest != null) { + request.getSession().removeAttribute(this.sessionAttributeName); + } + return authorizationRequest; + } + + private OAuth2AuthorizationRequest getAuthorizationRequest(HttpServletRequest request) { + HttpSession session = request.getSession(false); + return (session != null) ? (OAuth2AuthorizationRequest) session.getAttribute(this.sessionAttributeName) : null; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationCodeGrantRequest.java b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationCodeGrantRequest.java new file mode 100644 index 0000000..9372f1b --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationCodeGrantRequest.java @@ -0,0 +1,8 @@ +package nextstep.security.oauth2.client; + +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AuthorizationExchange; + +public record OAuth2AuthorizationCodeGrantRequest(ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange) { +} diff --git a/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationRequestRedirectFilter.java b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationRequestRedirectFilter.java new file mode 100644 index 0000000..b8c5a4b --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationRequestRedirectFilter.java @@ -0,0 +1,51 @@ +package nextstep.security.oauth2.client; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import nextstep.security.oauth2.client.registration.ClientRegistrationRepository; +import nextstep.security.oauth2.core.OAuth2AuthenticationException; +import nextstep.security.oauth2.core.OAuth2AuthorizationRequest; +import org.springframework.web.filter.OncePerRequestFilter; + +public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter { + public static final String DEFAULT_AUTHORIZATION_REQUEST_BASE_URI = "/oauth2/authorization/"; + private final OAuth2AuthorizationRequestResolver authorizationRequestResolver; + private final AuthorizationRequestRepository authorizationRequestRepository; + + public OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository clientRegistrationRepository, + AuthorizationRequestRepository authorizationRequestRepository) { + this(clientRegistrationRepository, authorizationRequestRepository, DEFAULT_AUTHORIZATION_REQUEST_BASE_URI); + } + + public OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository clientRegistrationRepository, + AuthorizationRequestRepository authorizationRequestRepository, + String authorizationRequestBaseUri) { + this.authorizationRequestRepository = authorizationRequestRepository; + this.authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, + authorizationRequestBaseUri); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request); + if (authorizationRequest != null) { + sendRedirectForAuthorization(request, response, authorizationRequest); + return; + } + } catch (Exception e) { + throw new OAuth2AuthenticationException("OAuth2 인증 요청을 처리하는데 실패 했습니다: " + e.getMessage()); + } + filterChain.doFilter(request, response); + } + + private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response, + OAuth2AuthorizationRequest authorizationRequest) throws IOException { + this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response); + response.sendRedirect(authorizationRequest.authorizationRequestUri()); + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationRequestResolver.java b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationRequestResolver.java new file mode 100644 index 0000000..a88b5a8 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizationRequestResolver.java @@ -0,0 +1,8 @@ +package nextstep.security.oauth2.client; + +import jakarta.servlet.http.HttpServletRequest; +import nextstep.security.oauth2.core.OAuth2AuthorizationRequest; + +public interface OAuth2AuthorizationRequestResolver { + OAuth2AuthorizationRequest resolve(HttpServletRequest request); +} diff --git a/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizedClient.java b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizedClient.java new file mode 100644 index 0000000..0dbe3cb --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizedClient.java @@ -0,0 +1,8 @@ +package nextstep.security.oauth2.client; + +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AccessToken; + +public record OAuth2AuthorizedClient(ClientRegistration clientRegistration, String principalName, + OAuth2AccessToken accessToken) { +} diff --git a/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizedClientService.java b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizedClientService.java new file mode 100644 index 0000000..0704e5f --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/OAuth2AuthorizedClientService.java @@ -0,0 +1,10 @@ +package nextstep.security.oauth2.client; + +public interface OAuth2AuthorizedClientService { + + OAuth2AuthorizedClient loadAuthorizedClient(String clientRegistrationId, String principalName); + + void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, String principalName); + + void removeAuthorizedClient(String clientRegistrationId, String principalName); +} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/GithubProfileUser.java b/src/main/java/nextstep/security/oauth2/client/authentication/GithubProfileUser.java new file mode 100644 index 0000000..bb8feb2 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/GithubProfileUser.java @@ -0,0 +1,33 @@ +package nextstep.security.oauth2.client.authentication; + +import jakarta.annotation.Nullable; +import java.util.Map; + +public class GithubProfileUser implements OAuth2ProfileUser { + private final String name; + private final String imageUrl; + @Nullable + private final String email; + + public GithubProfileUser(Map attributes) { + this.name = (String) attributes.get("name"); + this.imageUrl = (String) attributes.get("avatar_url"); + this.email = (String) attributes.get("email"); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getImageUrl() { + return imageUrl; + } + + @Override + @Nullable + public String getEmail() { + return email; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/GoogleProfileUser.java b/src/main/java/nextstep/security/oauth2/client/authentication/GoogleProfileUser.java new file mode 100644 index 0000000..27d3a9d --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/GoogleProfileUser.java @@ -0,0 +1,30 @@ +package nextstep.security.oauth2.client.authentication; + +import java.util.Map; + +public class GoogleProfileUser implements OAuth2ProfileUser { + private final String name; + private final String imageUrl; + private final String email; + + public GoogleProfileUser(Map attributes) { + this.name = (String) attributes.get("name"); + this.imageUrl = (String) attributes.get("picture"); + this.email = (String) attributes.get("email"); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getImageUrl() { + return imageUrl; + } + + @Override + public String getEmail() { + return email; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AccessTokenResponse.java b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AccessTokenResponse.java new file mode 100644 index 0000000..601290a --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AccessTokenResponse.java @@ -0,0 +1,19 @@ +package nextstep.security.oauth2.client.authentication; + +import nextstep.security.oauth2.core.OAuth2AccessToken; + +public class OAuth2AccessTokenResponse { + private OAuth2AccessToken accessToken; + + public OAuth2AccessTokenResponse() { + } + + public OAuth2AccessTokenResponse(OAuth2AccessToken accessToken) { + this.accessToken = accessToken; + } + + public OAuth2AccessToken getAccessToken() { + return accessToken; + } +} + diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthenticationToken.java b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthenticationToken.java new file mode 100644 index 0000000..a99013a --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthenticationToken.java @@ -0,0 +1,46 @@ +package nextstep.security.oauth2.client.authentication; + +import java.util.Set; +import nextstep.security.authentication.Authentication; +import nextstep.security.oauth2.core.user.OAuth2User; +import org.springframework.util.Assert; + +public class OAuth2AuthenticationToken implements Authentication { + private final OAuth2User principal; + + private final Set authorities; + + private final boolean authenticated; + + public OAuth2AuthenticationToken(OAuth2User principal, Set authorities) { + Assert.notNull(principal, "principal cannot be null"); + this.principal = principal; + this.authorities = authorities; + this.authenticated = true; + } + + @Override + public Set getAuthorities() { + return authorities; + } + + @Override + public Object getCredentials() { + return ""; + } + + @Override + public Object getPrincipal() { + return principal; + } + + @Override + public boolean isAuthenticated() { + return authenticated; + } + + public String getName() { + return principal.toString(); + } + +} 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 0000000..66f50ef --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java @@ -0,0 +1,37 @@ +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.OAuth2AccessTokenResponseClient; +import nextstep.security.oauth2.client.OAuth2AuthorizationCodeGrantRequest; +import nextstep.security.oauth2.core.OAuth2AuthorizationRequest; +import nextstep.security.oauth2.core.OAuth2AuthorizationResponse; +import org.springframework.util.Assert; + +public class OAuth2AuthorizationCodeAuthenticationProvider implements AuthenticationProvider { + private final OAuth2AccessTokenResponseClient accessTokenResponseClient; + + public OAuth2AuthorizationCodeAuthenticationProvider(OAuth2AccessTokenResponseClient accessTokenResponseClient) { + Assert.notNull(accessTokenResponseClient, "accessTokenResponseClient cannot be null"); + this.accessTokenResponseClient = accessTokenResponseClient; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthentication = (OAuth2AuthorizationCodeAuthenticationToken) authentication; + + OAuth2AccessTokenResponse accessTokenResponse = this.accessTokenResponseClient.getTokenResponse( + new OAuth2AuthorizationCodeGrantRequest(authorizationCodeAuthentication.getClientRegistration(), + authorizationCodeAuthentication.getAuthorizationExchange())); + + return new OAuth2AuthorizationCodeAuthenticationToken( + authorizationCodeAuthentication.getClientRegistration(), + authorizationCodeAuthentication.getAuthorizationExchange(), accessTokenResponse.getAccessToken()); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2AuthorizationCodeAuthenticationToken.class.isAssignableFrom(authentication); + } +} 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 0000000..71d78c1 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2AuthorizationCodeAuthenticationToken.java @@ -0,0 +1,64 @@ +package nextstep.security.oauth2.client.authentication; + + +import java.util.Set; +import nextstep.security.authentication.Authentication; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AccessToken; +import nextstep.security.oauth2.core.OAuth2AuthorizationExchange; + +public class OAuth2AuthorizationCodeAuthenticationToken implements Authentication { + private ClientRegistration clientRegistration; + + private OAuth2AuthorizationExchange authorizationExchange; + + private OAuth2AccessToken accessToken; + + public OAuth2AuthorizationCodeAuthenticationToken(ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange) { + this.clientRegistration = clientRegistration; + this.authorizationExchange = authorizationExchange; + } + + public OAuth2AuthorizationCodeAuthenticationToken(ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange, + OAuth2AccessToken accessToken) { + this.clientRegistration = clientRegistration; + this.authorizationExchange = authorizationExchange; + this.accessToken = accessToken; + } + + public ClientRegistration getClientRegistration() { + return clientRegistration; + } + + public OAuth2AuthorizationExchange getAuthorizationExchange() { + return authorizationExchange; + } + + public OAuth2AccessToken getAccessToken() { + return accessToken; + } + + @Override + public Set getAuthorities() { + return Set.of(); + } + + @Override + public Object getCredentials() { + return (this.accessToken != null) ? this.accessToken.tokenValue() + : this.authorizationExchange.authorizationResponse().code(); + } + + @Override + public Object getPrincipal() { + return this.clientRegistration.clientId(); + } + + @Override + public boolean isAuthenticated() { + return false; + } +} + 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 0000000..4c0e18f --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2LoginAuthenticationProvider.java @@ -0,0 +1,54 @@ +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.OAuth2AccessTokenResponseClient; +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( + new OAuth2AccessTokenResponseClient()); + this.userService = userService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication; + + // 1. 코드 교환 + OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken + = (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider + .authenticate( + new OAuth2AuthorizationCodeAuthenticationToken( + loginAuthenticationToken.getClientRegistration(), + loginAuthenticationToken.getAuthorizationExchange() + ) + ); + + // 2. Access Token 요청 + OAuth2AccessToken accessToken = authorizationCodeAuthenticationToken.getAccessToken(); + + // 3. User 정보 가져오기 + OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest( + loginAuthenticationToken.getClientRegistration(), accessToken)); + + return new OAuth2LoginAuthenticationToken( + loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(), + oauth2User, accessToken); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2LoginAuthenticationToken.class.isAssignableFrom(authentication); + } +} 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 0000000..ee5f19f --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2LoginAuthenticationToken.java @@ -0,0 +1,78 @@ +package nextstep.security.oauth2.client.authentication; + +import java.util.Collections; +import java.util.Set; +import nextstep.security.authentication.Authentication; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AccessToken; +import nextstep.security.oauth2.core.OAuth2AuthorizationExchange; +import nextstep.security.oauth2.core.user.OAuth2User; +import org.springframework.util.Assert; + +public class OAuth2LoginAuthenticationToken implements Authentication { + private final ClientRegistration clientRegistration; + private final OAuth2AuthorizationExchange authorizationExchange; + private OAuth2User principal; + private OAuth2AccessToken accessToken; + private boolean authenticated; + + public OAuth2LoginAuthenticationToken(ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange) { + Assert.notNull(clientRegistration, "clientRegistration cannot be null"); + Assert.notNull(authorizationExchange, "authorizationExchange cannot be null"); + this.clientRegistration = clientRegistration; + this.authorizationExchange = authorizationExchange; + this.setAuthenticated(false); + } + + public OAuth2LoginAuthenticationToken(ClientRegistration clientRegistration, + OAuth2AuthorizationExchange authorizationExchange, OAuth2User principal, + OAuth2AccessToken accessToken) { + Assert.notNull(clientRegistration, "clientRegistration cannot be null"); + Assert.notNull(authorizationExchange, "authorizationExchange cannot be null"); + Assert.notNull(principal, "principal cannot be null"); + Assert.notNull(accessToken, "accessToken cannot be null"); + this.clientRegistration = clientRegistration; + this.authorizationExchange = authorizationExchange; + this.principal = principal; + this.accessToken = accessToken; + this.setAuthenticated(true); + } + + + @Override + public OAuth2User getPrincipal() { + return this.principal; + } + + @Override + public boolean isAuthenticated() { + return this.authenticated; + } + + public void setAuthenticated(boolean authenticated) { + this.authenticated = authenticated; + } + + @Override + public Object getCredentials() { + return ""; + } + + @Override + public Set getAuthorities() { + return Collections.emptySet(); + } + + public ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + + public OAuth2AuthorizationExchange getAuthorizationExchange() { + return this.authorizationExchange; + } + + public OAuth2AccessToken getAccessToken() { + return this.accessToken; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2ProfileUser.java b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2ProfileUser.java new file mode 100644 index 0000000..4a669bd --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2ProfileUser.java @@ -0,0 +1,7 @@ +package nextstep.security.oauth2.client.authentication; + +public interface OAuth2ProfileUser { + String getName(); + String getImageUrl(); + String getEmail(); +} diff --git a/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2ProfileUserFactory.java b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2ProfileUserFactory.java new file mode 100644 index 0000000..391abaf --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/authentication/OAuth2ProfileUserFactory.java @@ -0,0 +1,13 @@ +package nextstep.security.oauth2.client.authentication; + +import java.util.Map; + +public class OAuth2ProfileUserFactory { + public static OAuth2ProfileUser create(String registrationId, Map attributes) { + return switch (registrationId) { + case "github" -> new GithubProfileUser(attributes); + case "google" -> new GoogleProfileUser(attributes); + default -> throw new IllegalArgumentException("Unsupported OAuth2 provider: " + registrationId); + }; + } +} 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 0000000..e67c716 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/registration/ClientRegistration.java @@ -0,0 +1,26 @@ +package nextstep.security.oauth2.client.registration; + +import java.util.Set; + +public record ClientRegistration( + String registrationId, + String clientId, + String clientSecret, + String redirectUri, + Set scope, + String authorizationGrantType, + String clientName, + ProviderDetails providerDetails +) { + public record ProviderDetails( + String authorizationUri, + String tokenUri, + UserInfoEndpoint userInfoEndpoint + ) { + } + + public record UserInfoEndpoint(String uri, + String userNameAttributeName + ) { + } +} 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 0000000..45c94c7 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/registration/ClientRegistrationRepository.java @@ -0,0 +1,27 @@ +package nextstep.security.oauth2.client.registration; + +import java.util.Map; + +public interface ClientRegistrationRepository { + + ClientRegistration findByRegistrationId(String registrationId); + + class InMemoryClientRegistrationRepository implements ClientRegistrationRepository { + private final Map registrations; + + public InMemoryClientRegistrationRepository(Map registrations) { + this.registrations = registrations; + } + + @Override + public ClientRegistration findByRegistrationId(String registrationId) { + if (registrationId == null || registrationId.isBlank()) { + throw new IllegalArgumentException("Registration ID must not be null or 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 0000000..b6d456a --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/userinfo/DefaultOAuth2UserService.java @@ -0,0 +1,116 @@ +package nextstep.security.oauth2.client.userinfo; + +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import nextstep.app.domain.Member; +import nextstep.app.domain.MemberRepository; +import nextstep.security.oauth2.client.authentication.OAuth2ProfileUser; +import nextstep.security.oauth2.client.authentication.OAuth2ProfileUserFactory; +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AuthenticationException; +import nextstep.security.oauth2.core.user.DefaultOAuth2User; +import nextstep.security.oauth2.core.user.OAuth2User; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +public class DefaultOAuth2UserService implements OAuth2UserService { + private final MemberRepository memberRepository; + + private static final ParameterizedTypeReference> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + private final RestOperations restOperations; + + public DefaultOAuth2UserService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + this.restOperations = new RestTemplate(); + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + Assert.notNull(userRequest, "userRequest cannot be null"); + String userNameAttributeName = getUserNameAttributeName(userRequest); + RequestEntity request = convertRequestEntity(userRequest); + ResponseEntity> response = getResponse(request); + Map attributes = convertAttributes(userRequest, response.getBody()); + String email = attributes.get("email").toString(); + Member member = memberRepository.findByEmail(email) + .orElseGet(() -> memberRepository.save( + new Member( + email, + "", + attributes.get("name").toString(), + attributes.get("imageUrl").toString(), + Set.of("USER") + ) + )); + return new DefaultOAuth2User(member, attributes, userNameAttributeName); + } + + private String getUserNameAttributeName(OAuth2UserRequest userRequest) { + if (!StringUtils + .hasText(userRequest.clientRegistration().providerDetails().userInfoEndpoint().uri())) { + throw new OAuth2AuthenticationException(); + } + String userNameAttributeName = userRequest.clientRegistration() + .providerDetails() + .userInfoEndpoint() + .userNameAttributeName(); + + if (!StringUtils.hasText(userNameAttributeName)) { + throw new OAuth2AuthenticationException(); + } + + return userNameAttributeName; + } + + private RequestEntity convertRequestEntity(OAuth2UserRequest userRequest) { + ClientRegistration clientRegistration = userRequest.clientRegistration(); + + HttpHeaders headers = new HttpHeaders(); + + URI uri = UriComponentsBuilder + .fromUriString(clientRegistration.providerDetails().userInfoEndpoint().uri()) + .build() + .toUri(); + + headers.setBearerAuth(userRequest.accessToken().tokenValue()); + + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + return new RequestEntity<>(headers, HttpMethod.GET, uri); + } + + private Map convertAttributes(OAuth2UserRequest userRequest, Map body) { + String clientRegistrationId = userRequest.clientRegistration().registrationId(); + + OAuth2ProfileUser profileUser = OAuth2ProfileUserFactory.create(clientRegistrationId, body); + + Map convertedAttributes = new HashMap<>(); + convertedAttributes.put("name", profileUser.getName()); + convertedAttributes.put("imageUrl", profileUser.getImageUrl()); + convertedAttributes.put("email", profileUser.getEmail()); + + return convertedAttributes; + } + + private ResponseEntity> getResponse(RequestEntity request) { + try { + return this.restOperations.exchange(request, PARAMETERIZED_RESPONSE_TYPE); + } catch (Exception e) { + throw new OAuth2AuthenticationException(); + } + } +} + 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 0000000..23637df --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserRequest.java @@ -0,0 +1,7 @@ +package nextstep.security.oauth2.client.userinfo; + +import nextstep.security.oauth2.client.registration.ClientRegistration; +import nextstep.security.oauth2.core.OAuth2AccessToken; + +public record OAuth2UserRequest(ClientRegistration clientRegistration, OAuth2AccessToken accessToken) { +} diff --git a/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserService.java b/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserService.java new file mode 100644 index 0000000..deec0b9 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/userinfo/OAuth2UserService.java @@ -0,0 +1,7 @@ +package nextstep.security.oauth2.client.userinfo; + +import nextstep.security.oauth2.core.user.OAuth2User; + +public interface OAuth2UserService { + OAuth2User loadUser(OAuth2UserRequest userRequest); +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/AuthenticationSuccessHandler.java b/src/main/java/nextstep/security/oauth2/client/web/AuthenticationSuccessHandler.java new file mode 100644 index 0000000..f7ba492 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/AuthenticationSuccessHandler.java @@ -0,0 +1,12 @@ +package nextstep.security.oauth2.client.web; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import nextstep.security.authentication.Authentication; + +public interface AuthenticationSuccessHandler { + void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException; +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/HttpSessionOAuth2AuthorizedClientRepository.java b/src/main/java/nextstep/security/oauth2/client/web/HttpSessionOAuth2AuthorizedClientRepository.java new file mode 100644 index 0000000..fc6bc68 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/HttpSessionOAuth2AuthorizedClientRepository.java @@ -0,0 +1,66 @@ +package nextstep.security.oauth2.client.web; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.util.HashMap; +import java.util.Map; +import nextstep.security.authentication.Authentication; +import nextstep.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.util.Assert; + +public class HttpSessionOAuth2AuthorizedClientRepository implements OAuth2AuthorizedClientRepository { + + private static final String DEFAULT_AUTHORIZED_CLIENTS_ATTR_NAME = HttpSessionOAuth2AuthorizedClientRepository.class + .getName() + ".AUTHORIZED_CLIENTS"; + + private final String sessionAttributeName = DEFAULT_AUTHORIZED_CLIENTS_ATTR_NAME; + + @SuppressWarnings("unchecked") + @Override + public OAuth2AuthorizedClient loadAuthorizedClient(String clientRegistrationId, Authentication principal, + HttpServletRequest request) { + Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty"); + Assert.notNull(request, "request cannot be null"); + return this.getAuthorizedClients(request).get(clientRegistrationId); + } + + @Override + public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal, + HttpServletRequest request, HttpServletResponse response) { + Assert.notNull(authorizedClient, "authorizedClient cannot be null"); + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); + Map authorizedClients = this.getAuthorizedClients(request); + authorizedClients.put(authorizedClient.clientRegistration().registrationId(), authorizedClient); + request.getSession().setAttribute(this.sessionAttributeName, authorizedClients); + } + + @Override + public void removeAuthorizedClient(String clientRegistrationId, Authentication principal, + HttpServletRequest request, HttpServletResponse response) { + Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty"); + Assert.notNull(request, "request cannot be null"); + Map authorizedClients = this.getAuthorizedClients(request); + if (!authorizedClients.isEmpty()) { + if (authorizedClients.remove(clientRegistrationId) != null) { + if (!authorizedClients.isEmpty()) { + request.getSession().setAttribute(this.sessionAttributeName, authorizedClients); + } else { + request.getSession().removeAttribute(this.sessionAttributeName); + } + } + } + } + + @SuppressWarnings("unchecked") + private Map getAuthorizedClients(HttpServletRequest request) { + HttpSession session = request.getSession(false); + Map authorizedClients = (session != null) + ? (Map) session.getAttribute(this.sessionAttributeName) : null; + if (authorizedClients == null) { + authorizedClients = new HashMap<>(); + } + return authorizedClients; + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationResponseUtils.java b/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationResponseUtils.java new file mode 100644 index 0000000..08aec88 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizationResponseUtils.java @@ -0,0 +1,44 @@ +package nextstep.security.oauth2.client.web; + +import java.util.Map; +import nextstep.security.oauth2.core.OAuth2AuthorizationResponse; +import nextstep.security.oauth2.core.OAuth2ParameterNames; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +public class OAuth2AuthorizationResponseUtils { + private OAuth2AuthorizationResponseUtils() { + } + + static MultiValueMap toMultiMap(Map map) { + MultiValueMap params = new LinkedMultiValueMap<>(map.size()); + map.forEach((key, values) -> { + for (String value : values) { + params.add(key, value); + } + }); + return params; + } + + static boolean isAuthorizationResponse(MultiValueMap request) { + return isAuthorizationResponseSuccess(request) || isAuthorizationResponseError(request); + } + + static boolean isAuthorizationResponseSuccess(MultiValueMap request) { + return StringUtils.hasText(request.getFirst(OAuth2ParameterNames.CODE.getValue())); + } + + static boolean isAuthorizationResponseError(MultiValueMap request) { + return StringUtils.hasText(request.getFirst(OAuth2ParameterNames.ERROR.getValue())); + } + + public static OAuth2AuthorizationResponse convert(MultiValueMap request, String redirectUri) { + String code = request.getFirst(OAuth2ParameterNames.CODE.getValue()); + String errorCode = request.getFirst(OAuth2ParameterNames.ERROR.getValue()); + if (StringUtils.hasText(code)) { + return OAuth2AuthorizationResponse.success(code, redirectUri); + } + return OAuth2AuthorizationResponse.error(errorCode, redirectUri); + } +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizedClientRepository.java b/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizedClientRepository.java new file mode 100644 index 0000000..6be0bfa --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/OAuth2AuthorizedClientRepository.java @@ -0,0 +1,17 @@ +package nextstep.security.oauth2.client.web; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import nextstep.security.authentication.Authentication; +import nextstep.security.oauth2.client.OAuth2AuthorizedClient; + +public interface OAuth2AuthorizedClientRepository { + OAuth2AuthorizedClient loadAuthorizedClient(String clientRegistrationId, Authentication principal, + HttpServletRequest request); + + void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal, + HttpServletRequest request, HttpServletResponse response); + + void removeAuthorizedClient(String clientRegistrationId, Authentication principal, HttpServletRequest request, + HttpServletResponse response); +} diff --git a/src/main/java/nextstep/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java b/src/main/java/nextstep/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java new file mode 100644 index 0000000..7f10d50 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java @@ -0,0 +1,175 @@ +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 java.io.IOException; +import nextstep.security.access.RequestMatcher; +import nextstep.security.authentication.Authentication; +import nextstep.security.authentication.AuthenticationException; +import nextstep.security.authentication.AuthenticationManager; +import nextstep.security.context.HttpSessionSecurityContextRepository; +import nextstep.security.context.SecurityContext; +import nextstep.security.context.SecurityContextHolder; +import nextstep.security.oauth2.client.AuthorizationRequestRepository; +import nextstep.security.oauth2.client.HttpSessionOAuth2AuthorizationRequestRepository; +import nextstep.security.oauth2.client.OAuth2AuthorizedClient; +import nextstep.security.oauth2.client.authentication.OAuth2AuthenticationToken; +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.core.OAuth2AuthenticationException; +import nextstep.security.oauth2.core.OAuth2AuthorizationExchange; +import nextstep.security.oauth2.core.OAuth2AuthorizationRequest; +import nextstep.security.oauth2.core.OAuth2AuthorizationResponse; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.log.LogMessage; +import org.springframework.util.MultiValueMap; +import org.springframework.web.filter.GenericFilterBean; + +public class OAuth2LoginAuthenticationFilter extends GenericFilterBean { + public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/"; + + private final AuthorizationRequestRepository authorizationRequestRepository = new HttpSessionOAuth2AuthorizationRequestRepository(); + + private final Converter authenticationResultConverter = this::createAuthenticationResult; + + private final HttpSessionSecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); + + private final ClientRegistrationRepository clientRegistrationRepository; + + private final RequestMatcher requiresAuthenticationRequestMatcher; + + private final AuthenticationManager authenticationManager; + + private final OAuth2AuthorizedClientRepository authorizedClientRepository; + + private static final AuthenticationSuccessHandler successHandler = (request, response, authentication) -> response.sendRedirect( + "/"); + + public OAuth2LoginAuthenticationFilter(ClientRegistrationRepository clientRegistrationRepository, + OAuth2AuthorizedClientRepository authorizedClientRepository, + AuthenticationManager authenticationManager) { + this.clientRegistrationRepository = clientRegistrationRepository; + this.requiresAuthenticationRequestMatcher = request -> { + String uri = request.getRequestURI(); + return uri.startsWith(DEFAULT_FILTER_PROCESSES_URI); + }; + this.authorizedClientRepository = authorizedClientRepository; + this.authenticationManager = authenticationManager; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain); + } + + private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!requiresAuthentication(request)) { + chain.doFilter(request, response); + return; + } + try { + Authentication authenticationResult = attemptAuthentication(request, response); + if (authenticationResult == null) { + return; + } + successfulAuthentication(request, response, authenticationResult); + } catch (AuthenticationException ex) { + SecurityContextHolder.clearContext(); + } + } + + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + // 1. 요청 파라미터에서 필요한 정보를 추출 + MultiValueMap params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap()); + if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) { + throw new OAuth2AuthenticationException("Invalid OAuth2 Authorization Response"); + } + + // 2. 세션에서 OAuth2AuthorizationRequest 로드 + OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository.removeAuthorizationRequest( + request, response); + if (authorizationRequest == null) { + throw new OAuth2AuthenticationException("Authorization Request not found in session"); + } + + // 3. registrationId와 ClientRegistration 조회 + String registrationId = getRegistrationId(request); + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); + if (clientRegistration == null) { + throw new OAuth2AuthenticationException("Client Registration not found"); + } + + // 4. 인증에 필요한 OAuth2AuthorizationResponse 생성 + OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, + clientRegistration.redirectUri()); + + // 5. Authentication 생성 (실제 인증은 Provider가 수행) + Authentication authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration, + new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)); + + // 6. AuthenticationManager에 요청 전달 + return getAuthenticationManager().authenticate(authenticationRequest); + } + + private boolean requiresAuthentication(HttpServletRequest request) { + if (this.requiresAuthenticationRequestMatcher.matches(request)) { + return true; + } + if (this.logger.isTraceEnabled()) { + this.logger + .trace(LogMessage.format("Did not match request to %s", this.requiresAuthenticationRequestMatcher)); + } + return false; + } + + private AuthenticationManager getAuthenticationManager() { + return this.authenticationManager; + } + + private String getRegistrationId(HttpServletRequest request) { + String uri = request.getRequestURI(); + + if (uri.startsWith(DEFAULT_FILTER_PROCESSES_URI)) { + return uri.substring(DEFAULT_FILTER_PROCESSES_URI.length()); + } + + return null; + } + + private OAuth2AuthenticationToken createAuthenticationResult(OAuth2LoginAuthenticationToken authenticationResult) { + return new OAuth2AuthenticationToken(authenticationResult.getPrincipal(), + authenticationResult.getAuthorities()); + } + + private void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, + Authentication authResult) throws ServletException, IOException { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authResult); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, request, response); + + if (authResult instanceof OAuth2LoginAuthenticationToken authentication) { + OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter.convert( + authentication); + + // authorizedClientRepository 에 저장할 OAuth2AuthorizedClient을 만들고 저장 + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient( + authentication.getClientRegistration(), + oauth2Authentication.getName(), + authentication.getAccessToken()); + + this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, + response); + } + + successHandler.onAuthenticationSuccess(request, response, authResult); + } +} diff --git a/src/main/java/nextstep/security/oauth2/core/OAuth2AccessToken.java b/src/main/java/nextstep/security/oauth2/core/OAuth2AccessToken.java new file mode 100644 index 0000000..38a2292 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/OAuth2AccessToken.java @@ -0,0 +1,5 @@ +package nextstep.security.oauth2.core; + +public record OAuth2AccessToken(String tokenValue) { + +} diff --git a/src/main/java/nextstep/security/oauth2/core/OAuth2AuthenticationException.java b/src/main/java/nextstep/security/oauth2/core/OAuth2AuthenticationException.java new file mode 100644 index 0000000..aca51d7 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/OAuth2AuthenticationException.java @@ -0,0 +1,12 @@ +package nextstep.security.oauth2.core; + +import nextstep.security.authentication.AuthenticationException; + +public class OAuth2AuthenticationException extends AuthenticationException { + + public OAuth2AuthenticationException() {} + + public OAuth2AuthenticationException(String message) { + super(message); + } +} diff --git a/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationExchange.java b/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationExchange.java new file mode 100644 index 0000000..3312593 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationExchange.java @@ -0,0 +1,13 @@ +package nextstep.security.oauth2.core; + +import org.springframework.util.Assert; + +public record OAuth2AuthorizationExchange( + OAuth2AuthorizationRequest authorizationRequest, + OAuth2AuthorizationResponse authorizationResponse) { + + public OAuth2AuthorizationExchange { + Assert.notNull(authorizationRequest, "authorizationRequest cannot be null"); + Assert.notNull(authorizationResponse, "authorizationResponse cannot be null"); + } +} diff --git a/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationRequest.java b/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationRequest.java new file mode 100644 index 0000000..44ab550 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationRequest.java @@ -0,0 +1,13 @@ +package nextstep.security.oauth2.core; + +import java.util.Set; + +public record OAuth2AuthorizationRequest( + String authorizationUri, + String clientId, + String redirectUri, + Set scope, + String authorizationRequestUri +) { + +} diff --git a/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationResponse.java b/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationResponse.java new file mode 100644 index 0000000..ca5ab7c --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/OAuth2AuthorizationResponse.java @@ -0,0 +1,17 @@ +package nextstep.security.oauth2.core; + +import org.springframework.util.Assert; + +public record OAuth2AuthorizationResponse( + String code, + String redirectUri +) { + public static OAuth2AuthorizationResponse success(String code, String redirectUri) { + return new OAuth2AuthorizationResponse(code, redirectUri); + } + + public static OAuth2AuthorizationResponse error(String errorCode, String redirectUri) { + Assert.hasText(errorCode, "errorCode cannot be empty"); + return new OAuth2AuthorizationResponse(errorCode, redirectUri); + } +} diff --git a/src/main/java/nextstep/security/oauth2/core/OAuth2ParameterNames.java b/src/main/java/nextstep/security/oauth2/core/OAuth2ParameterNames.java new file mode 100644 index 0000000..9ee314b --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/OAuth2ParameterNames.java @@ -0,0 +1,30 @@ +package nextstep.security.oauth2.core; + +public enum OAuth2ParameterNames { + CLIENT_ID("client_id"), + REDIRECT_URI("redirect_uri"), + RESPONSE_TYPE("response_type"), + SCOPE("scope"), + CODE("code"), + ERROR("error"), + ERROR_DESCRIPTION("error_description"), + ERROR_URI("error_uri"), + GRANT_TYPE("grant_type"), + AUTHORIZATION_CODE("authorization_code"), + ACCESS_TOKEN("access_token"); + + private final String value; + + OAuth2ParameterNames(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/nextstep/security/oauth2/core/user/DefaultOAuth2User.java b/src/main/java/nextstep/security/oauth2/core/user/DefaultOAuth2User.java new file mode 100644 index 0000000..c09b5f5 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/user/DefaultOAuth2User.java @@ -0,0 +1,35 @@ +package nextstep.security.oauth2.core.user; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import nextstep.app.domain.Member; + +public class DefaultOAuth2User implements OAuth2User { + + private final Member member; + private final Map attributes; + private final String userNameAttributeName; + + // 생성자 + public DefaultOAuth2User(Member member, Map attributes, String userNameAttributeName) { + this.member = member; + this.attributes = attributes; + this.userNameAttributeName = userNameAttributeName; + } + + @Override + public String getUserNameAttributeName() { + return userNameAttributeName; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Set getAuthorities() { + return member.getRoles(); + } +} diff --git a/src/main/java/nextstep/security/oauth2/core/user/OAuth2User.java b/src/main/java/nextstep/security/oauth2/core/user/OAuth2User.java new file mode 100644 index 0000000..0bbc701 --- /dev/null +++ b/src/main/java/nextstep/security/oauth2/core/user/OAuth2User.java @@ -0,0 +1,12 @@ +package nextstep.security.oauth2.core.user; + +import java.util.Map; +import java.util.Set; + +public interface OAuth2User { + Set getAuthorities(); + + Map getAttributes(); + + String getUserNameAttributeName(); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 78593fa..672bea8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,8 +26,10 @@ spring: token-uri: "https://github.com/login/oauth/access_token" access-token-uri: "http://localhost:8089/login/oauth/access_token" user-info-uri: "https://api.github.com/user" + user-name-attribute-name: "email" google: - authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth?" + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" token-uri: "https://oauth2.googleapis.com/token" access-token-uri: "http://localhost:8089/login/oauth/access_token" user-info-uri: "https://www.googleapis.com/oauth2/v3/userinfo" + user-name-attribute-name: "email" diff --git a/src/test/java/nextstep/app/GithubAuthenticationFilterTest.java b/src/test/java/nextstep/app/GithubAuthenticationFilterTest.java index 1746661..cdf09c3 100644 --- a/src/test/java/nextstep/app/GithubAuthenticationFilterTest.java +++ b/src/test/java/nextstep/app/GithubAuthenticationFilterTest.java @@ -2,9 +2,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpSession; -import nextstep.security.context.HttpSessionSecurityContextRepository; -import nextstep.security.context.SecurityContext; +import nextstep.app.domain.Member; +import nextstep.app.domain.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -12,6 +11,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -23,15 +23,18 @@ import static com.github.tomakehurst.wiremock.client.WireMock.*; import static org.assertj.core.api.Assertions.assertThat; +@ActiveProfiles("test") @SpringBootTest @AutoConfigureMockMvc -@ActiveProfiles("test") @AutoConfigureWireMock(port = 8089) class GithubAuthenticationFilterTest { @Autowired private MockMvc mockMvc; + @Autowired + private MemberRepository memberRepository; + @BeforeEach void setupMockServer() throws Exception { stubForAccessToken(); @@ -40,20 +43,21 @@ void setupMockServer() throws Exception { @Test void redirectAndRequestGithubAccessToken() throws Exception { + MockHttpSession session = new MockHttpSession(); + + mockMvc.perform(MockMvcRequestBuilders.get("/oauth2/authorization/github").session(session)) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()); + String requestUri = "/login/oauth2/code/github?code=mock_code"; - mockMvc.perform(MockMvcRequestBuilders.get(requestUri)) + mockMvc.perform(MockMvcRequestBuilders.get(requestUri).session(session)) .andExpect(MockMvcResultMatchers.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("a@a.com"); - }); + .andExpect(MockMvcResultMatchers.redirectedUrl("/")); + + Member savedMember = memberRepository.findByEmail("a@a.com").get(); + assertThat(savedMember).isNotNull(); + assertThat(savedMember.getEmail()).isEqualTo("a@a.com"); + assertThat(savedMember.getName()).isEqualTo("a"); } private static void stubForAccessToken() throws JsonProcessingException { @@ -81,4 +85,3 @@ private static void stubForUser() throws JsonProcessingException { .withBody(profileJsonResponse))); } } - diff --git a/src/test/java/nextstep/app/GoogleAuthenticationFilterTest.java b/src/test/java/nextstep/app/GoogleAuthenticationFilterTest.java index b559e57..b76568b 100644 --- a/src/test/java/nextstep/app/GoogleAuthenticationFilterTest.java +++ b/src/test/java/nextstep/app/GoogleAuthenticationFilterTest.java @@ -6,14 +6,14 @@ import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpSession; import java.util.HashMap; import java.util.Map; -import nextstep.security.context.HttpSessionSecurityContextRepository; -import nextstep.security.context.SecurityContext; +import nextstep.app.domain.Member; +import nextstep.app.domain.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -21,6 +21,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -35,6 +36,9 @@ class GoogleAuthenticationFilterTest { @Autowired private MockMvc mockMvc; + @Autowired + private MemberRepository memberRepository; + @BeforeEach void setupMockServer() throws Exception { stubForAccessToken(); @@ -43,20 +47,22 @@ void setupMockServer() throws Exception { @Test void redirectAndRequestGoogleAccessToken() throws Exception { + MockHttpSession session = new MockHttpSession(); + + mockMvc.perform(MockMvcRequestBuilders.get("/oauth2/authorization/google").session(session)) + .andDo(print()) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()); + String requestUri = "/login/oauth2/code/google?code=mock_code"; - mockMvc.perform(MockMvcRequestBuilders.get(requestUri)) + mockMvc.perform(MockMvcRequestBuilders.get(requestUri).session(session)) .andExpect(MockMvcResultMatchers.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("a@a.com"); - }); + .andExpect(MockMvcResultMatchers.redirectedUrl("/")); + + Member savedMember = memberRepository.findByEmail("a@a.com").get(); + assertThat(savedMember).isNotNull(); + assertThat(savedMember.getEmail()).isEqualTo("a@a.com"); + assertThat(savedMember.getName()).isEqualTo("a"); } private static void stubForAccessToken() throws JsonProcessingException { diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 7061def..845b118 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -24,12 +24,14 @@ spring: github: name: "github" authorization-uri: "https://github.com/login/oauth/authorize?" - token-uri: "https://github.com/login/oauth/access_token" + token-uri: "http://localhost:8089/login/oauth/access_token" access-token-uri: "http://localhost:8089/login/oauth/access_token" user-info-uri: "http://localhost:8089/user" + user-name-attribute-name: "email" google: name: "google" - authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth?" - token-uri: "https://oauth2.googleapis.com/token" + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" + token-uri: "http://localhost:8089/oauth2.googleapis.com/token" access-token-uri: "http://localhost:8089/oauth2.googleapis.com/token" user-info-uri: "http://localhost:8089/oauth2/v1/userinfo" + user-name-attribute-name: "email"