다양한 소셜 로그인 가능한 OAuth 라이브러리(Java)

ScribeJava, the simple OAuth Java lib

다양한 소셜 로그인이 가능한 ScribeJava를 소개합니다.

웹서비스를 제작할 때 다양한 SNS 서비스의 OAuth를 이용하는 경우가 많은데
이것들을 하나로 처리할 수 있는 통합 라이브러리입니다.

[기능 및 특징]

  • 심플하다
  • Thread-safe
  • Async
  • facebook, google, Sina, Tweeter, Yahoo, Pinterest 등 36개 SNS 로그인 지원
  • 작고 모듈화 됨 (약 1k LOC)
  • 안드로이드앱 지원
  • 안전성 & 오류처리
  • Maven 지원 (pom.xml)

[사용법]

  1. pom.xml 에 dependency 등록
  2. OAuthService 오브젝트 생성
  3. request 토큰 가져오기
  4. OAuth call로 서비스할 앱에 대한 권한 허용을 사용자가 하면
  5. 로그인 처리를 수행할 URL을 던져준다
  6. 발급된 AccessToken을 가져와서
  7. 로그인처리 URL을 AccessToken과 함께 요청한다
  8. 이후는 자신의 서비스 로직대로 진행

[사용예]

  • 프론트 (HTML/JS)
  •     <ul class="snsLogin">
            <li class="facebook"><a href="/snsGateway.do?mode=fb&returnUrl="><span></span><strong>페이스북</strong>으로
                            로그인</a></li>
            <li class="kakao"><a href="/snsGateway.do?mode=kakao&returnUrl="><span></span><strong>카카오톡</strong>으로
                            로그인</a></li>
            <li class="instagram"><a href="/snsGateway.do?mode=instagram&returnUrl="><span></span><strong>인스타그램</strong>으로
                            로그인</a></li>
        </ul>
    
  • 웹 콘트롤러 (Java)
  • import com.github.scribejava.core.model.*;
    import com.github.scribejava.core.oauth.OAuth10aService;
    import com.github.scribejava.core.oauth.OAuth20Service;
    
    /* ... */
    
        @RequestMapping("/snsGateway.do")
        public ModelAndView snsLogin(@RequestParam String mode,
                                     @RequestParam(required = false, value = "returnUrl") String returnUrl) {
            SnsServiceFactory factory = new SnsServiceFactory(request);
    
            ModelAndView mav = new ModelAndView();
    
            if (returnUrl != null) {
                request.getSession().setAttribute("returnUrl", returnUrl);
            }
    
            if ("fb".equals(mode)) {
                mav.setViewName("redirect:" + factory.facebook().getAuthorizationUrl());
            } else if ("instagram".equals(mode)) {
                mav.setViewName("redirect:" + factory.instagram().getAuthorizationUrl());
            } else if ("kakao".equals(mode)) {
                mav.setViewName("redirect:" + factory.kakao().getAuthorizationUrl());
            } else if ("line".equals(mode)) {
                mav.setViewName("redirect:" + factory.line().getAuthorizationUrl());
            } else if ("twitter".equals(mode)) {
                OAuth10aService service = factory.twitter();
                OAuth1RequestToken requestToken = service.getRequestToken();
                String u = service.getAuthorizationUrl(requestToken);
    
                request.getSession().setAttribute("tw_requestToken", requestToken);
    
                mav.setViewName("redirect:" + u);
            } else if ("google".equals(mode)) {
                mav.setViewName("redirect:" + factory.google().getAuthorizationUrl());
            } else if ("weibo".equals(mode)) {
                mav.setViewName("redirect:" + factory.weibo().getAuthorizationUrl());
            } else if ("wechat".equals(mode)) {
                mav.setViewName("redirect:" + factory.wechat().getAuthorizationUrl());
            }
    
            return mav;
        }
    
  • 서비스 팩토리 (Java)
  • import com.github.scribejava.apis.FacebookApi;
    import com.github.scribejava.apis.GoogleApi20;
    import com.github.scribejava.apis.TwitterApi;
    import com.github.scribejava.core.builder.ServiceBuilder;
    import com.github.scribejava.core.oauth.OAuth10aService;
    import com.github.scribejava.core.oauth.OAuth20Service;
    
    public class SnsConfigParameter {
        private String clientId;
        private String client_secret;
        private String redirect_uri;
    
        // ...
    };
    
    public class SnsServiceFactory {
        private HttpServletRequest request;
    
        public SnsServiceFactory(HttpServletRequest request) {
            this.request = request;
        }
    
        public OAuth10aService twitter() {
            SnsConfigParameter t = SnsConfig.getInstance(request).twitter();
    
            return new ServiceBuilder()
                    .apiKey(t.getClientId())
                    .apiSecret(t.getClient_secret())
                    .callback(t.getRedirect_uri())
                    .build(TwitterApi.instance());
        }
    
        public SnsService kakao() {
            SnsConfigParameter t = SnsConfig.getInstance(request).kakao();
    
            return new ServiceBuilder()
                    .apiKey(t.getClientId())
                    .grantType("authorization_code")
                    .callback(t.getRedirect_uri())
                    .build(KakaoApi.instance());
        }
    
        public SnsService weibo() {
            SnsConfigParameter t = SnsConfig.getInstance(request).weibo();
    
            return new ServiceBuilder()
                    .apiKey(t.getClientId())
                    .apiSecret(t.getClient_secret())
                    .grantType("authorization_code")
                    .callback(t.getRedirect_uri())
                    .build(WeiboApi.instance());
        }
    
        public SnsService instagram() {
            SnsConfigParameter t = SnsConfig.getInstance(request).instagram();
    
            return new ServiceBuilder()
                    .apiKey(t.getClientId())
                    .apiSecret(t.getClient_secret())
                    .grantType("authorization_code")
                    .callback(t.getRedirect_uri())
                    .build(InstagramApi.instance());
        }
    
        public OAuth20Service facebook() {
            SnsConfigParameter t = SnsConfig.getInstance(request).facebook();
    
            return new ServiceBuilder()
                    .apiKey(t.getClientId())
                    .apiSecret(t.getClient_secret())
                    .scope("email")
                    .callback(t.getRedirect_uri())
                    .build(FacebookApi.instance());
        }
    
        public SnsService line() {
            SnsConfigParameter t = SnsConfig.getInstance(request).line();
    
            return new ServiceBuilder()
                    .apiKey(t.getClientId())
                    .apiSecret(t.getClient_secret())
                    .grantType("authorization_code")
                    .callback(t.getRedirect_uri())
                    .build(LineApi.instance());
        }
    
        public OAuth20Service google() {
            SnsConfigParameter t = SnsConfig.getInstance(request).google();
    
            return new ServiceBuilder()
                    .apiKey(t.getClientId())
                    .apiSecret(t.getClient_secret())
                    .scope("profile")
                    .callback(t.getRedirect_uri())
                    .build(GoogleApi20.instance());
        }
    
        public SnsService wechat() {
            SnsConfigParameter t = SnsConfig.getInstance(request).wechat();
    
            return new ServiceBuilder()
                    .apiKey(t.getClientId())
                    .apiSecret(t.getClient_secret())
                    .callback(t.getRedirect_uri())
                    .scope("snsapi_login")
                    .grantType("authorization_code")
                    .build(WechatApi.instance());
        }
    }
    
  • API 설정 파라미터 (Java)
  • public class SnsConfig {
        private HttpServletRequest request;
    
        public String getHostName() {
            String scheme = request.getScheme();
            String serverName = request.getServerName();
    
            return scheme + "://" + serverName;
        }
    
        public String getRedirectUrl(String snsType) {
            return getHostName() + "/oAuthCallback.do?m=" + snsType;
        }
    
        public SnsConfigParameter line() {
            return new SnsConfigParameter( /* ... */ );
        }
    
        public SnsConfigParameter kakao() {
            return new SnsConfigParameter( /* ... */ );
        }
    
        public SnsConfigParameter facebook() {
            return new SnsConfigParameter( /* ... */ );
        }
    
        public SnsConfigParameter instagram() {
            return new SnsConfigParameter( /* ... */ );
        }
    
        public SnsConfigParameter twitter() {
            return new SnsConfigParameter( /* ... */ );
        }
    
        public SnsConfigParameter google() {
            return new SnsConfigParameter( /* ... */ );
        }
    
        public SnsConfigParameter weibo() {
            return new SnsConfigParameter( /* ... */ );
        }
    
        public SnsConfigParameter wechat() {
            return new SnsConfigParameter( /* ... */ );
        }
    
        private static SnsConfig instance = new SnsConfig();
    
        public void setRequest(HttpServletRequest request) {
            this.request = request;
        }
    
        public static SnsConfig getInstance(HttpServletRequest request) {
            instance.setRequest(request);
    
            return instance;
        }
    
        public final static SnsConfigParameter FACEBOOK = new SnsConfigParameter( /* ... */ );
    
        public final static SnsConfigParameter INSTAGRAM = new SnsConfigParameter( /* ... */ );
    
        public final static SnsConfigParameter KAKAO = new SnsConfigParameter( /* ... */ );
    
        public final static SnsConfigParameter TWITTER = new SnsConfigParameter( /* ... */ );
    
        public final static SnsConfigParameter GOOGLE = new SnsConfigParameter( /* ... */ );
    
        public final static SnsConfigParameter WEIBO = new SnsConfigParameter( /* ... */ );
    }
    
  • 웹콘트롤러 (java)
  •     public Map getTokenMap() {
            return request.getSession().getAttribute(TOKEN_KEY) == null ?
                    new HashMap() : (Map) request.getSession().getAttribute(TOKEN_KEY);
        }
    
        @RequestMapping("/oAuthCallback.do")
        public String oauthCallback(@RequestParam String m, HttpServletResponse response) {
            SnsServiceFactory factory = new SnsServiceFactory(request);
    
            String code = request.getParameter("code");
    
            JSONObject result;
            String profile_image = null;
            String id = null;
            String name = null;
    
            try {
                if ("fb".equals(m)) {
                    OAuth20Service service = factory.facebook();
                    OAuth2AccessToken accessToken = service.getAccessToken(code);
    
                    getTokenMap().put(m, accessToken);
    
                    OAuthRequest request = new OAuthRequest(Verb.GET, "https://graph.facebook.com/v2.5/me", service);
                    service.signRequest(accessToken, request);
                    result = new JSONObject(request.send().getBody());
    
                    OAuthRequest request2 = new OAuthRequest(Verb.GET, "https://graph.facebook.com/v2.5/me?fields=picture.type(large)", service);
                    service.signRequest(accessToken, request2);
                    JSONObject result2 = new JSONObject(request2.send().getBody());
                    result = JsonUtils.merge(result, result2);
    
                    id = result.getString("id");
                    name = result.getString("name");
                    profile_image = result.getJSONObject("picture").getJSONObject("data").getString("url");
                } 
                else if ("google".equals(m)) {
                    OAuth20Service service = factory.google();
                    OAuth2AccessToken accessToken = service.getAccessToken(code);
                    getTokenMap().put(m, accessToken);
    
                    OAuthRequest request = new OAuthRequest(Verb.GET, "https://www.googleapis.com/plus/v1/people/me", service);
                    service.signRequest(accessToken, request);
                    result = new JSONObject(request.send().getBody());
    
                    id = String.valueOf(result.get("id"));
                    name = result.getString("displayName");
                    profile_image = result.getJSONObject("image").getString("url");
                } 
                else if ("twitter".equals(m)) {
                    OAuth1RequestToken requestToken = (OAuth1RequestToken) request.getSession().getAttribute("tw_requestToken");
    
                    OAuth10aService service = factory.twitter();
                    String oauthVerifier = request.getParameter("oauth_verifier");
                    OAuth1AccessToken accessToken = service.getAccessToken(requestToken, oauthVerifier);
                    getTokenMap().put(m, accessToken);
    
                    OAuthRequest request = new OAuthRequest(Verb.GET, "https://api.twitter.com/1.1/account/verify_credentials.json", service);
                    service.signRequest(accessToken, request);
                    result = new JSONObject(request.send().getBody());
    
                    id = String.valueOf(result.get("id"));
                    name = result.getString("name");
                    profile_image = result.getString("profile_image_url_https");
                } 
                else if ("instagram".equals(m)) {
                    SnsService service = factory.instagram();
                    result = requestSns(code, "https://api.instagram.com/v1/users/self", service);
    
                    id = result.getJSONObject("data").getString("id");
                    name = result.getJSONObject("data").getString("username");
                    profile_image = result.getJSONObject("data").getString("profile_picture");
                } 
                else if ("kakao".equals(m)) {
                    SnsService service = factory.kakao();
                    result = requestSns(code, "https://kapi.kakao.com/v1/user/me", service);
    
                    id = String.valueOf(result.get("id"));
                    name = result.getJSONObject("properties").getString("nickname");
                    profile_image = result.getJSONObject("properties").getString("profile_image");
                } else if ("weibo".equals(m)) {
                    SnsService service = factory.weibo();
                    String url = "https://api.weibo.com/2/users/show.json";
    
                    OAuth2AccessToken accessToken = service.getAccessToken(code);
                    SnsRequest request = new SnsRequest(Verb.GET, url, service);
                    request.addParameter("access_token", accessToken.getAccessToken());
                    request.addParameter("uid", String.valueOf(service.getResponseJson().get("uid")));
                    result = new JSONObject(request.send().getBody());
    
                    id = String.valueOf(result.get("id"));
                    name = result.getString("screen_name");
                    profile_image = result.getString("avatar_large");
                } 
                else if ("line".equals(m)) {
                    SnsService service = factory.line();
                    result = requestSns(code, "https://api.line.me/v1/profile", service);
    
                    id = String.valueOf(result.get("mid"));
                    name = result.getString("displayName");
                    profile_image = result.getString("pictureUrl");
                } 
                else if ("wechat".equals(m)) {
                    SnsService service = factory.wechat();
    
                    Map<String, String> data = new HashMap<String, String>();
                    data.put("appid", service.getConfig().getApiKey());
                    data.put("secret", service.getConfig().getApiSecret());
    
                    OAuth2AccessToken accessToken = service.getAccessToken(code, data);
                    String url = "https://api.weixin.qq.com/sns/userinfo";
    
                    SnsRequest request = new SnsRequest(Verb.GET, url, service);
                    request.addParameter("access_token", accessToken.getAccessToken());
                    request.addParameter("openid", String.valueOf(service.getResponseJson().get("openid")));
                    result = new JSONObject(request.send().getBody());
    
                    id = String.valueOf(result.get("openid"));
                    name = result.getString("nickname");
                    profile_image = result.getString("headimgurl");
                }
    
                User user = new User();
                user.setId(id);
                user.setName(name);
                user.setPicture(profile_image);
                user.setSnsType(m);
    
                MbrManageVO mbrUser = mbrManageService.getUserInfoMbrId(user.getUniqueID());
    
                if (mberUser == null) {
                    // 임의 사용자 처리
                } 
                request.getSession().setAttribute("mbrUser", mbrUser);
    
                // 토큰 발급 
    
                Authentication authentication = this.authenticationManager.authenticate(authToken);
                this.persistRealNameAuthentication(authentication, request.getSession());
                this.rememberMeServices.loginSuccess(request, response, realAuthToken);
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            String returnUrl = (String) request.getSession().getAttribute("returnUrl");
            request.getSession().removeAttribute("returnUrl");
            if (!StringUtils.isEmpty(returnUrl)) {
                return "redirect:" + returnUrl;
            }
    
            if ("mobile".equals(request.getSession().getAttribute("loginMode"))) {
                return "redirect:/mobile/main.do";
            } else {
                return "redirect:/index.do";
            }
        }
    
        protected JSONObject requestSns(String code, String url, SnsService service) throws Exception {
            OAuth2AccessToken accessToken = service.getAccessToken(code);
            OAuthRequest request = new OAuthRequest(Verb.GET, url, service);
    
            request.addHeader("Authorization", accessToken.getAccessToken());
            service.signRequest(accessToken, request);
    
            return new JSONObject(request.send().getBody());
        }
    
  • SNS별 API (Java)
    ** 없는건 형식에 맞춰 만들어 사용
  • import com.github.scribejava.core.builder.api.BaseApi;
    import com.github.scribejava.core.extractors.OAuth2AccessTokenJsonExtractor;
    import com.github.scribejava.core.extractors.TokenExtractor;
    import com.github.scribejava.core.model.OAuth2AccessToken;
    import com.github.scribejava.core.model.Verb;
    import com.github.scribejava.core.builder.api.DefaultApi20;
    import com.github.scribejava.core.model.OAuthConfig;
    import com.github.scribejava.core.model.Verb;
    import com.github.scribejava.core.utils.OAuthEncoder;
    import com.github.scribejava.core.utils.Preconditions;
    
    public abstract class SnsApi implements BaseApi<SnsService> {
        public TokenExtractor<OAuth2AccessToken> getAccessTokenExtractor() {
            return OAuth2AccessTokenJsonExtractor.instance();
        }
    
        public Verb getAccessTokenVerb() {
            return Verb.POST;
        }
    
        public abstract String getAccessTokenEndpoint();
    
        public abstract String getAuthorizationUrl(OAuthConfig var1);
    
        public String getAuthorizationUrl(OAuthConfig config, Map<String, String> additionalParams) {
            String authUrl = this.getAuthorizationUrl(config);
    
            if (additionalParams != null && !additionalParams.isEmpty()) {
                StringBuilder authUrlWithParams = (new StringBuilder(authUrl)).append(authUrl.indexOf(63) == -1 ? '?' : '&');
    
                for (Object o : additionalParams.entrySet()) {
                    Map.Entry param = (Map.Entry) o;
                    authUrlWithParams.append(OAuthEncoder.encode((String) param.getKey())).append('=').append(OAuthEncoder.encode((String) param.getValue())).append('&');
                }
    
                authUrl = authUrlWithParams.substring(0, authUrlWithParams.length() - 1);
            }
    
            return authUrl;
        }
    
        public SnsService createService(OAuthConfig config) {
            return new SnsService(this, config);
        }
    }
    
    public class WechatApi extends SnsApi {
        public static WechatApi instance() {
            return WechatApi.InstanceHolder.INSTANCE;
        }
    
        public Verb getAccessTokenVerb() {
            return Verb.POST;
        }
    
        public String getAccessTokenEndpoint() {
            return "https://api.weixin.qq.com/sns/oauth2/access_token";
        }
    
        public String getAuthorizationUrl(OAuthConfig config) {
            StringBuilder sb = new StringBuilder(String.format("https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code", new Object[]{config.getApiKey(), OAuthEncoder.encode(config.getCallback())}));
    
            if (config.hasScope()) {
                sb.append('&').append("scope").append('=').append(OAuthEncoder.encode(config.getScope()));
            }
    
            String state = config.getState();
            if (state != null) {
                sb.append('&').append("state").append('=').append(OAuthEncoder.encode(state));
            }
    
            return sb.toString();
        }
    
        private static class InstanceHolder {
            private static final WechatApi INSTANCE = new WechatApi();
        }
    }
    
    ////////////////////////////////////////////////////////
    import com.github.scribejava.core.model.OAuthConfig;
    import com.github.scribejava.core.model.Verb;
    import com.github.scribejava.core.utils.OAuthEncoder;
    import com.github.scribejava.core.utils.Preconditions;
    
    public class LineApi extends SnsApi {
        public static LineApi instance() {
            return LineApi.InstanceHolder.INSTANCE;
        }
    
        public Verb getAccessTokenVerb() {
            return Verb.POST;
        }
    
        public String getAccessTokenEndpoint() {
            return "https://api.line.me/v1/oauth/accessToken";
        }
    
        public String getRefreshTokenEndpoint() {
            throw new UnsupportedOperationException("Line doesn\'t support refershing tokens");
        }
    
        public String getAuthorizationUrl(OAuthConfig config) {
            Preconditions.checkValidUrl(config.getCallback(), "Must provide a valid url as callback. Kakao does not support OOB");
    
            StringBuilder sb = new StringBuilder(String.format("https://access.line.me/dialog/oauth/weblogin?client_id=%s&redirect_uri=%s&response_type=code", new Object[]{config.getApiKey(), OAuthEncoder.encode(config.getCallback())}));
    
            if (config.hasScope()) {
                sb.append('&').append("scope").append('=').append(OAuthEncoder.encode(config.getScope()));
            }
    
            String state = config.getState();
            if (state != null) {
                sb.append('&').append("state").append('=').append(OAuthEncoder.encode(state));
            }
    
            return sb.toString();
        }
    
        private static class InstanceHolder {
            private static final LineApi INSTANCE = new LineApi();
    
            private InstanceHolder() {
            }
        }
    }
    
    ////////////////////////////////////////////////////////
    import com.github.scribejava.core.model.OAuthConfig;
    import com.github.scribejava.core.model.Verb;
    import com.github.scribejava.core.utils.OAuthEncoder;
    
    public class KakaoApi extends SnsApi {
        public static KakaoApi instance() {
            return KakaoApi.InstanceHolder.INSTANCE;
        }
    
        public Verb getAccessTokenVerb() {
            return Verb.POST;
        }
    
        public String getAccessTokenEndpoint() {
            return "https://kauth.kakao.com/oauth/token";
        }
    
        public String getAuthorizationUrl(OAuthConfig config) {
            StringBuilder sb = new StringBuilder(String.format("https://kauth.kakao.com/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code", new Object[]{config.getApiKey(), OAuthEncoder.encode(config.getCallback())}));
    
            if (config.hasScope()) {
                sb.append('&').append("scope").append('=').append(OAuthEncoder.encode(config.getScope()));
            }
    
            String state = config.getState();
            if (state != null) {
                sb.append('&').append("state").append('=').append(OAuthEncoder.encode(state));
            }
    
            return sb.toString();
        }
    
        private static class InstanceHolder {
            private static final KakaoApi INSTANCE = new KakaoApi();
        }
    }
    
    ////////////////////////////////////////////////////////
    import com.github.scribejava.core.model.OAuthConfig;
    import com.github.scribejava.core.model.Verb;
    import com.github.scribejava.core.utils.OAuthEncoder;
    
    public class WeiboApi extends SnsApi {
        protected WeiboApi() {
        }
    
        public static WeiboApi instance() {
            return WeiboApi.InstanceHolder.INSTANCE;
        }
    
        public Verb getAccessTokenVerb() {
            return Verb.POST;
        }
    
        public String getAccessTokenEndpoint() {
            return "https://api.weibo.com/oauth2/access_token";
        }
    
        public String getAuthorizationUrl(OAuthConfig config) {
            StringBuilder sb = new StringBuilder(String.format("https://api.weibo.com/oauth2/authorize?client_id=%s&redirect_uri=%s&response_type=code", new Object[]{config.getApiKey(), OAuthEncoder.encode(config.getCallback())}));
    
            if (config.hasScope()) {
                sb.append('&').append("scope").append('=').append(OAuthEncoder.encode(config.getScope()));
            }
    
            String state = config.getState();
            if (state != null) {
                sb.append('&').append("state").append('=').append(OAuthEncoder.encode(state));
            }
    
            return sb.toString();
        }
    
        private static class InstanceHolder {
            private static final WeiboApi INSTANCE = new WeiboApi();
        }
    }
    
  1. 안녕하세요 우선 올려주신 내용 잘보았습니다 감사합니다.
    다름이 아니라 제가 인스타그램을 이용한 로그인을 스프링으로 구현하려고하는데
    토니아빠님이 올려주신 코드를 보아도 잘 이해안가는 부분이 있어서 질문남깁니다.
    서비스 팩토리 부분에서
    .build(InstagramApi.instance());
    로 해당 함수를 호출하는 부분이있는데 어디서 해당함수를 호출하는지 잘모르겠네요 ㅠ
    코드를 일단 제가 만들고있던 자바단에 붙여서 쓰고있는데 제가 이해를 못해서 적용이 안되서그런데 혹시 전체소스를 메일로 보내주실수있나요?

    좋아하기

    응답

  2. 안녕하세요 글보고 따라하는중에 반환형이 SnsService 인 객체들이 많이 나오는데 그클래스에 관한 내용을 못찾겠습니다 SnsService는 어떤 클래스인가요?

    좋아하기

    응답

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중

%d 블로거가 이것을 좋아합니다: