SNS ๋ก๊ทธ์ธ - Spring OAuth2 Client
OAuth2
๋?
๊ฐ๋
- OAuth(Open Authorization) : ์ธํฐ๋ท ์ฌ์ฉ์๋ค์ด ๋น๋ฐ๋ฒํธ๋ฅผ ์ ๊ณตํ์ง ์๊ณ , ๋ค๋ฅธ ์น์ฌ์ดํธ ์์ ์์ ๋ค์ ์ ๋ณด์ ๋ํด ์ดํ๋ฆฌ์ผ์ด์ ์ ์ ๊ทผ ๊ถํ์ ๋ถ์ฌํ ์ ์๋ ๊ณตํต์ ์ธ ์๋จ์ผ๋ก์จ ์ฌ์ฉ๋๋ ์์ ๊ถํ๋ถ์ฌ๋ฅผ ์ํ ํ์ค ํ๋กํ ์ฝ์ด๋ค.
๐ก `OAuth2`๋ `OAuth`์ ์๋ ค์ง ๋ณด์ ๋ฌธ์ ๋ฑ์ ๊ฐ์ ํ ๋ฒ์ ์
์ฃผ์ ์ฉ์ด
์ด๋ฆ | ์ค๋ช |
---|---|
Authentication | (์ธ์ฆ) ์ ๊ทผ ์๊ฒฉ์ด ์๋์ง ๊ฒ์ฆํ๋ ๋จ๊ณ |
Authorization | (์ธ๊ฐ) ์์์ ์ ๊ทผํ ๊ถํ์ ๋ถ์ฌํ๋ ๊ฒ์ด๋ฉฐ, ์ธ๊ฐ๊ฐ ์๋ฃ๋๋ฉด ๋ฆฌ์์ค ์ ๊ทผ ๊ถํ์ด ๋ด๊ธด Access Token์ด ํด๋ผ์ด์ธํธ์๊ฒ ๋ถ์ฌ |
Access Token | ๋ฆฌ์์ค ์๋ฒ์๊ฒ์ ๋ฆฌ์์ค ์์ ์์ ๋ณดํธ๋ ์์์ ํ๋ํ ๋ ์ฌ์ฉํ๋ ํ ํฐ |
Refresh Token | Access Token๋ง๋ฃ ์ ์ด๋ฅผ ๊ฐฑ์ ํ๊ธฐ ์ํ ์ฉ๋๋ก ์ฌ์ฉํ๋ ํ ํฐ |
๊ตฌ์ฑ
์ด๋ฆ | ์ค๋ช |
---|---|
Resource Owner | ์น ์๋น์ค๋ฅผ ์ด์ฉํ๋ ค๋ ์ ์ , ์์(๊ฐ์ธ์ ๋ณด)์ ์์ ํ๋ ์, ์ฌ์ฉ์ |
Client | ์์ฌ ๋๋ ๊ฐ์ธ์ด ๋ง๋ ์ ํ๋ฆฌ์ผ์ด์ ์๋ฒ |
Resource Server | ์ฌ์ฉ์์ ๊ฐ์ธ์ ๋ณด๋ฅผ ๊ฐ์ง๊ณ ์๋ ์ ํ๋ฆฌ์ผ์ด์ (Google, Facebook, Kakao ๋ฑ) ํ์ฌ ์๋ฒ |
- Client๋ Token์ ์ด ์๋ฒ๋ก ๋๊ฒจ ๊ฐ์ธ์ ๋ณด๋ฅผ ์๋ต ๋ฐ์ ์ ์์ |
| Authorization Server | ๊ถํ์ ๋ถ์ฌ(์ธ์ฆ์ ์ฌ์ฉํ ์์ดํ ์ ์ ๊ณต์ฃผ๋)ํด์ฃผ๋ ์๋ฒ - ์ฌ์ฉ์๋ ์ด ์๋ฒ๋ก ID, PW๋ฅผ ๋๊ฒจ Authorization Code๋ฅผ ๋ฐ๊ธ ๋ฐ์ ์ ์์
- Client๋ ์ด ์๋ฒ๋ก Authorization Code์ ๋๊ฒจ Token์ ๋ฐ๊ธ ๋ฐ์ ์ ์์ |
์ธ์ฆ ๋ฐฉ์
Authorization Code Grant
(๊ถํ ๋ถ์ฌ ์ฝ๋ ์น์ธ ๋ฐฉ์) :OAuth2
์์ ๊ฐ์ฅ ๊ธฐ๋ณธ์ด ๋๋ ๋ฐฉ์์ด๋ฉฐ, SNS ๋ก๊ทธ์ธ ๊ธฐ๋ฅ์์ ์ฌ์ฉ๋๋ ๋ฐฉ์- ์ ๊ทผ ๊ถํ ์์ฒญ ์,
response_type=code
๋ก ์์ฒญํ๊ฒ ๋๋ฉด ํด๋ผ์ด์ธํธ๋Authorization Server
์์ ์ ๊ณตํ๋ ๋ก๊ทธ์ธ ํ์ด์ง ์ด๋ - ๋ก๊ทธ์ธ ์,
Authorization Server
๋ ์ ๊ทผ ๊ถํ ์์ฒญ์์ ๋ฐ์redirect_url
๋กAuthorization Code
๋ฅผ ์ ๋ฌ Client
์์ ์ ๋ฌ๋ฐ์Authorization Code
๋กAccess Token
์์ฒญClient
์์ ์ ๋ฌ๋ฐ์Access Token
์ผ๋กResource Server
์ ์์ ์์ฒญ
์ด์ธ์๋ ๋ค๋ฅธ ๋ฐฉ์์ด ์กด์ฌํ์ง๋ง ์ฌ๊ธฐ์ ์ค๋ช ํ์ง ์๊ฒ ๋ค.
Implicit Grant
: ์๋ฌต์ ์น์ธ ๋ฐฉ์Client Credentials Grant
: ํด๋ผ์ด์ธํธ ์๊ฒฉ ์ฆ๋ช ๋ฐฉ์Resource Owner Password Credentials Grant
: ์์ ์์ ์ ์๊ฒฉ ์ฆ๋ช ๋ฐฉ์
[์ฐธ๊ณ ] : https://wildeveloperetrain.tistory.com/247
- ์ ๊ทผ ๊ถํ ์์ฒญ ์,
![<Authorization Code Grant ํ๋ก์ฐ>]
Spring Security
์๋ฆฌ
DelegatingProxyChain
gt;
<DelegatingProxy์ ์ญํ ></
- ์๋ธ๋ฆฟ ํํฐ๋ ์๋ธ๋ฆฟ ์ปจํ ์ด๋์์ ๊ด๋ฆฌ๋์ด ์คํ๋ง ๋น์ ์ฌ์ฉํ ์ ์๋ค.
DelegatingFilterProxy
: ์๋ธ๋ฆฟ ํํฐ์ ์คํ๋ง ๋น์ ์ฐ๊ฒฐํด์ฃผ๋ ํด๋์ค, ์๋ธ๋ฆฟ ํํฐ๋ก ์์ฒญ์ ๋ฐ์์ ์คํ๋ง์์ ๊ด๋ฆฌํ๋ ํํฐ์๊ฒ ์์ฒญ์ ์์ํ๋ ์ญํ ์ ํ๋ค.springSecurityFilterChain
: ์คํ๋ง ์ํ๋ฆฌํฐ ์คํ๋ง ๋น
FilterChainProxy
gt;
<์คํ๋ง ์ํ๋ฆฌํฐ ๊ธฐ๋ณธ ํํฐ ๋ชฉ๋ก ๋ฐ ์์></์คํ๋ง>
FilterChainProxy
๋ ๊ฐ ํํฐ๋ค์ ์์๋๋ก ํธ์ถํ๋ฉฐ ์ธ์ฆ/์ธ๊ฐ์ฒ๋ฆฌ ๋ฐ ๊ฐ์ข ์์ฒญ์ ๋ํ ์ฒ๋ฆฌ๋ฅผ ์ํํ๋ค.- ์คํ๋ง ์ํ๋ฆฌํฐ ์ด๊ธฐํ ์ ์์ฑ๋๋ ํํฐ๋ค์ ๊ด๋ฆฌํ๊ณ ์ ์ด
- ์คํ๋ง ์ํ๋ฆฌํฐ๊ฐ ๊ธฐ๋ณธ์ ์ผ๋ก ์์ฑํ๋ ํํฐ
- ์ค์ ํด๋์ค์์
API
์ถ๊ฐ ์ ์์ฑ๋๋ ํํฐ
- ์ฌ์ฉ์์ ์์ฒญ์ ํํฐ ์์๋๋ก ํธ์ถํ์ฌ ์ ๋ฌ
- ์ฌ์ฉ์์ ์ ํํฐ๋ฅผ ์์ฑํด์ ๊ธฐ์กด์ ํํฐ ์ , ํ๋ก ์ถ๊ฐ ๊ฐ๋ฅ
- ํํฐ์ ์์๋ฅผ ์ ์ ์
- ๋ง์ง๋ง ํํฐ๊น์ง ์ธ์ฆ ๋ฐ ์ธ๊ฐ ์์ธ๊ฐ ๋ฐ์ํ์ง ์์ผ๋ฉด ๋ณด์ ํต๊ณผ
OAuth2
๋ก๊ทธ์ธ์ ํ์ฑํ ํ๋ฉดUsernamePasswordAuthenticationFilter
๋์OAuth2LoginAuthenticationFilter
ํํฐ๊ฐ ์ฌ์ฉ ๋๋ค.
๋์ ๋ฐฉ์
gt;
<์๋ธ๋ฆฟ ์ปจํ
์ด๋์ ์คํ๋ง ์ปจํ
์ด๋์ DelegatingFilterProxy์ ๋ํ `Flow`></์๋ธ๋ฆฟ>
DelegatingFilterProxy
์ด ์์ฒญ์ ๋ฐ๊ฒ๋๋ฉดdelegate request
๋ก ์์ฒญ ์์FilterChainProxy
์ ํํฐ ๋ชฉ๋ก๋ค ์์ฐจ์ ์ผ๋ก ์ํ- ํํฐ ์๋ฃ ์
DispatcherServlet(Controller)
๋ก ์ ๋ฌ
Spring OAuth2 Client
์๋ฆฌ
Access Token
ํ๋
gt;
<์ฌ์ฉ์ ๋ก๊ทธ์ธ ํ Access Token์ ๋ฐ๊ธ ๋ฐ๋ `Flow`></์ฌ์ฉ์>
Auth-Server
์์ ๋ก๊ทธ์ธ์ ์๋ฃ ํ๋ฉด ์ค์ ํRedirect URL
๋กAuthorization Code
๋ฅผ ์ ๋ฌAuthorization Code
๋ฅผ ๊ฐ์ง๊ณAccess Token
์์ฒญAccess Token
๋ฐ๊ธ
User Info
ํ๋
gt;
<๋ฐ๊ธ ๋ฐ์ Access Token์ผ๋ก ์ฌ์ฉ์ ๋ฆฌ์์ค ์ ๋ณด๋ฅผ ์กฐํํ์ฌ ์ธ์ฆ ์ ์ญ ๊ฐ์ฒด๋ฅผ ์์ฑํ๋ `Flow`></๋ฐ๊ธ>
- ์์์ ๋ฐ๊ธ ๋ฐ์
Access Token
์ผ๋ก ์ฌ์ฉ์ ์ ๋ณด ์กฐํ SecurityContext
์ ์ธ์ฆ ๊ฐ์ฒด ์ ์ฅ
Spring OAuth2 Client
์ค์ ์์
๐ ๊ฐ์ฅ ๋ง์ด ์ฌ์ฉํ๋ SNS ํ๋ซํผ ์ค ์คํ๋ง ๋ถํธ์์ ๊ธฐ๋ณธ ์ ๊ณตํด์ฃผ๋ `Google`, `Facebook`, `Github`์ ์ด ์ธ์ ์ง์ `Provider` ์ค์ ์์ ์ด ํ์ํ `Kakao`, `Naver`์ SNS ๋ก๊ทธ์ธ ๊ธฐ๋ฅ์ ์์ ๋ก ํ์ธํด๋ณด์.
ํ๋ก์ ํธ ์์ฑ
https://start.spring.io/ ์์ ๋ค์๊ณผ ๊ฐ์ด ์์ ํ๋ก์ ํธ๋ฅผ ์ธํ
โGENERATEโ๋ฅผ ํด๋ฆญํ์ฌ ํ๋ก์ ํธ ์ ์ฅ ****<์คํ๋ง ๋ถํธ ํ๋ก์ ํธ ์์ฑ ํ์ด์ง์์ ์์ ์ค์ ํ๋ ํ๋ฉด></์คํ๋ง>
gt;
IDE
๋ฅผ ์ด์ฉํ์ฌWAS
๋ฅผ ์คํ๋ค์๊ณผ ๊ฐ์ ํ๋ฉด ํ์ด์ง๊ฐ ํ์ธ๋๋ฉด ํ๋ก์ ํธ ์ค์ ์๋ฃ<
localhost:8080 ์ ์ ์ ๋ ธ์ถ๋๋ ํ๋ฉด></
gt;
๋ก์ปฌ ํธ์คํธ ์ค์
๐จ SNS ํ๋ซํผ์์ `redirect url`์ ์ค์ ํ ๋ `https` ํ๋กํ ์ฝ๋ง ์ง์ํ๋ ๊ฒฝ์ฐ๊ฐ ๋๋ถ๋ถ์ด๋ค. `ngrok`์ด๋ผ๋ ์คํ ํฐ๋๋ง ํ๋ก๊ทธ๋จ์ ์ฌ์ฉํ์ฌ `public https url`์ ์ธํ ํ๋ฉด ๊ฐ๋ฅํ๋ค.
ngrok
๊ณต์ ํํ์ด์ง : https://ngrok.com/์ค์น ๊ฐ์ด๋ : https://tlog.tammolo.com/posts/ngrok-localtunnel
๋ก์ปฌ
WAS
์คํ ํ, ํฐ๋ฏธ๋ ์๋ ๋ช ๋ น์ด ์ ๋ ฅ$ ngrok http 8080
๋ค์๊ณผ ๊ฐ์ด
https://c029-218-152-213-155.ngrok-free.app
๋ก ํฐ๋๋ง์ด ์๋ฃ๋ ๊ฒ์ ํ์ธ<ngrok ์คํ ํ๋ฉด></
gt;
์์ URL๋ก ์ ์ ์, ๋ค์๊ณผ ๊ฐ์ ํ๋ฉด ๋ ธ์ถ ํ์ธ<
ngrok ํฐ๋๋ง URL ์ ์ ํ๋ฉด></
gt;
SNS ํ๋ซํผ ์ค์
๐ก ์คํ๋ง ๊ณต์ ํํ์ด์ง์์ ๊ธฐ๋ณธ์ผ๋ก ์ ๊ณตํ๋ Redirect URI ํ ํ๋ฆฟ์ `{baseUrl}/login/oauth2/code/{registrationId}`์ด๋ค.
The default redirect URI template is {baseUrl}/login/oauth2/code/{registrationId}. The registrationId is a unique identifier for the ClientRegistration. [์ฐธ๊ณ ] : [https://spring.io/guides/tutorials/spring-boot-oauth2/](https://spring.io/guides/tutorials/spring-boot-oauth2/)
- ๊ตฌ๊ธ ๋ก๊ทธ์ธ ์ค์ : https://console.cloud.google.com/
- ํ์ด์ค๋ถ ๋ก๊ทธ์ธ ์ค์ : https://developers.facebook.com/apps
- ๊นํ๋ธ ๋ก๊ทธ์ธ ์ค์ : https://github.com/settings/developers
- ๋ค์ด๋ฒ ๋ก๊ทธ์ธ ์ค์ : https://developers.naver.com/apps
- ์นด์นด์ค ๋ก๊ทธ์ธ ์ค์ : https://developers.kakao.com/console/app
CommonOAuth2Provider
Spring OAuth2 Client
์์ ๊ธฐ๋ณธ์ผ๋ก ์ ๊ณตํ๋ ํ๋ซํผ์ ์ ๋ณด๊ฐ์ ๊ฐ์ง๊ณ ์๋ค.
public enum CommonOAuth2Provider { GOOGLE { @Override public Builder getBuilder(String registrationId) { ClientRegistration.Builder builder = getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL); builder.scope("openid", "profile", "email"); builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth"); builder.tokenUri("https://www.googleapis.com/oauth2/v4/token"); builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs"); builder.issuerUri("https://accounts.google.com"); builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo"); builder.userNameAttributeName(IdTokenClaimNames.SUB); builder.clientName("Google"); return builder; } }, GITHUB { @Override public Builder getBuilder(String registrationId) { ClientRegistration.Builder builder = getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL); builder.scope("read:user"); builder.authorizationUri("https://github.com/login/oauth/authorize"); builder.tokenUri("https://github.com/login/oauth/access_token"); builder.userInfoUri("https://api.github.com/user"); builder.userNameAttributeName("id"); builder.clientName("GitHub"); return builder; } }, FACEBOOK { @Override public Builder getBuilder(String registrationId) { ClientRegistration.Builder builder = getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_POST, DEFAULT_REDIRECT_URL); builder.scope("public_profile", "email"); builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth"); builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token"); builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email"); builder.userNameAttributeName("id"); builder.clientName("Facebook"); return builder; } } ... }
application.yml
์ค์
spring: security: oauth2: client: registration: google: client-id: #{client-id} client-secret: #{client-secret} redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}" facebook: client-id: #{client-id} client-secret: #{client-secret} redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}" github: client-id: #{client-id} client-secret: #{client-secret} redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}" naver: client-name: Naver client-id: #{client-id} client-secret: #{client-secret} authorization-grant-type: authorization_code redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}" scope: name,email,age kakao: client-name: Kakao client-id: #{client-id} client-secret: #{client-secret} authorization-grant-type: authorization_code redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}" scope: profile_nickname,account_email client-authentication-method: post provider: # ๊ธฐ๋ณธ์ ๊ณตํ์ง ์๋ ํ๋ซํผ์ธ ๊ฒฝ์ฐ, ์ง์ Provider ์ค์ ํ์ naver: authorization_uri: https://nid.naver.com/oauth2.0/authorize token_uri: https://nid.naver.com/oauth2.0/token user-info-uri: https://openapi.naver.com/v1/nid/me user_name_attribute: response kakao: authorization_uri: https://kauth.kakao.com/oauth/authorize token_uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me user_name_attribute: id
#{client-id}
,#{client-secret}
์๋ ํ๋ซํผ ์ค์ ์ ๋ณด์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์์ผ ํ๋ค.- Naver, Kakao์ ๊ฒฝ์ฐ ๊ธฐ๋ณธ์ผ๋ก ์ ๊ณตํ์ง ์๋ ํ๋ซํผ์ผ๋ก ์ง์
provider
์ ์ค์ ํด์ผํ๋ค. authorization-grant-type
: ์ธ์ฆ ๋ฐฉ์์authorization_code
๋ก ์ค์ scope
: ๊ฐ ํ๋ซํผ ๋ณ, ํด๋ผ์ด์ธํธ์๊ฒ ํ์ฉ๋ ๋ฆฌ์์ค์ ๋์ ํญ๋ชฉ๋ง ๋ช ์ํ๋ฉด ๋๋ค.
Spring Security
์ค์ ๋ฐ ํ์ฑํ
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain config(HttpSecurity http) throws Exception { return http .authorizeRequests() .antMatchers("/login").permitAll() .anyRequest().authenticated() .and() .oauth2Login() .defaultSuccessUrl("/user") .and() .build(); } }
/login
๋ง ์ ์ฒด ํ์ฉ,/login
์ ์ ์ธํ ๋๋จธ์งpath
๋ ์ธ์ฆ ๊ณผ์ (์ฆ, ๋ก๊ทธ์ธ)์ด ํ์oauth2Login()
:oauth2
๋ฅผ ํ์ฑํ.defaultSuccessUrl("/user")
: ๋ก๊ทธ์ธ ์ฑ๊ณต์์/user
๋ก ๋ฆฌ๋ค์ด๋ ํธ ํ๋ค.
/login
/login
์ ๋ฐ๋ก ๊ตฌํํ์ง ์์์ ์, ์คํ๋ง ์ํ๋ฆฌํฐ์์ ๊ธฐ๋ณธ ์ ๊ณต๋๋ui
๋ฅผ ๊ทธ๋ ค์ค๋ค.
public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { boolean loginError = isErrorPage(request); boolean logoutSuccess = isLogoutSuccess(request); if (isLoginUrlRequest(request) || loginError || logoutSuccess) { String loginPageHtml = generateLoginPageHtml(request, loginError, logoutSuccess); response.setContentType("text/html;charset=UTF-8"); response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length); response.getWriter().write(loginPageHtml); return; } chain.doFilter(request, response); } private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) { ... if (this.oauth2LoginEnabled) { // oauth2Login์ด ํ์ฑํ ์ผ ๋ sb.append("<h2 class=\"form-signin-heading\">Login with OAuth 2.0</h2>"); sb.append(createError(loginError, errorMsg)); sb.append(createLogoutSuccess(logoutSuccess)); sb.append("<table class=\"table table-striped\">\n"); for (Map.Entry<String, String> clientAuthenticationUrlToClientName : this.oauth2AuthenticationUrlToClientName .entrySet()) { sb.append(" <tr><td>"); String url = clientAuthenticationUrlToClientName.getKey(); sb.append("<a href=\"").append(contextPath).append(url).append("\">"); String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue()); sb.append(clientName); sb.append("</a>"); sb.append("</td></tr>\n"); } sb.append("</table>\n"); } ... } }
WAS
์คํ ํ,/login
์ ์ ์ํ๋ฉด ์๋์ ๊ฐ์ ํ์ด์ง๊ฐ ๋ ธ์ถ ๋๋ ๊ฒ์ ํ์ธํ ์ ์๋ค.
gt;
</login ํ์ด์ง ํ๋ฉด></
/user
@RestController @RequestMapping("/user") public class UserController { @GetMapping public OAuth2User user(@AuthenticationPrincipal OAuth2User user) { return user; } }
@AuthenticationPrincipal
: ํ์ฌ ์ธ์ฆ๋ ์ฌ์ฉ์์ ๋ํ ์ ๋ณด์ ์ฝ๊ฒ ์ ๊ทผ ํด์ฃผ๋ ์ด๋ ธํ ์ด์ OAuth2User
๋ฅผ ์ง์ return
ํ์ฌ ์ธ์ฆ๊ฐ์ฒด๋ฅผJSON
์ผ๋ก ํ์ธ ๊ฐ๋ฅํ๋ค.
๋ก๊ทธ์ธ ๊ณผ์ ์์ - ๋ค์ด๋ฒ
- Naver ํด๋ฆญ
gt;
<์คํ๋ง ์ํ๋ฆฌํฐ์์ ์ ๊ณตํ๋ ๋ก๊ทธ์ธ ํ ํ๋ฆฟ></์คํ๋ง>
- ๋ค์ด๋ฒ ๋ก๊ทธ์ธ ํ์ด์ง ์ด๋
gt;
<๋ค์ด๋ฒ ๋ก๊ทธ์ธ ํ๋ฉด></๋ค์ด๋ฒ>
- ๋ก๊ทธ์ธ ๊ณผ์ ์ ๊ฑฐ์ณ ์ฑ๊ณต ์์
/user
๋ก ๋ฆฌ๋ค์ด๋ ํธ ๋๋ ๊ฒ์ ํ์ธ ํ ์ ์๋ค.
gt;
</user๋ก ์ ๋ฌ๋ ์ธ์ฆ ๊ฐ์ฒด ํ์ธ></
Spring OAuth2 Client
์ฌํ ์์
๐ก ๊ฐ๋จ ์์ ์ ๊ฒฝ์ฐ, ๋จ์ SNS ๋ก๊ทธ์ธ์ ์งํ ํ `Spring Context`์ ์ธ์ฆ ๊ฐ์ฒด๋ฅผ ์์ฑํด ๋ก๊ทธ์ธ์ ํ๋ ๊ณผ์ ๋ง์ ๋ํ๋ธ ๊ฒ์ด๋ค. ๋๋ถ๋ถ์ **ํผ๋ธ๋ฆญ ์๋น์ค**๋ `DB`์ ํ์ ํ ์ด๋ธ ๊ฒ์ฆ์ ํตํด ์ค์ ํ์์ธ์ง ํ์ธ์ ํ๊ฑฐ๋ ์ถ๊ฐ์ ์ผ๋ก ํ์ ์ ๋ณด๋ฅผ ๋ฐ์ ํ์๊ฐ์ ์ ์งํํด์ผํ๋ **์๊ตฌ ์ฌํญ**์ด ์กด์ฌ ํ๋ค. ์ด๋ฌํ ์๊ตฌ์ฌํญ ๋๋ฌธ์ ๋ก๊ทธ์ธ ํ๋ ๊ณผ์ ์์ ์ค์ ์์ด ์ปค์คํ ์ ํด์ผ๋๋ ์ด์๋ฅผ ์ฌํ ์์ ๋ฅผ ํตํด ์์ ๋ณด๊ฒ ๋ค.
UserDetailsService
UserDetailsService
: ์ผ๋ฐ์ ์ธForm(HTML)
์ ์ด์ฉํ ๋ก๊ทธ์ธ์ ์ ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๋ ์ธํฐํ์ด์ค์ด๋ค.UserDetails
:UserDetailsService
์loadUserByUsername
๋ฆฌํด ๊ฐ์ผ๋ก, ์ ์ ์ ๋ณด๋ฅผ ๋ด๋ ๊ฐ์ฒด์ด๋ค.
public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
[์ฐธ๊ณ ]
UserDetailsService
: https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details-service.htmlUserDetails
: https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details.html
OAuth2
- UserDetailsService
OAuth2
์์ ์ฌ์ฉํ๋UserDetailsService
๋OAuth2UserService
์ด๋ค.OAuth2UserRequest
: ์ ์ ์ ๋ณด API๋ฅผ ํธ์ถํ๋ ์์ฒญ ๊ฐ์ฒด๋ฅผ ์ธ์ ๊ฐ์ผ๋ก ๊ฐ์ง๊ณ ์๋ค.OAuth2User
๋ฅผ ์์ ๋ฐ์ ๊ฐ์ฒด๋ฅผ ๋ฐํ
@FunctionalInterface public interface OAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User> { U loadUser(R userRequest) throws OAuth2AuthenticationException; }
DefaultOAuth2UserService
:OAuth2
์์๋ ๊ธฐ๋ณธ์ ์ผ๋กOAuth2UserService
๊ตฌํ ํด๋์ค๋ฅผ ์ ๊ณตํด์ฃผ๊ณ ์๋ค.loadUser(OAuth2UserRequest userRequest)
: ์ฝ๋๋ฅผ ๋ณด๋ฉดCommonOAuth2Provider
๋๋application.yml
์์ ์ค์ ํprovider
์user-info-url
์API
๋ฅผ ํธ์ถํด ์ ๋ฌ ๋ฐ์Response
๊ฐ์DefaultOAuth2User
๊ฐ์ฒด๋ก ๋ณํํ์ฌ ๋ฐํ ํ๊ณ ์๋ ๊ฒ์ ํ์ธํ ์ ์๋ค.
public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> { ... @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { Assert.notNull(userRequest, "userRequest cannot be null"); if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) { OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE, "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), null); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint() .getUserNameAttributeName(); if (!StringUtils.hasText(userNameAttributeName)) { OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE, "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), null); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } RequestEntity<?> request = this.requestEntityConverter.convert(userRequest); ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request); Map<String, Object> userAttributes = response.getBody(); Set<GrantedAuthority> authorities = new LinkedHashSet<>(); authorities.add(new OAuth2UserAuthority(userAttributes)); OAuth2AccessToken token = userRequest.getAccessToken(); for (String authority : token.getScopes()) { authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority)); } return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName); } ... }
DefaultOAuth2UserService
- ์ฌ์ฉ์ํ
OAuth2UserService
์ธํฐํ์ด์ค๋ฅผ ์ง์ ๊ตฌํํ ์๋ ์์ง๋ง, ์ ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๋API
๋ก์ง๋ ๊ตฌํ์ ํด์ผ๋๋ ์ด๋ ค์์ด ์์ด ๋ค์ ์์ ์์๋DefaultOAuth2UserService
๋ฅผ ์์๋ฐ์์ ๊ตฌํํด๋ณด์.- ์ง๊ธ ์์ ์์๋
DB
๋ฅผ ํตํ ํ์๊ฒ์ฆ์ ์งํํ์ง๋ ์์ง๋ง, ํ์ํ๋ค๋ฉด ๋ณ๋๋ก ๊ตฌํ์ด ํ์ํ๋ค.
@Service public class CustomOAuth2UserService extends DefaultOAuth2UserService { @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); // TODO : DB๋ฅผ ํตํ ํ์๊ฒ์ฆ return oAuth2User; } }
CustomOAuth2UserService
๋ฑ๋ก
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final CustomOAuth2UserService userService; @Bean public SecurityFilterChain config(HttpSecurity http) throws Exception { return http .authorizeRequests() .antMatchers("/login").permitAll() .anyRequest().authenticated() .and() .oauth2Login() .userInfoEndpoint() .userService(userService) .and() .defaultSuccessUrl("/user") .and() .build(); } }
WAS
๋ฅผ ๋๋ฒ๊น ๋ชจ๋๋ก ์คํ์์ผ ์ ๋๋ก ์ ์ฉ์ด ๋์๋์ง ํ์ธํด๋ณด์.
OpenID Connect(OIDC)
โ ๏ธ Google์ ๊ฒฝ์ฐ `break`๊ฐ ์ ๊ฑธ๋ฆฌ๋ ๊ฒ์ ํ์ธํ ์ ์๋ค. (์ค์ ํ ๋๋จธ์ง ํ๋ซํผ๋ค์ ์ ์์ ์ผ๋ก `break`๊ฐ ๊ฑธ๋ฆฐ๋ค.) ์ด์ ๋ Google์ `OpenID Connect` ๋ฐฉ์์ผ๋ก ์๋๋๊ธฐ ๋๋ฌธ์ด๋ค.
- ์๋๋, ChatGPT์๊ฒ โ
OAuth2
vsOIDC
โ ํค์๋๋ก ๋ฌผ์ด๋ณธ ๋ต๋ณ์ ์ผ๋ถ๋ถ์ด๋ค. - ์ฆ,
OAuth2
๊ธฐ๋ฐ์ด์ง๋ง ๋ค๋ฅธ ์ธ์ฆ ๋ฐฉ์์ด๋ค. - ๊ถํ ์์ฒญ์์,
scope
๊ฐ์openid
๊ฐ ํฌํจ ๋์ด ์๋ค๋ฉดOIDC
๋ฐฉ์์ผ๋ก ์ธ์ฆ ์งํ
gt;
<โoauth2 vs oidcโ์ ๊ฒ์ ๊ฒฐ๊ณผ - chatgpt></โoauth2>
[์ฐธ๊ณ ] : https://webapp.chatgpt4google.com/s/MjYzMTU5
- Google์ ์ค์ ์ ๋ณด๋ฅผ ๋ค์ ์ดํด๋ณด๋ฉด,
scope
์openid
๊ฐ ํฌํจ๋์ด์๋๊ฑธ ํ์ธ ํ ์ ์๋ค.
GOOGLE { @Override public Builder getBuilder(String registrationId) { ClientRegistration.Builder builder = getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL); builder.scope("openid", "profile", "email"); // openid ํฌํจ builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth"); builder.tokenUri("https://www.googleapis.com/oauth2/v4/token"); builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs"); builder.issuerUri("https://accounts.google.com"); builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo"); builder.userNameAttributeName(IdTokenClaimNames.SUB); builder.clientName("Google"); return builder; } }
OidcUserService
- ์ฌ์ฉ์ํ
Spring OAuth2 Client
์์OAuth2
์DefaultOAuth2UserService
์ ๋ง์ฐฌ๊ฐ์ง๋กOIDC
๋OidcUserService
๋ฅผ ๊ตฌํ ํด๋์ค๋ก ์ ๊ณตํด์ฃผ๊ณ ์๋ค.OAuth2UserService
์ ์์๋ฐ๊ณ ํ์ ๋งค๊ฐ๋ณ์๊ฐOIDC
๊ด๋ จ ๊ฐ์ฒด์ธ ๊ฒ์ ํ์ธํ ์ ์๋ค.OidUser
๋ํOAuth2User
๋ฅผ ์์๋ฐ๊ณ ์๋ค.
public class OidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> { ... @Override public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { Assert.notNull(userRequest, "userRequest cannot be null"); OidcUserInfo userInfo = null; if (this.shouldRetrieveUserInfo(userRequest)) { OAuth2User oauth2User = this.oauth2UserService.loadUser(userRequest); Map<String, Object> claims = getClaims(userRequest, oauth2User); userInfo = new OidcUserInfo(claims); // https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse // 1) The sub (subject) Claim MUST always be returned in the UserInfo Response if (userInfo.getSubject() == null) { OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } // 2) Due to the possibility of token substitution attacks (see Section // 16.11), // the UserInfo Response is not guaranteed to be about the End-User // identified by the sub (subject) element of the ID Token. // The sub Claim in the UserInfo Response MUST be verified to exactly match // the sub Claim in the ID Token; if they do not match, // the UserInfo Response values MUST NOT be used. if (!userInfo.getSubject().equals(userRequest.getIdToken().getSubject())) { OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } } Set<GrantedAuthority> authorities = new LinkedHashSet<>(); authorities.add(new OidcUserAuthority(userRequest.getIdToken(), userInfo)); OAuth2AccessToken token = userRequest.getAccessToken(); for (String authority : token.getScopes()) { authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority)); } return getUser(userRequest, userInfo, authorities); } ... }
public interface OidcUser extends OAuth2User, IdTokenClaimAccessor { ... }
- ์์์ ์์ฑํ
CustomOAuth2UserService
์ ๋น์ทํ๊ฒCustomOidcUserService
๋ฅผ ์์ฑํด๋ณด์
@Service public class CustomOidcUserService extends OidcUserService { @Override public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { OidcUser oidcUser = super.loadUser(userRequest); // TODO : DB๋ฅผ ํตํ ํ์๊ฒ์ฆ return oidcUser; } }
CustomOidcUserService
๋ฑ๋ก
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final CustomOAuth2UserService userService; private final CustomOidcUserService oidcUserService; @Bean public SecurityFilterChain config(HttpSecurity http) throws Exception { return http .authorizeRequests() .antMatchers("/login").permitAll() .anyRequest().authenticated() .and() .oauth2Login() .userInfoEndpoint() .userService(userService) .oidcUserService(oidcUserService) .and() .defaultSuccessUrl("/user") .and() .build(); } }
- ๋ฑ๋ก ํ,
breakpoint
๋ฅผ ์ค์ ํ๊ณWAS
๋ฅผ ๋๋ฒ๊ทธ ๋ชจ๋๋ก ์คํ์ ํด๋ณด๋ฉด Google์ผ ๋๋break
๊ฑธ๋ฆฌ๋ ๊ฒ์ ํ์ธํ ์ ์๋ค.
์ด๋ ๊ฒ Spring OAuth2 Client
๋ฅผ ๊ฐ๋จํ(?) ์์๋ดค๋๋ฐโฆ
๋์ฑ ์์ธํ๊ฒ ๋ค์ด๊ฐ๋ฉด ์ด ๊ธ์ด ๋๋์ง ์์ ๊ฒ ๊ฐ์ ์ฌ๊ธฐ์ ๋ง๋ฌด๋ฆฌ ํ๊ฒ ๋ค. ๐
(๋๋ฌด ๋ด์ฉ์ด ๋ฑ๋ฑํ๊ณ ์ง์งํด์ ์ฌ๋ฏธ๊ฐ ์์ด ๊ฑฑ์ ์ด ๋๋คโฆ.)
์กฐ๊ธ์ด๋๋ง, Spring OAuth2 Client
์ ๊ฐ๋
์ ๋ํ ์ดํด์ ์์ ๋ค์ ํตํด ๋๊ธฐ ์ํด ์์ฑํ ๊ธ์ด๋ค.
์ฐธ๊ณ ์ ์ถ์ฒ๋ฅผ ํตํด ๋ ๋ํ
์ผํ๊ฒ ์์ ๋ณด๋ ๊ฒ์ ๊ถ์ฅํ๊ณ , ํ์ต ํ ์๋น์ค์ ์ ์ฉํ๊ธฐ๋ฅผ ๋ฐ๋๋ค.
์์ ์์ ์์๋ 5๊ฐ์ ๋ก๊ทธ์ธ ํ๋ซํผ์ ์ฐ๋ ์์ผ๋ดค๋๋ฐ,
์ด ์ธ์๋ ๋๊ธ๋ก Twitter, Apple, Weibo ๋ฑ ์์ฒญ์ด ๋ค์ ๋ค์ด์ค๋ฉด ๋ค์ ํฌ์คํธ์์ ์๊ฐ ํ๋๋ก ํ๊ฒ ๋ค.
[์ถ์ฒ]
https://velog.io/@tmdgh0221/Spring-Security-์-OAuth-2.0-์-JWT-์-์ฝ๋ผ๋ณด
https://catsbi.oopy.io/f9b0d83c-4775-47da-9c81-2261851fe0d0
https://inpa.tistory.com/entry/WEB-๐-OAuth-20-๊ฐ๋ -๐ฏ-์ ๋ฆฌ
[Github]
'๐ฑBackend' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
ํ์ด์ฌ FastAPI๋ฅผ ์ด์ฉํ API ํ๋ก์ ํธ ๋ง๋ค๊ธฐ (1) - ์คํฌ๋ํ API ๋ง๋ค๊ธฐ (0) | 2024.11.26 |
---|---|
ํ์ด์ฌ FastAPI๋ฅผ ์ด์ฉํ API ํ๋ก์ ํธ ๋ง๋ค๊ธฐ (0) - ์๊ฐ ๋ฐ ์์ (0) | 2024.11.26 |
ํ์ด์ฌ ์น ์คํฌ๋ํ - ๋ฉ๋ก ์์ ์ถ์ถ (2) | 2024.03.29 |
๋๋ค๋ฅผ ์ฌ์ฉํด์ผ ๋๋ ์ด์ (0) | 2023.06.05 |
์คํ๋ง API ๋น๋๊ธฐ ๋ ผ๋ธ๋กํน ๋ฐฉ์ ํธ์ถ (0) | 2023.03.27 |