суббота, 5 мая 2012 г.

Использование OAuth2 авторизации в Android-приложении

Первое, что делает клиент, получив в руки новый Android-девайс - привязывает его к своему google-аккаунту. Эта привязка даёт ему возможность пользоваться всеми сервисами google, а нам, разработчикам - предоставлять ему все эти сервисы с помощью наших приложений. Google даёт нам доступ через API к удивительно богатой инфраструктуре: документы, почта, задачи, поиск, переводчик, распознавание голоса и т.п. И всё это почти бесплатно и очень удобно для клиента с Android в руках. Ключ ко всему этому богатству - OAuth-авторизация. Давайте разберёмся, как ей пользоваться. Наш небольшой пример запросит у клиента доступ к сохранённому на Android-устройстве аккаунту, получит токен, сохранит его для будущего использования, а затем будет полчать список документов в корне его GoogleDoc (или GoogleDrive, как это теперь называется) и отображать полученный список клиенту.

Просим доступы

Добавляем в AndroidManifest.xml запрос доступов на получение списка сохранённых аккаунтов, получение полномочий и доступ к интернет. Соответственно:
android.permission.GET_ACCOUNTS
android.permission.USE_CREDENTIALS
android.permission.INTERNET

Собственно делаем это

Так выглядит код главного Activity нашего приложения:

import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
 
public class AuthDocActivity extends Activity {
 
 private static final String PREF_TOKEN = "token";
 private GAuthHelper gah;
 
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
 
        gah = new GAuthHelper(this);
 
        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
        String authToken = prefs.getString(PREF_TOKEN, "");
 
        if (authToken.length()==0) { // token not found, need authorization
         final String[] accn = gah.getAccNames();
         if (accn.length==0) {
          Toast.makeText(this, "Stored Google accounts not found", Toast.LENGTH_LONG).show();
         } else {
          AlertDialog.Builder builder = new AlertDialog.Builder(this);
    builder.setTitle("Select a Google account");
    builder.setItems(accn, new DialogInterface.OnClickListener() {
     public void onClick(DialogInterface dialog, final int which) {
      saveToken(accn[which]);
     }
    }).create().show();
         }
        } else {     // use stored token
         loadGDocs(authToken);
        }
    }
 
 private void saveToken(String accname) {
  gah.getAuthToken(accname, new GAuthHelper.OAuthCallbackListener() {
   @Override
   public void callback(String authToken) {
    if (authToken==null) {
     Toast.makeText(AuthDocActivity.this, "Operation cancelled", Toast.LENGTH_LONG).show();
    } else {
     PreferenceManager.getDefaultSharedPreferences(AuthDocActivity.this).edit().putString(PREF_TOKEN, authToken).commit();
     loadGDocs(authToken);
    }
   }
  });
 }
 
 private void loadGDocs(final String token) {
  new DocsLoader(this){
   protected void onPostExecute(String[] result) {
    ScrollView sw = new ScrollView(AuthDocActivity.this);
    LinearLayout ll = new LinearLayout(AuthDocActivity.this);
    ll.setOrientation(LinearLayout.VERTICAL);
    if (result!=null) {
     for (String s : result) {
      TextView tw = new TextView(AuthDocActivity.this);
      tw.setText(s);
      ll.addView(tw);
     }
    } else { // token expired: reload
     gah.invalidateToken(token);
     PreferenceManager.getDefaultSharedPreferences(AuthDocActivity.this).edit().putString(PREF_TOKEN, "").commit();
     startActivity(new Intent(AuthDocActivity.this, AuthDocActivity.class));
     finish();
    }
    sw.addView(ll);
    setContentView(sw);
    super.onPostExecute(result);
   };
  }.execute(token);
 }
}

Рассмотрим его внимательнее.
В методе onCreate пытаемся получить сохранённый токен из SharedPreferences. Если он получен, выполняем метод loadGDocs, если нет - получаем список сохранённых на устройстве аккаунтов и даём возможность клиенту выбрать тот аккаунт, от имени которого следует работать дальше. Если аккаунтов нет вообще - ругаемся.

Работаем с аккаунтами

Для работы с аккаунтами у нас есть отдельный класс: GAuthHelper. Вот его код:

import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerCallback;
import android.accounts.AccountManagerFuture;
import android.accounts.OperationCanceledException;
import android.app.Activity;
import android.os.Bundle;
 
public class GAuthHelper {
 
 private AccountManager accountManager;
 private static final String ACC_TYPE = "com.google";
 private static final String SCOPE = "oauth2:https://docs.google.com/feeds/";
 private Activity act;
 
 public GAuthHelper(Activity activity) {
  accountManager = AccountManager.get(activity);
  act = activity;
 }
 
 public Account[] getAccounts() {
  Account[] accounts = accountManager.getAccountsByType(ACC_TYPE);
  return accounts;
 }
 
 public String[] getAccNames() {
  Account[] accounts = getAccounts();
  String[] rez = new String[accounts.length];
  for (int i = 0; i < accounts.length; i++) {
   rez[i] = accounts[i].name;
  }
  return rez;
 }
 
 private Account getAccountByName(String name) {
  Account[] accounts = getAccounts();
  for (int i = 0; i < accounts.length; i++) {
   if (name.equals(accounts[i].name)) return accounts[i];
  }
  return null;
 }
 
 public void invalidateToken(String token) {
  accountManager.invalidateAuthToken(ACC_TYPE, token);
 }
 
 public void getAuthToken(String accname, OAuthCallbackListener authCallbackListener) {
  getAuthToken(getAccountByName(accname), authCallbackListener);
 }
 
 public void getAuthToken(Account account, final OAuthCallbackListener authCallbackListener) {
  accountManager.getAuthToken(account, SCOPE, null, act,
    new AccountManagerCallback<Bundle>() {
     public void run(AccountManagerFuture<Bundle> future) {
      try {
       String token = future.getResult().getString(AccountManager.KEY_AUTHTOKEN);
       authCallbackListener.callback(token);
      } catch (OperationCanceledException e) {
       authCallbackListener.callback(null);
      } catch (Exception e) {
       e.printStackTrace();
      }
     }
    }, null);
 
 }
 
 public static interface OAuthCallbackListener {
  public void callback(String authToken);
 }
}
 
Получив список имён аккаунтов методом getAccNames мы строим окно со списком и по выбору элемента получаем токен (методом getAuthToken) для аккаунта с выбранным именем. В этот метод мы передаём OAuthCalbackListener, реализующий интерфейс, описанный тут же, в GAuthHelper. При получении токена вызываем у listener-а calback, возвращаясь таким образом к методу loadGDocs, описанному в главном Activity. Теперь, когда мы так или иначе имеем токен пришло время разобрать-таки этот метод.

Загрузка данных из GoogleDocs

Метод loadGDocs создаёт и выполняет фоновый процесс, логика которого описана в классе DocsLoader:

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.net.HttpURLConnection;
import java.net.URL;
 
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSession;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
 
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
 
import android.app.Activity;
import android.app.ProgressDialog;
import android.os.AsyncTask;
 
public class DocsLoader extends AsyncTask<String, Integer, String[]>{
 
 private static final String DEFAULT_URL = "https://docs.google.com/feeds/default/private/full";
 private final static String HEADER_PREFIX_OAUTH = "OAuth ";
 
 private ProgressDialog progressDialog;
 private Activity act;
 
 public DocsLoader(Activity act) {
  this.act = act;
 }
 
 @Override
 protected void onPreExecute() {
  progressDialog = new ProgressDialog(act);
  progressDialog.setMessage("Downloading docs list...");
  progressDialog.show();
 }
 
 @Override
 protected String[] doInBackground(String... params) {
  String[] rez = null;
  try {
   String resp = request(DEFAULT_URL, params[0]);
   DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            DocumentBuilder builder = factory.newDocumentBuilder();
            Document doc = builder.parse(new InputSource(new StringReader(resp)));
   NodeList titles = doc.getElementsByTagName("title");
   rez = new String[titles.getLength()];
   for (int i=0; i<titles.getLength(); i++) {
    Node n = titles.item(i);
    rez[i] = n.getTextContent();
   }
  } catch (Exception e) {
   e.printStackTrace();
  }
  return rez;
 }
 
 @Override
 protected void onPostExecute(String[] result) {
  progressDialog.dismiss();
 }
 
 private String request(String url, String token) {
  StringBuilder sb = new StringBuilder();
  try {
   HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
         if (conn instanceof HttpsURLConnection) {
             ((HttpsURLConnection) conn).setHostnameVerifier(new HostnameVerifier() {
 
                 public boolean verify(String hostname, SSLSession session) {
                     return true;
                 }
             });
         }
         conn.setUseCaches(false);
         conn.setDoOutput(true);
         conn.setDoInput(true);
         conn.setRequestProperty("GData-Version", "3.0");
         conn.setRequestProperty("Authorization", HEADER_PREFIX_OAUTH + token);
 
         if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) {
          throw new Exception("http error "+conn.getResponseCode());
         }
 
         BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
         String nextLine = null;
         while ((nextLine = in.readLine()) != null) {
             sb.append(nextLine);
         }
         in.close();
         conn.disconnect();
 
  } catch (Exception e) {
   e.printStackTrace();
  }
  return sb.toString();
 }
}

Этот класс расширяет AsyncTask, а значит реализует метод doInBackground. Тут мы выполняем запрос к API GoogleDocs, передавая два http-header, которые обеспечивают нам авторизацию с помощью полученного токена. Сам запрос описан в методе request. В методе onPreExecute мы запускаем прелоадер, а в onPostExecute останавливаем. Кстати, метод onPostExecute мы переопределяем в главном Activity чтобы построить список файлов по данным, полученным из API.
Собственно, на этом реализацию нашего примера можно завершить. Заметьте, что тут нам не приходиться использовать никаких специальных библиотек для работы с сервисами google, хотя их можно найти в сети достаточно много. Google Data API, которое мы тут используем, представляет собой классическое rest-API, для работы с которым достаточно отправлять обычные GET-запросы и анализировать xml-структуры, получаемые в ответ. Единственная библиотека, которую потребуется подключить к проекту, потребуется в рантайме на этапе получения токена.    

3 комментария:

  1. Доброе утро, пробую ваш код. но на if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) {
    throw new Exception("http error " + conn.getResponseCode());
    }
    Возвращает ошибку 403 в чем может бить пролема?
    Спасибо.

    ОтветитьУдалить
    Ответы
    1. Я попробовал сделать так
      int status = conn.getResponseCode();
      switch (status) {
      case 200:
      case 201:
      BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
      //StringBuilder sb = new StringBuilder();
      String nextLine;
      while ((nextLine = in.readLine()) != null) {
      Log.d("VkDemoApp", "next line "+nextLine );
      sb.append(nextLine);
      Log.d("VkDemoApp", "sb if"+sb.toString());
      }
      in.close();
      conn.disconnect();
      Log.d("VkDemoApp", "sb exit"+sb.toString());
      return sb.toString();
      }
      но прога крашится в mainactivity
      if (result!=null) {
      for (String s : result) {
      TextView tw = new TextView(MainActivity.this);
      tw.setText(s);
      ll.addView(tw);
      }

      Удалить
  2. спасибо, всё работает, вот бы ещё такой же мануал для сервера ))

    ОтветитьУдалить