/** * Copyright 2014 Daum Kakao Corp. * * Redistribution and modification in source or binary forms are not permitted without specific prior written permission.  * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kakao; import java.util.ArrayList; import java.util.List; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; import android.os.Handler; import android.os.Looper; import android.webkit.CookieManager; import android.webkit.CookieSyncManager; import com.kakao.authorization.AuthorizationResult; import com.kakao.authorization.accesstoken.AccessToken; import com.kakao.authorization.accesstoken.AccessTokenRequest; import com.kakao.authorization.authcode.AuthorizationCode; import com.kakao.authorization.authcode.AuthorizationCodeRequest; import com.kakao.exception.KakaoException; import com.kakao.exception.KakaoException.ERROR_TYPE; import com.kakao.helper.CommonProtocol; import com.kakao.helper.Logger; import com.kakao.helper.SharedPreferencesCache; import com.kakao.helper.Utility; /** * 로그인 상태를 유지 시켜주는 객체로 access token을 관리한다. * @author MJ */ public class Session { private static Session currentSession; private static final String REDIRECT_URL_PREFIX = "kakao"; private static final String REDIRECT_URL_POSTFIX = "://oauth"; private static final String COOKIE_SEPERATOR = ";"; private static final String COOKIE_NAME_VALUE_DELIMITER = "="; private final Context context; private final String appKey; private final String redirectUri; private final AuthType[] authTypes; private final SharedPreferencesCache appCache; private final List<SessionCallback> sessionCallbacks; private final Handler sessionCallbackHandler; private final Object INSTANCE_LOCK = new Object(); // 아래 값들은 변경되는 값으로 INSTANCE_LOCK의 보호를 받는다. private SessionState state; // close시 삭제 private RequestType requestType; private AuthorizationCode authorizationCode; private AccessToken accessToken; /** * 세션이 존재하지 않으면 세션을 생성하고, 기존에 존재하는데 만료되었으면 갱신을 시도한다. * 처음 세션을 접근할 때 사용한다. <br/> * opened 상태 : 다음 acitivity로 <br/> * closed 상태 : 사용자 action을 받아 open 시도 <br/> * opening 상태 : 토큰 갱신 시도 <br/> * @param context 세션을 접근하는 context. 여기로 부터 app key와 redirect uri를 구해온다. * @param sessionCallback 토큰 갱신이 필요할 때 갱신의 결과를 받는 콜백 * @param authTypes 로그인시 인증받을 타입을 지정한다. 지정하지 않을 시 가능한 모든 옵션이 지정된다. 예시) AuthType.KAKAO_TALK */ public static synchronized boolean initializeSession(final Context context, final SessionCallback sessionCallback, final AuthType... authTypes) { if (currentSession == null) { currentSession = new Session(context, authTypes); } return currentSession.implicitOpen(sessionCallback); } /** * 토큰 갱신이 가능한지 여부를 반환한다. * 토큰 갱신은 background로 사용자가 모르도록 진행한다. * @param sessionCallback 세션의 변경되었을 때 받게되는 콜백 * @return 토큰 갱신을 진행할 때는 true, 토큰 갱신을 하지 못할때는 false를 return 한다. */ public boolean implicitOpen(final SessionCallback sessionCallback) { if (currentSession.isOpening() && currentSession.accessToken.hasRefreshToken()) { internalOpen(sessionCallback, null); return true; } else { addCallback(sessionCallback); return false; } } /** * 현재 세션을 반환한다. * @return 현재 세션 객체 */ public static synchronized Session getCurrentSession() { if(currentSession == null) throw new IllegalStateException("Session is not initialized. Use Session#initializeSession(Context ,SessionCallback) in login process."); return currentSession; } /** * 세션 오픈을 진행한다. <br/> * {@link com.kakao.Session.SessionState#OPENED} 상태이면 바로 종료. <br/> * {@link com.kakao.Session.SessionState#CLOSED} 상태이면 authorization code 요청. 에러/취소시 {@link com.kakao.Session.SessionState#CLOSED} <br/> * {@link com.kakao.Session.SessionState#OPENING} 상태이면 code 또는 refresh token 이용하여 access token 을 받아온다. 에러/취소시 {@link com.kakao.Session.SessionState#CLOSED}, refresh 취소시에만 {@link com.kakao.Session.SessionState#OPENING} 유지. <br/> * param으로 받은 콜백으로 그 결과를 전달한다. <br/> * @param authType 인증받을 타입. 예를 들어, 카카오톡 또는 카카오스토리 또는 직접 입력한 카카오계정 */ public void open(final AuthType authType) { internalOpen(null, authType); } /** * 명시적 강제 close(로그아웃/탈퇴). request중 인 것들은 모두 실패를 받게 된다. * token을 삭제하기 때문에 authorization code부터(로그인 버튼) 다시 받아서 세션을 open 해야한다. * @param sessionCallback close 결과를 받고자 하는 callback */ public void close(final SessionCallback sessionCallback) { if(sessionCallback != null) addCallback(sessionCallback); internalClose(null, true); } public AuthType[] getAuthTypes() { return authTypes; } /** * 현재 세션이 가지고 있는 access token이 유효한지를 검사후 세션의 상태를 반환한다. * 만료되었다면 opened 상태가 아닌 opening상태가 반환된다. * @return 세션의 상태 */ public final SessionState checkState() { synchronized (INSTANCE_LOCK) { if(state.isOpened() && !accessToken.hasValidAccessToken()){ synchronized (INSTANCE_LOCK) { state = SessionState.OPENING; requestType = null; authorizationCode = AuthorizationCode.createEmptyCode(); } } return state; } } /** * 현재 세션의 상태 * @return 세션의 상태 */ public SessionState getState() { synchronized (INSTANCE_LOCK) { return state; } } /** * 현재 세션이 열린 상태인지 여부를 반환한다. * @return 세션이 열린 상태라면 true, 그외의 경우 false를 반환한다. */ public final boolean isOpened() { final SessionState state = checkState(); return state == SessionState.OPENED; } private boolean isOpening() { final SessionState state = checkState(); return state == SessionState.OPENING; } /** * 현재 세션이 닫힌 상태인지 여부를 반환한다. * @return 세션이 닫힌 상태라면 true, 그외의 경우 false를 반환한다. */ public final boolean isClosed() { final SessionState state = checkState(); return state == SessionState.CLOSED; } /** * 현재 진행 중인 요청 타입 * @return 현재 진행 중인 요청 타입 */ public final RequestType getRequestType() { synchronized (INSTANCE_LOCK) { return requestType; } } /** * 현재 세션이 가지고 있는 access token을 반환한다. * @return access token */ public final String getAccessToken() { synchronized (INSTANCE_LOCK) { return (accessToken == null) ? null : accessToken.getAccessTokenString(); } } /** * 앱 캐시를 반환한다. * @return 앱 캐시 */ public static SharedPreferencesCache getAppCache() { final Session session = Session.getCurrentSession(); return session.appCache; } /** * authorization code 결과를 받아 처리한다. (authcode 저장, state 변경, accesstoken요청) */ public void onAuthCodeCompleted(final AuthorizationResult result) { AuthorizationCode authCode = null; KakaoException exception = null; if(getRequestType() == null){ exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "session is closed during requesting authorization code. result will be ignored. state = " + getState()); } else if (result == null) { exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "the result of authorization code request is null."); } else { final String resultRedirectURL = result.getRedirectURL(); if(result.isSuccess()){ // 기대 했던 redirect uri 일치 if (resultRedirectURL != null && resultRedirectURL.startsWith(redirectUri)) { authCode = AuthorizationCode.createFromRedirectedUri(result.getRedirectUri()); // authorization code가 포함되지 않음 if (!authCode.hasAuthorizationCode()) { authCode = null; exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "the result of authorization code request does not have authorization code."); } // 기대 했던 redirect uri 불일치 } else { exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "the result of authorization code request mismatched the registered redirect uri. msg = " + result.getResultMessage()); } } else if (result.isCanceled()) { exception = new KakaoException(ERROR_TYPE.CANCELED_OPERATION, result.getResultMessage()); } else { exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, result.getResultMessage()); } } synchronized (INSTANCE_LOCK) { final SessionState previousState = state; if (authCode != null) { this.authorizationCode = authCode; state = SessionState.OPENING; // log만 남기고 callback은 호출되지 않는다. onStateChange(previousState, state, requestType, null, false); // request가 성공적으로 끝났으니 request는 reset requestType = null; } else { internalClose(exception, false); return; } } // request AccessToken open(null); } /** * access token 결과를 받아 처리한다. (access token 저장, state 변경) */ public void onAccessTokenCompleted(final AuthorizationResult result) { AccessToken resultAccessToken = null; KakaoException exception = null; if(getRequestType() == null){ exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "session is closed during requesting access token. result will be ignored. state = " + getState()); } else if (result == null) { exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "the result of access token request is null."); } else { if (result.isSuccess()) { resultAccessToken = result.getAccessToken(); if (!resultAccessToken.hasValidAccessToken()) { resultAccessToken = null; exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "the result of access token request is invalid access token."); } } else if (result.isCanceled()) { exception = new KakaoException(ERROR_TYPE.CANCELED_OPERATION, result.getResultMessage()); } else { exception = new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, result.getResultMessage()); } } synchronized (INSTANCE_LOCK) { final SessionState previousState = state; if (resultAccessToken != null) { // refresh 요청에는 refresh token이 내려오지 않을 수 있으므로 accessToken = resultAccessToken을 하면 안된다. accessToken.updateAccessToken(resultAccessToken); //authorization code는 한번 밖에 사용하지 못한다. authorizationCode = AuthorizationCode.createEmptyCode(); saveTokenToCache(accessToken); state = SessionState.OPENED; onStateChange(previousState, state, requestType, null, false); requestType = null; } else { // refresh token으로 요청했는데 취소를 한 경우는 다음에 다시 refresh token을 사용할 수 있으므로 close시키진 않는다. if(!(getRequestType().isRefreshingTokenRequest() && exception.isCancledOperation())){ internalClose(exception, false); } } } } private Session(final Context context, final AuthType... authTypes){ if(context == null) throw new KakaoException(ERROR_TYPE.ILLEGAL_ARGUMENT, "cannot create Session without Context."); this.context = context; this.appKey = Utility.getMetadata(context, CommonProtocol.APP_KEY_PROPERTY); if(appKey == null) { throw new KakaoException(ERROR_TYPE.MISS_CONFIGURATION, String.format("need to declare %s in your AndroidManifest.xml", CommonProtocol.APP_KEY_PROPERTY)); } this.redirectUri = REDIRECT_URL_PREFIX + this.appKey + REDIRECT_URL_POSTFIX; if(authTypes == null || authTypes.length == 0) { this.authTypes = AuthType.values(); } else { this.authTypes = authTypes; } this.appCache = new SharedPreferencesCache(context, appKey); this.sessionCallbacks = new ArrayList<SessionCallback>(); this.sessionCallbackHandler = new Handler(Looper.getMainLooper()); //세션 callback은 main thread에서 호출되도록 한다. appCache.reloadAll(); synchronized (INSTANCE_LOCK) { authorizationCode = AuthorizationCode.createEmptyCode(); accessToken = AccessToken.createFromCache(appCache); if (accessToken.hasValidAccessToken()) { this.state = SessionState.OPENED; } else if (accessToken.hasRefreshToken()) { this.state = SessionState.OPENING; } else { this.state = SessionState.CLOSED; internalClose(null, false); } } } /** * @param sessionCallback initialize시 설정한 sessionCallback을 그대로 사용하려고 할때는 지정하지 않을 수 있다. * @param authType CLOSED 상태에서 authcode를 받아야 하는 시점에 필요. */ private void internalOpen(final SessionCallback sessionCallback, final AuthType authType) { // 이미 open이 되어 있다. if (getState().isOpened()) { return; } addCallback(sessionCallback); //끝나지 않은 request가 있다. if(getRequestType() != null){ Logger.getInstance().d(getRequestType() + " is still doing."); return; } try { checkLoginActivity(); synchronized (INSTANCE_LOCK){ switch (state) { case CLOSED: if (appKey != null && redirectUri != null) { this.requestType = RequestType.GETTING_AUTHORIZATION_CODE; final AuthorizationCodeRequest authorizationCodeRequest = AuthorizationCodeRequest.createNewRequest(appKey, redirectUri); requestLogin(getLoginActivityIntent(authorizationCodeRequest, authType)); } else { internalClose(new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "can not request authorization code because appKey or redirectUri is invalid."), false); } break; case OPENING: if(accessToken.hasRefreshToken()){ this.requestType = RequestType.REFRESHING_ACCESS_TOKEN; final AccessTokenRequest accessTokenRequest = AccessTokenRequest.createRequestWithRefreshToken(context, appKey, redirectUri, accessToken.getRefreshTokenString()); requestLogin(getLoginActivityIntent(accessTokenRequest)); } else if(authorizationCode.hasAuthorizationCode()){ this.requestType = RequestType.GETTING_ACCESS_TOKEN; final AccessTokenRequest accessTokenRequest = AccessTokenRequest.createRequestWithAuthorizationCode(context, appKey, redirectUri, authorizationCode.getAuthorizationCode()); requestLogin(getLoginActivityIntent(accessTokenRequest)); } else { internalClose(new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "can not request access token because both authorization code and refresh token are invalid."), false); } break; default: throw new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "current session state is not possible to open. state = " + state); } } } catch (KakaoException e) { internalClose(e, false); } } /** * @param callback 추가할 세션 콜백 */ private void addCallback(final SessionCallback callback) { synchronized (sessionCallbacks) { if (callback != null && !sessionCallbacks.contains(callback)) { sessionCallbacks.add(callback); } } } private void removeCallbacks(final List<SessionCallback> sessionCallbacksToBeRemoved) { synchronized (sessionCallbacks) { sessionCallbacks.removeAll(sessionCallbacksToBeRemoved); } } private void checkLoginActivity() throws KakaoException { Intent intent = new Intent(); intent.setClass(context, LoginActivity.class); if (!resolveIntent(intent)) { throw new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, String.format("need to declare %s as an activity in your AndroidManifest.xml", LoginActivity.class.getName())); } } private boolean resolveIntent(final Intent intent) { final ResolveInfo resolveInfo = Utility.resolveIntent(context, intent); return resolveInfo != null; } private Intent getLoginActivityIntent(final AuthorizationCodeRequest authCodeRequest) { return getLoginActivityIntent(authCodeRequest, AuthType.KAKAO_TALK); } private Intent getLoginActivityIntent(final AuthorizationCodeRequest authCodeRequest, final AuthType authType) { final Intent intent = new Intent(context, LoginActivity.class); intent.putExtra(LoginActivity.CODE_REQUEST_KEY, authCodeRequest); intent.putExtra(LoginActivity.CODE_REQUEST_TYPE_KEY, authType.getNumber()); return intent; } private Intent getLoginActivityIntent(final AccessTokenRequest accessTokenRequest) { final Intent intent = new Intent(context, LoginActivity.class); intent.putExtra(LoginActivity.TOKEN_REQUEST_KEY, accessTokenRequest); return intent; } private void requestLogin(final Intent intent) { boolean found = startLoginActivity(intent); if (!found) { internalClose(new KakaoException(ERROR_TYPE.AUTHORIZATION_FAILED, "failed to find LoginActivity"), false); } } private boolean startLoginActivity(final Intent intent) { try { intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } catch (ActivityNotFoundException e) { return false; } return true; } /** * 세션을 close하여 처음부터 새롭게 세션 open을 진행한다. * @param kakaoException exception이 발생하여 close하는 경우 해당 exception을 넘긴다. * @param forced 강제 close 여부. 강제 close이면 이미 close가 되었어도 callback을 호출한다. */ private void internalClose(final KakaoException kakaoException, final boolean forced) { synchronized (INSTANCE_LOCK) { final SessionState previous = state; state = SessionState.CLOSED; requestType = null; authorizationCode = AuthorizationCode.createEmptyCode(); accessToken = AccessToken.createEmptyToken(); onStateChange(previous, state, requestType, kakaoException, forced); } if (this.appCache != null) { this.appCache.clearAll(); } removeCookiesForKakaoDomain(context); } private static void removeCookiesForKakaoDomain(final Context context){ removeCookiesForDomain(context, "kakao.com"); removeCookiesForDomain(context, ".kakao.com"); removeCookiesForDomain(context, "https://kakao.com"); removeCookiesForDomain(context, "https://.kakao.com"); removeCookiesForDomain(context, "kakao.co.kr"); removeCookiesForDomain(context, ".kakao.co.kr"); removeCookiesForDomain(context, "https://kakao.co.kr"); removeCookiesForDomain(context, "https://.kakao.co.kr"); } private static void removeCookiesForDomain(final Context context, final String domain) { // CookieManager를 쓰려면 CookieSyncManager를 만들어야 하는 버그가 있다. CookieSyncManager syncManager = CookieSyncManager.createInstance(context); syncManager.sync(); CookieManager cookieManager = CookieManager.getInstance(); // domain으로 쿠키를 삭제하는 API가 제공되지 않으므로 삭제하고 싶은 쿠키를 강제로 expire 시킨다음 removeExpiredCookie 호출한다. String cookieForDomain = cookieManager.getCookie(domain); if (cookieForDomain == null) { return; } String[] cookiesForDomain = cookieForDomain.split(COOKIE_SEPERATOR); for (String currentCookie : cookiesForDomain) { String[] cookieNameAndValue = currentCookie.split(COOKIE_NAME_VALUE_DELIMITER); if (cookieNameAndValue.length > 0) { // value를 reset하고 expire 시각을 강제로 set. String revisedCookie = cookieNameAndValue[0].trim() + "=;expires=Web, 18 Mar 2010 09:00:01 GMT;"; cookieManager.setCookie(domain, revisedCookie); } } cookieManager.removeExpiredCookie(); } private void saveTokenToCache(final AccessToken newToken) { if (newToken != null && appCache != null) { newToken.saveAccessTokenToCache(appCache); } } private void onStateChange(final SessionState previousState, final SessionState newState, final RequestType requestType, final KakaoException exception, boolean forced) { if (!forced && (previousState == newState) && exception == null) { return; } Logger.getInstance().d(String.format("Session State changed : %s -> %s \n ex = %s, request_type = %s",previousState, newState ,(exception != null ? ", ex=" + exception.getMessage() : ""), requestType)); // 사용자에게 opening을 state를 알려줄 필요는 없는듯. if( newState.isOpening()) return; final List<SessionCallback> dumpSessionCallbacks = new ArrayList<SessionCallback>(sessionCallbacks); Runnable runCallbacks = new Runnable() { public void run() { for(SessionCallback callback : dumpSessionCallbacks){ if (newState.isOpened()) callback.onSessionOpened(); else if (newState.isClosed()) callback.onSessionClosed(exception); } removeCallbacks(dumpSessionCallbacks); } }; //세션 callback은 main thread에서 호출되도록 한다. sessionCallbackHandler.post(runCallbacks); } /** * @author MJ */ private static enum SessionState { /** * memory와 cache에 session 정보가 없는 전혀 상태. * 처음 session에 접근할 때 또는 session을 close(예를 들어 로그아웃, 탈퇴)한 상태. * open({@link com.kakao.Session.RequestType#GETTING_AUTHORIZATION_CODE}) : 성공 - {@link #OPENING}, 실패 - 그대로 CLOSED * close(명시적 close) : 그대로 CLOSED */ CLOSED, /** * {@link #CLOSED}상태에서 token을 발급 받기 위해 authorization code를 발급 받아 valid한 authorization code를 가지고 있는 상태. * 또는 토큰이 만료되었으나 refresh token을 가지고 있는 상태. * open({@link com.kakao.Session.RequestType#GETTING_ACCESS_TOKEN} 또는 {@link com.kakao.Session.RequestType#REFRESHING_ACCESS_TOKEN}) : 성공 - {@link #OPENED}, 실패 - {@link #CLOSED} * close(명시적 close) : {@link #CLOSED} */ OPENING, /** * access token을 성공적으로 발급 받아 valid access token을 가지고 있는 상태. * 토크 만료 : {@link #OPENING} * close(명시적 close) : {@link #CLOSED} */ OPENED; private boolean isClosed(){ return this == SessionState.CLOSED; } private boolean isOpening(){ return this == SessionState.OPENING; } private boolean isOpened(){ return this == SessionState.OPENED; } } private enum RequestType { GETTING_AUTHORIZATION_CODE, GETTING_ACCESS_TOKEN, REFRESHING_ACCESS_TOKEN; private boolean isAuthorizationCodeRequest() { return this == RequestType.GETTING_AUTHORIZATION_CODE; } private boolean isAccessTokenRequest() { return this == RequestType.GETTING_ACCESS_TOKEN; } private boolean isRefreshingTokenRequest() { return this == RequestType.REFRESHING_ACCESS_TOKEN; } } }