-
[Spring Security] OAuth2.0 + Vue.js 소셜 로그인 구현, SPA (OAuth 2.0 / 구글 로그인 / 카카오 로그인 / 네이버 로그인)spring security 2024. 5. 9. 12:59
Spring Boot + Vue.js를 사용하여 소셜 로그인을 구현하려 한다.
Rest api를 통해 통신한다.
로그인 화면 (Vue.js)
<v-btn class="social-btn google-btn" block href="http://localhost:8080/oauth2/authorize/google?redirect_uri=http://localhost:9090/oauth2/redirect"> <v-col class="text-left"> <v-icon left class="social-icon">mdi-google</v-icon> </v-col> <v-col class="ml-10"> <span>구글 계정으로 로그인</span> </v-col> </v-btn> <v-btn class="social-btn kakao-btn mt-2" block href="http://localhost:8080/oauth2/authorize/kakao?redirect_uri=http://localhost:9090/oauth2/redirect"> <v-col> <svg class="social-icon" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#ffffff" transform="matrix(1, 0, 0, 1, 0, 0)rotate(0)" stroke="#ffffff"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="35.839999999999996"></g><g id="SVGRepo_iconCarrier"><path fill="#000000" d="M255.5 48C299.345 48 339.897 56.5332 377.156 73.5996C414.415 90.666 443.871 113.873 465.522 143.22C487.174 172.566 498 204.577 498 239.252C498 273.926 487.174 305.982 465.522 335.42C443.871 364.857 414.46 388.109 377.291 405.175C340.122 422.241 299.525 430.775 255.5 430.775C241.607 430.775 227.262 429.781 212.467 427.795C148.233 472.402 114.042 494.977 109.892 495.518C107.907 496.241 106.012 496.15 104.208 495.248C103.486 494.706 102.945 493.983 102.584 493.08C102.223 492.177 102.043 491.365 102.043 490.642V489.559C103.126 482.515 111.335 453.169 126.672 401.518C91.8486 384.181 64.1974 361.2 43.7185 332.575C23.2395 303.951 13 272.843 13 239.252C13 204.577 23.8259 172.566 45.4777 143.22C67.1295 113.873 96.5849 90.666 133.844 73.5996C171.103 56.5332 211.655 48 255.5 48Z"></path></g></svg> </v-col> <v-col class="ml-10"> 카카오 계정으로 로그인 </v-col> </v-btn> <v-btn class="social-btn twitter-btn mt-2" block href="http://localhost:8080/oauth2/authorize/naver?redirect_uri=http://localhost:9090/oauth2/redirect"> <v-col> <svg class="social-icon" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#ffffff"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path fill="#ffffff" d="M9 32V480H181.366V255.862L331.358 480H504V32H331.358V255.862L181.366 32H9Z"></path></g></svg> </v-col> <v-col class="ml-10"> 네이버 계정으로 로그인 </v-col> </v-btn>
oauth2 로그인 요청을 보내고 이후 응답으로 보내질 redirect_uri를 같이 써준다.
<template> <div></div> </template> <script> import router from '@/router'; import store from '@/store'; export default { created() { const accessToken = this.$route.query.accessToken; const refreshToken = this.$route.query.refreshToken; if (accessToken && refreshToken) { store.commit('setAccessToken', accessToken); store.commit('setRefreshToken', refreshToken); router.push({path: '/'}) } else { router.push({path: '/login'}) } } } </script>
http://localhost:9090/oauth2/redirect로 redirect 되면 access token과 refresh token을 저장해준다.
store.js
import Vuex from 'vuex' const store = new Vuex.Store({ state: { accessToken: localStorage.getItem('accessToken') || null, refreshToken: localStorage.getItem('refreshToken') || null }, mutations: { setAccessToken(state, token) { state.accessToken = token; localStorage.setItem('accessToken', token); }, setRefreshToken(state, token) { state.refreshToken = token; localStorage.setItem('refreshToken', token); }, clearToken(state) { state.accessToken = null; state.refreshToken = null; localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); } }, }); export default store
application.yml 파일 설정
spring: security: oauth2: client: registration: google: client-id: [client-id] client-secret: [client-secret] scope: - profile - email naver: client-id: [client-id] client-secret: [client-secret] client-name: Naver redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" authorization-grant-type: authorization_code scope: - name - email kakao: client-id: [client-id] client-secret: [client-secret] client-name: Kakao client-authentication-method: client_secret_post redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" authorization-grant-type: authorization_code scope: - profile_nickname - account_email 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: authorizationUri: https://kauth.kakao.com/oauth/authorize tokenUri: https://kauth.kakao.com/oauth/token userInfoUri: https://kapi.kakao.com/v2/user/me userNameAttribute: id datasource: url: [url] username: [username] password: [password] driverClassName: com.mysql.jdbc.Driver jpa: hibernate.ddl-auto: update generate-ddl: true show-sql: true jwt: secret: [secret]
SecurityConfig.java
@Configuration @EnableWebSecurity public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2SuccessHandler oAuth2SuccessHandler; private final OAuth2FailureHandler oAuth2FailureHandler; private final JwtTokenProvider jwtTokenProvider; @Autowired public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, OAuth2SuccessHandler oAuth2SuccessHandler, OAuth2FailureHandler oAuth2FailureHandler, JwtTokenProvider jwtTokenProvider) { this.customOAuth2UserService = customOAuth2UserService; this.oAuth2SuccessHandler = oAuth2SuccessHandler; this.oAuth2FailureHandler = oAuth2FailureHandler; this.jwtTokenProvider = jwtTokenProvider; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.formLogin(AbstractHttpConfigurer::disable); http.httpBasic(AbstractHttpConfigurer::disable); http.sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry.requestMatchers("/api/**", "/oauth2/**", "/login/**").permitAll().anyRequest().authenticated()); http.oauth2Login(httpSecurityOAuth2LoginConfigurer -> httpSecurityOAuth2LoginConfigurer .authorizationEndpoint(authorizationEndpointConfig -> authorizationEndpointConfig.baseUri("/oauth2/authorize") .authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository()) ) .redirectionEndpoint(redirectionEndpointConfig -> redirectionEndpointConfig.baseUri("/login/oauth2/code/**") ) .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig .userService(customOAuth2UserService) ) .successHandler(oAuth2SuccessHandler) .failureHandler(oAuth2FailureHandler) ); http.exceptionHandling(httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer .authenticationEntryPoint( (request, response, authException) -> response.sendError(401) ) .accessDeniedHandler( (request, response, accessDeniedException) -> response.sendError(403) ) ); http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public BCryptPasswordEncoder encoder() { return new BCryptPasswordEncoder(); } @Bean public HttpCookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository() { return new HttpCookieOAuth2AuthorizationRequestRepository(); } }
oauth2 로그인 요청이오면 설정된 내용들을 바탕으로 로그인 처리가 된다.
요청이 들어오면 HttpCookieOAuth2AuthorizationRequestRepository로 가서 authorization request를 저장한다.
HttpCookieOAuth2AuthorizationRequestRepository.java
@Component public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> { private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; private static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri"; private static final int cookieMaxAge = 180; private static final Logger LOGGER = LoggerFactory.getLogger(HttpCookieOAuth2AuthorizationRequestRepository.class); @Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { LOGGER.debug("load auth req cookie"); return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) .orElse(null); } @Override public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { if (authorizationRequest == null) { LOGGER.debug("request is null"); CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME); return; } LOGGER.debug("add cookies"); CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieMaxAge); String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME); if (StringUtils.isNotBlank(redirectUriAfterLogin)) { CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieMaxAge); } } @Override public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { return this.loadAuthorizationRequest(request); } }
authorization request를 cookie에 저장.
CookieUtils.java
public class CookieUtils { public static Optional<Cookie> getCookie(HttpServletRequest request, String name) { Cookie[] cookies = request.getCookies(); if (cookies != null && cookies.length > 0) { for (Cookie cookie : cookies) { if (cookie.getName().equals(name)) { return Optional.of(cookie); } } } return Optional.empty(); } public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { Cookie cookie = new Cookie(name, value); cookie.setPath("/"); cookie.setMaxAge(maxAge); cookie.setHttpOnly(true); response.addCookie(cookie); } public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) { Cookie[] cookies = request.getCookies(); if (cookies != null && cookies.length > 0) { for (Cookie cookie : cookies) { if (cookie.getName().equals(name)) { cookie.setValue(""); cookie.setPath("/"); cookie.setMaxAge(0); response.addCookie(cookie); } } } } public static String serialize(Object object) { return Base64.getUrlEncoder() .encodeToString(SerializationUtils.serialize(object)); } public static <T> T deserialize(Cookie cookie, Class<T> cls) { return cls.cast(SerializationUtils.deserialize( Base64.getUrlDecoder().decode(cookie.getValue()) )); } }
CustomOAuth2UserService.java
@Service public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> { private final UserRepository userRepository; private static final Logger LOGGER = LoggerFactory.getLogger(CustomOAuth2UserService.class); @Autowired public CustomOAuth2UserService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService(); OAuth2User oAuth2User = delegate.loadUser(userRequest); LOGGER.debug("user loaded"); String registrationId = userRequest.getClientRegistration().getRegistrationId(); String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); OAuthAttributes oAuthAttributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); Optional<User> user = userRepository.findByEmail(oAuthAttributes.getEmail()); if (user.isEmpty()) { user = Optional.of(userRepository.save(oAuthAttributes.toEntity())); } LOGGER.debug("user loading succeeded"); return new DefaultOAuth2User( Collections.singleton(new SimpleGrantedAuthority(user.get().getRole().toString())), oAuthAttributes.getAttributes(), oAuthAttributes.getNameAttributeKey() ); } }
소셜로그인 Provider별로 OAuthAttributes에 정보를 담아 user db에 저장해준다.
OAuthAttributes.java
@Builder @Getter public class OAuthAttributes { private Map<String, Object> attributes; private String nameAttributeKey; String name; String email; public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) { if (registrationId.equals("kakao")) { return ofKakao(userNameAttributeName, attributes); } else if (registrationId.equals("naver")) { return ofNaver(userNameAttributeName, attributes); } return ofGoogle(userNameAttributeName, attributes); } private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) { return OAuthAttributes.builder() .name((String) attributes.get("name")) .email((String) attributes.get("email")) .attributes(attributes) .nameAttributeKey(userNameAttributeName) .build(); } private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) { Map<String, Object> response = (Map<String, Object>) attributes.get("response"); return OAuthAttributes.builder() .name((String) response.get("name")) .email((String) response.get("email")) .attributes(attributes) .nameAttributeKey(userNameAttributeName) .build(); } private static OAuthAttributes ofKakao(String usernameAttributeName, Map<String, Object> attributes) { Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account"); return OAuthAttributes.builder() .name((String) account.get("nickname")) .email((String) account.get("email")) .attributes(attributes) .nameAttributeKey(usernameAttributeName) .build(); } public User toEntity() { return User.builder() .name(name) .email(email) .role(Role.USER) .build(); } }
OAuth2SuccessHandler.java
@Component public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final JwtTokenProvider jwtTokenProvider; private final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri"; private final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; private static final Logger LOGGER = LoggerFactory.getLogger(OAuth2SuccessHandler.class); @Autowired public OAuth2SuccessHandler(JwtTokenProvider jwtTokenProvider) { this.jwtTokenProvider = jwtTokenProvider; } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { String targetUrl = determineTargetUrl(request, response, authentication); clearAuthenticationAttributes(request, response); LOGGER.debug("authentication succeeded and redirect"); getRedirectStrategy().sendRedirect(request, response, targetUrl); } protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { Optional<String> cookie = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME) .map(Cookie::getValue); String targetUrl = cookie.orElse(getDefaultTargetUrl()); JwtToken jwtToken = jwtTokenProvider.generateToken(authentication); String accessToken = jwtToken.getAccessToken(); String refreshToken = jwtToken.getRefreshToken(); return UriComponentsBuilder.fromUriString(targetUrl) .queryParam("accessToken", accessToken) .queryParam("refreshToken", refreshToken) .build().toUriString(); } protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { super.clearAuthenticationAttributes(request); LOGGER.debug("clear authentication attributes"); removeAuthorizationRequestCookies(request, response); } public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) { LOGGER.debug("remove authorization request cookies"); CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME); } }
로그인에 성공하면 OAuth2SuccessHandler를 실행한다.
OAuth2FailureHandler.java
@Component public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { private final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri"; private final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; private static final Logger LOGGER = LoggerFactory.getLogger(CustomOAuth2UserService.class); @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME) .map(Cookie::getValue).orElse("/"); targetUrl = UriComponentsBuilder.fromHttpUrl(targetUrl) .queryParam("error", exception.getLocalizedMessage()) .toUriString(); removeAuthorizationRequestCookies(request, response); LOGGER.debug("authentication failed and redirect"); getRedirectStrategy().sendRedirect(request, response, targetUrl); } public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) { LOGGER.debug("remove authorization request cookies"); CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME); } }
로그인에 실패하면 OAuth2FailureHandler를 실행한다.
JwtTokenProvider.java
@Component public class JwtTokenProvider { private final SecretKey key; public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) { byte[] bytes = DatatypeConverter.parseBase64Binary(secretKey); this.key = Keys.hmacShaKeyFor( bytes ); } private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class); public JwtToken generateToken(Authentication authentication) { String authorities = authentication.getAuthorities() .stream().map(GrantedAuthority::getAuthority) .collect(Collectors.joining(",")); String accessToken = createAccessToken(authentication, authorities); String refreshToken = createRefreshToken(authentication, authorities); LOGGER.debug("generate Token success"); return JwtToken.builder() .grantType("Bearer") .accessToken(accessToken) .refreshToken(refreshToken) .build(); } public String createAccessToken(Authentication authentication, String authorities) { String accessToken = Jwts.builder() .signWith(key) .setSubject(authentication.getName()) .claim("auth", authorities) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 30)) .compact(); return accessToken; } public String createRefreshToken(Authentication authentication, String authorities) { String refreshToken = Jwts.builder() .signWith(key) .setSubject(authentication.getName()) .claim("auth", authorities) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 72)) .compact(); return refreshToken; } public Authentication getAuthentication(String accessToken) { Claims claims = parseClaims(accessToken); if (claims.get("auth") == null) { throw new RuntimeException("권한 정보 없는 토큰 입니다."); } Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(",")) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); UserDetails principal = new User(claims.getSubject(), "", authorities); LOGGER.debug("getAuthentication Success"); return new UsernamePasswordAuthenticationToken(principal, "", authorities); } public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); LOGGER.debug("token is valid"); return true; } catch (io.jsonwebtoken.security.SecurityException e) { } catch (ExpiredJwtException e) { } catch (UnsupportedJwtException e) { } catch (IllegalArgumentException e) { } return false; } private Claims parseClaims(String accessToken) { try { LOGGER.debug("parse claim success"); return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); } catch (ExpiredJwtException e) { return e.getClaims(); } } }
JwtToken.java
@Data @Builder @AllArgsConstructor public class JwtToken { String grantType; String accessToken; String refreshToken; }
JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class); @Autowired public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { this.jwtTokenProvider = jwtTokenProvider; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String accessToken = jwtTokenProvider.resolveAccessToken(request); String refreshToken = jwtTokenProvider.resolveRefreshToken(request); boolean isValidToken = jwtTokenProvider.validateToken(accessToken); if (accessToken != null && isValidToken) { Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); LOGGER.debug("Filter passed"); } else if (refreshToken != null && !isValidToken) { LOGGER.debug(refreshToken); boolean isValidRefreshToken = jwtTokenProvider.validateToken(refreshToken); if (isValidRefreshToken) { accessToken = jwtTokenProvider.regenerateAccessToken(refreshToken); jwtTokenProvider.setHeaderAccessToken(response, accessToken); Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); LOGGER.debug("token regenerated"); } else { LOGGER.debug("not valid refresh token"); } } filterChain.doFilter(request, response); } }
인증이 필요한 요청이 들어올때 마다 JwtAuthenticationFilter를 실행합니다.
access token이 유효하면 그대로 진행하고 유효하지 않으면 refresh token을 통해 access token을 재발급 합니다.
[출처]
https://deeplify.dev/back-end/spring/oauth2-social-login#securityconfig
[Spring Boot] OAuth2 소셜 로그인 가이드 (구글, 페이스북, 네이버, 카카오)
스프링부트를 이용하여 구글, 페이스북, 네이버, 카카오 OAuth2 로그인 구현하는 방법에 대해서 소개합니다.
deeplify.dev
https://datamoney.tistory.com/336#google_vignette
[Spring] Spring Security + OAuth2.0으로 소셜 로그인 구현 ver 2. SPA (OAuth2.0 / 구글 로그인 / 네이버 로그인
2023.01.20 - [Backend/Spring] - [Spring] Spring Security + OAuth2.0으로 소셜 로그인 구현 ver 1. SSR (OAuth2.0 / 구글 로그인 / 네이버 로그인 / 카카오 로그인 / 회원가입) [Spring] Spring Security + OAuth2.0으로 소셜 로그인
datamoney.tistory.com
[백업] Refresh Token 발급과 Access Token 재발급
[백업] Refresh Token 발급과 Access Token 재발급
velog.io
'spring security' 카테고리의 다른 글
[Spring security] CORS 설정하기 (0) 2024.04.03 OAuth 2가 작동하는 방법 (1) 2023.01.31