Category Archives: JAVA

Server to Server 어플리케이션을 위한 Google OAuth 2.0

google_developers_logo

Google OAuth 2.0 시스템은 웹 어플리케이션과 구글 서비스간의 통신과 같은 서버간 통신을 지원합니다. 이러한 시나리오를 위해서는 개별 최종 사용자 대신에 당신의 어플리케이션에 속하는 서비스 계정(Service Account)가 필요합니다. 당신의 어플리케이션은 서비스 계정을 이용하여 구글 API를 호출할 수 있으며 이 경우 사용자가 직접적으로 연관되지 않습니다. 이 시나리오는 “Two-Legged OAuth” 또는 “2LO”로 불립니다. (비슷한 용어로 “Three-Legged OAuth”의 경우에는 최종 사용자가 구글 API를 호출하는 경우를 말하며 이때에 권한 요청 화면이 뜰 수 있습니다)

일반적으로, 어플리케이션이 구글 API를 호출할 때 사용자의 정보보다는 어플리케이션 자체의 정보를 가지고 작업할 때 서비스 계정을 사용합니다. 예를 들어 데이터를 영구적으로 저장하기 위해 Google Cloud Datastore를 사용하는 어플리케이션이 있다면 서비스 계정을 사용하여 인증하여 사용할 수 있습니다.

Google Apps의 도에인 관리자를 이용하면 도메인의 유저들을 대표하여 사용자 정보에 접근할 수 있는 전체 도메인 권한을 부여할 수 있습니다. 이 문서에서는 어플리케이션이 Google APIs 클라이언트 라이브러리 또는 HTTP를 이용하여 OAuth 2.0 서버간(Server to Server) 호출을 하는 방법에 대해 정리합니다.

개요

서버간 상호작용을 지원하기 위해서 먼저 개발자 콘솔에 있는 당신의 프로젝트의 서비스 계정을 만들어야 합니다. 만약 당신이 Google Apps 도메인 내의 사용자 정보에 접근하고자 한다면 도메인 전체(domain-wide) 접근 권한을 서비스 어카운트에 부여해야 합니다.

그다음 당신의 어플리케이션이 OAuth 2.0 인증 서버로부터 엑세스토큰을 요청하기 위해  서비스 계정 자격을 이용하여 인증 API 요청을 만듭니다.

마지막으로  당신의 어플리케이션은 구글 API를 호출할 수 있는 엑세스토큰을 사용할 수 있게 됩니다.

추천 : 당신의 어플리케이션은 이러한 작업을 당신이 사용하는 언어에 적절한 구글 API 클라이언트 라이브러리를 사용하거나 HTTP를 직접 이용하여 OAuth 2.0 시스템과 통신하는 방법을 사용할 수 있습니다. 하지만 서버간 통신을 통한 인증 과정에서 암호화 사인이 된 JSON Web Tokens (JWTs)를 만들어 사용하게 되고 이 부분에서 당신의 어플리케이션의 보안에 치명적인 영향을 끼칠 수 있는 심각한 에러가 발생하기 쉽습니다.

이런 이유로 우리는 구글 API 클라이언트 라이브러리를 사용하여 당신의 어플리케이션 코드에서 암호화 관련 처리를 신경쓰지 않기를 강력하게 추천합니다.

서비스 계정 만들기

서비스 계정 자격은 유니크하게 생성된 이메일주소(email address)와 최소한 한개의 공개/비밀 키쌍(public/private key pair)이 포함됩니다. 만약에 도메인 전체 위임을 활성화 한다면 클라이언트 아이디(client id) 역시 서비스 계정 자격에 포함되어야 합니다.

당신의 어플리케이션이 구글 앱 엔진(Google App Engine)에서 돌아간다면 서비스 계정은 당신의 프로젝트가 생성될 때 자동으로 설정됩니다.

만약 당신의 어플리케이션이 구글 컴퓨트 엔진(Google Compute Engine)에서 돌아간다면 프로젝트 생성시 서비스 계정은 자동으로 설정되지만 어플리케이션이 접근하고자 하는 접근 범위(Scope)를 정의해 주어야 합니다. [참고]

당신의 어플리케이션이 구글 앱엔진 또는 구글 컴퓨트엔진 에서 동작하지 않는다면 구글 개발자 콘솔에서 이러한 자격을 획득하여야 합니다. 서비스 계정 자격을 생성하기 위해서, 혹은 이미 생성된 공개된 자격을 보기 위해서는 다음을 수행합니다.

  1. 개발자 콘솔 권한(Permission) 페이지의 서비스 계정 섹션(Service accounts section)에 진입합니다.

google_api_oauth_01

2. 서비스 계정 만들기 버튼을 누릅니다.

google_api_oauth_02

3. 서비스 계정 만들기 창에서 서비스 계정의 이름을 입력하고, 새 비공개 키 제공을 선택합니다. 만약 구글 앱스 도메인 전체 인증을 허용하려면 Google Apps 도메인 전체 위임 사용을 체크하시면 됩니다. 그리고 만들기를 클릭합니다.

google_api_oauth_03

당신의 새로운 공개/비공개 키 쌍이 생성되며 당신의 컴퓨터에 다운로드 됩니다. 이것은 단 하나의 복사본이기에 당신은 안전하게 이 키를 보관할 의무가 있습니다.

당신은 언제든지 이 개발자 콘솔에 돌아와서 이메일 주소, 키 ID와 같은 정보를 확인할 수 있습니다. 또는 추가로 공개/비공개 키를 생성할 수 있습니다. 개발자 콘솔에서의 서비스 계정 자격에 대한 더 자세한 내용은 [참고]를 확인해주세요.

서비스 계정의 이메일 주소를 적어놓고 서비스 계정의 P12 비밀키 파일을 당신의 어플리케이션이 접근할 수 있는 위치에 저장하십시오. 당신의 어플리케이션이 인증 API 호출을 만드는데 이것들이 필요합니다.

참고 : 당신은 개발 환경과 프로덕션 환경 모두에서 이 비밀키를 안전하게 보관하고 관리해야 합니다. 구글은 당신의 비밀키의 복사본을 보관하지 않습니다. 단지 공개키만을 가지고 있습니다.

인증 API 호출을 준비하기

구글에서는 구글 API 호출을 위한 라이브러리로 Java를 비롯한 다양한 언어를 지원하고 있습니다. [참고]를 확인해주세요.

구글 개발자 콘솔에서 이메일 주소와 비밀키를 획득한 뒤 JAVA용 구글 API 클라이언트 라이브러리를 이용하여 서비스 계정 자격 및 어플리케이션이 엑세스를 원하는 범위를 정의하는 GoogleCredential을 생성합니다.

import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.services.sqladmin.SQLAdminScopes;

// ...

String emailAddress = "123456789000-abc123def456@developer.gserviceaccount.com";
JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
HttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
GoogleCredential credential = new GoogleCredential.Builder()
    .setTransport(httpTransport)
    .setJsonFactory(JSON_FACTORY)
    .setServiceAccountId(emailAddress)
    .setServiceAccountPrivateKeyFromP12File(new File("MyProject.p12"))
    .setServiceAccountScopes(Collections.singleton(SQLAdminScopes.SQLSERVICE_ADMIN))
    .build();

만약 도메인 전체 위임을 포함한 서비스 어카운트이고 특정 유저로 가장하여 사용하고 싶을 경우 GoogleCredential 팩토리의 setServiceAccountUser 메소드를 사용하여 사용자 계정의 이메일 주소를 특정해 주시기 바랍니다.

GoogleCredential credential = new GoogleCredential.Builder()
    .setTransport(httpTransport)
    .setJsonFactory(JSON_FACTORY)
    .setServiceAccountId(emailAddress)
    .setServiceAccountPrivateKeyFromP12File(new File("MyProject.p12"))
    .setServiceAccountScopes(Collections.singleton(SQLAdminScopes.SQLSERVICE_ADMIN))
    .setServiceAccountUser("user@example.com")
    .build();

이제 이렇게 만들어진 GoogleCredential 객체를 사용하여 당신의 어플리케이션에서 구글 API를 호출할 수 있습니다.

구글 API 호출

GoogleCredential 객체를 사용하여 구글 API를 다음과 같은 방법으로 호출할 수 있습니다.

1. GoogleCredential 객체를 사용하여 당신이 사용하고자 하는 API의 서비스 객체를 생성합니다.

SQLAdmin sqladmin =
    new SQLAdmin.Builder(httpTransport, JSON_FACTORY, credential).build();

2. 서비스 객체가 제공하는 인터페이스를 사용하여 원하는 API의 요청을 생성합니다. 예를 들어 exciting-example-123 프로젝트의 클라우드 SQL 데이터베이스에 존재하는 리스트를 불러오는 코드는 다음과 같습니다.

SQLAdmin.Instances.List instances =
    sqladmin.instances().list("exciting-example-123").execute();

참고 : Using OAuth 2.0 to Access Google APIs

Spring에서 GCM CCS (XMPP) 서버 구현하기

spring_title

이번에 Spring 프로젝트를 진행하면서 GCM 서버를 CCS 방식으로 구현하여 보았습니다. CCS 방식은 구글 서버와 지속적인 커넥션을 유지하며 비동기 양방향 통신을 하는 XMPP방식의 엔드포인트를 의미합니다. XMPP의 비동기적인 특성에 의해 적은 리소스로 더 많은 메시지를 발송할 수 있으며 양방향 통신을 지원하기 때문에 서버에서 디바이스로 메시지를 보내는것뿐 아니라 반대로 디바이스에서 서버로 응답 메시지를 되돌려 보낼 수도 있습니다. 이 때에 디바이스는 메시지 수신을 위해 연결했던 커넥션을 재활용하므로 배터리를 아낄 수 있습니다. 이렇게 좋은 장점들이 있지만 저는 우선 단방향 통신이면 충분한것 같습니다.

1. gradle에 필요한 라이브러리 추가하기

프로젝트가 gradle 프로젝트일경우 다음과 같이 smack 라이브러리를 dependencies 에 추가합니다. gradle 프로젝트가 아닐경우 적당히 라이브러리를 다운받아 프로젝트에 추가하시면 됩니다.

dependencies {
  ...
  compile "org.igniterealtime.smack:smack-core:4.0.4"
  compile "org.igniterealtime.smack:smack-tcp:4.0.4"
}

2. smack 클라이언트 클래스 작성

smack을 이용하여 구글 서버와 CCS 방식으로 통신하기 위해 클라이언트 클래스의 작성이 필요합니다. 깊게 생각할 것 없이 구글에서 제공하는 코드를 사용합니다.

public class SmackCcsClient {

    private static final Logger logger = Logger.getLogger("SmackCcsClient");

    private static final String GCM_SERVER = "gcm.googleapis.com";
    private static final int GCM_PORT = 5235;

    private static final String GCM_ELEMENT_NAME = "gcm";
    private static final String GCM_NAMESPACE = "google:mobile:data";

    static {

        ProviderManager.addExtensionProvider(GCM_ELEMENT_NAME, GCM_NAMESPACE,
                new PacketExtensionProvider() {
                    @Override
                    public PacketExtension parseExtension(XmlPullParser parser) throws
                            Exception {
                        String json = parser.nextText();
                        return new GcmPacketExtension(json);
                    }
                });
    }

    private XMPPConnection connection;

    /**
     * Indicates whether the connection is in draining state, which means that it
     * will not accept any new downstream messages.
     */
    protected volatile boolean connectionDraining = false;

    /**
     * Sends a downstream message to GCM.
     *
     * @return true if the message has been successfully sent.
     */
    public boolean sendDownstreamMessage(String jsonRequest) throws
            NotConnectedException {
        if (!connectionDraining) {
            send(jsonRequest);
            return true;
        }
        logger.info("Dropping downstream message since the connection is draining");
        return false;
    }

    /**
     * Returns a random message id to uniquely identify a message.
     *
     * <p>Note: This is generated by a pseudo random number generator for
     * illustration purpose, and is not guaranteed to be unique.
     */
    public String nextMessageId() {
        return "m-" + UUID.randomUUID().toString();
    }

    /**
     * Sends a packet with contents provided.
     */
    protected void send(String jsonRequest) throws NotConnectedException {
        Packet request = new GcmPacketExtension(jsonRequest).toPacket();
        connection.sendPacket(request);
    }

    /**
     * Handles an upstream data message from a device application.
     *
     * <p>This sample echo server sends an echo message back to the device.
     * Subclasses should override this method to properly process upstream messages.
     */
    protected void handleUpstreamMessage(Map<String, Object> jsonObject) {
        // PackageName of the application that sent this message.
        String category = (String) jsonObject.get("category");
        String from = (String) jsonObject.get("from");
        @SuppressWarnings("unchecked")
        Map<String, String> payload = (Map<String, String>) jsonObject.get("data");
        payload.put("ECHO", "Application: " + category);

        // Send an ECHO response back
        String echo = createJsonMessage(from, nextMessageId(), payload,
                "echo:CollapseKey", null, false);

        try {
            sendDownstreamMessage(echo);
        } catch (NotConnectedException e) {
            logger.log(Level.WARNING, "Not connected anymore, echo message is not sent", e);
        }
    }

    /**
     * Handles an ACK.
     *
     * <p>Logs a INFO message, but subclasses could override it to
     * properly handle ACKs.
     */
    protected void handleAckReceipt(Map<String, Object> jsonObject) {
        String messageId = (String) jsonObject.get("message_id");
        String from = (String) jsonObject.get("from");
        logger.log(Level.INFO, "handleAckReceipt() from: " + from + ", messageId: " + messageId);
    }

    /**
     * Handles a NACK.
     *
     * <p>Logs a INFO message, but subclasses could override it to
     * properly handle NACKs.
     */
    protected void handleNackReceipt(Map<String, Object> jsonObject) {
        String messageId = (String) jsonObject.get("message_id");
        String from = (String) jsonObject.get("from");
        logger.log(Level.INFO, "handleNackReceipt() from: " + from + ", messageId: " + messageId);
    }

    protected void handleControlMessage(Map<String, Object> jsonObject) {
        logger.log(Level.INFO, "handleControlMessage(): " + jsonObject);
        String controlType = (String) jsonObject.get("control_type");
        if ("CONNECTION_DRAINING".equals(controlType)) {
            connectionDraining = true;
        } else {
            logger.log(Level.INFO, "Unrecognized control type: %s. This could happen if new features are " + "added to the CCS protocol.",
            controlType);
        }
    }

    /**
     * Creates a JSON encoded GCM message.
     *
     * @param to RegistrationId of the target device (Required).
     * @param messageId Unique messageId for which CCS will send an
     *         "ack/nack" (Required).
     * @param payload Message content intended for the application. (Optional).
     * @param collapseKey GCM collapse_key parameter (Optional).
     * @param timeToLive GCM time_to_live parameter (Optional).
     * @param delayWhileIdle GCM delay_while_idle parameter (Optional).
     * @return JSON encoded GCM message.
     */
    public static String createJsonMessage(String to, String messageId,
                                           Map<String, String> payload, String collapseKey, Long timeToLive,
                                           Boolean delayWhileIdle) {
        Map<String, Object> message = new HashMap<String, Object>();
        message.put("to", to);
        if (collapseKey != null) {
            message.put("collapse_key", collapseKey);
        }
        if (timeToLive != null) {
            message.put("time_to_live", timeToLive);
        }
        if (delayWhileIdle != null && delayWhileIdle) {
            message.put("delay_while_idle", true);
        }
        message.put("message_id", messageId);
        message.put("data", payload);
        return JSONValue.toJSONString(message);
    }

    /**
     * Creates a JSON encoded ACK message for an upstream message received
     * from an application.
     *
     * @param to RegistrationId of the device who sent the upstream message.
     * @param messageId messageId of the upstream message to be acknowledged to CCS.
     * @return JSON encoded ack.
     */
    protected static String createJsonAck(String to, String messageId) {
        Map<String, Object> message = new HashMap<String, Object>();
        message.put("message_type", "ack");
        message.put("to", to);
        message.put("message_id", messageId);
        return JSONValue.toJSONString(message);
    }

    /**
     * Connects to GCM Cloud Connection Server using the supplied credentials.
     *
     * @param senderId Your GCM project number
     * @param apiKey API Key of your project
     */
    public void connect(long senderId, String apiKey)
            throws XMPPException, IOException, SmackException {
        ConnectionConfiguration config =
                new ConnectionConfiguration(GCM_SERVER, GCM_PORT);
        config.setSecurityMode(SecurityMode.enabled);
        config.setReconnectionAllowed(true);
        config.setRosterLoadedAtLogin(false);
        config.setSendPresence(false);
        config.setSocketFactory(SSLSocketFactory.getDefault());
        config.setDebuggerEnabled(false);

        connection = new XMPPTCPConnection(config);
        connection.connect();

        connection.addConnectionListener(new LoggingConnectionListener());

        // Handle incoming packets
        connection.addPacketListener(new PacketListener() {

            @Override
            public void processPacket(Packet packet) {
                logger.log(Level.INFO, "Received: " + packet.toXML());
                Message incomingMessage = (Message) packet;
                GcmPacketExtension gcmPacket =
                        (GcmPacketExtension) incomingMessage.
                                getExtension(GCM_NAMESPACE);
                String json = gcmPacket.getJson();
                try {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> jsonObject =
                            (Map<String, Object>) JSONValue.parseWithException(json);

                    // present for "ack"/"nack", null otherwise
                    Object messageType = jsonObject.get("message_type");

                    if (messageType == null) {
                        // Normal upstream data message
                        handleUpstreamMessage(jsonObject);

                        // Send ACK to CCS
                        String messageId = (String) jsonObject.get("message_id");
                        String from = (String) jsonObject.get("from");
                        String ack = createJsonAck(from, messageId);
                        send(ack);
                    } else if ("ack".equals(messageType.toString())) {
                        // Process Ack
                        handleAckReceipt(jsonObject);
                    } else if ("nack".equals(messageType.toString())) {
                        // Process Nack
                        handleNackReceipt(jsonObject);
                    } else if ("control".equals(messageType.toString())) {
                        // Process control message
                        handleControlMessage(jsonObject);
                    } else {
                        logger.log(Level.WARNING,
                                "Unrecognized message type (%s)",
                                messageType.toString());
                    }
                } catch (ParseException e) {
                    logger.log(Level.SEVERE, "Error parsing JSON " + json, e);
                } catch (Exception e) {
                    logger.log(Level.SEVERE, "Failed to process packet", e);
                }
            }
        }, new PacketTypeFilter(Message.class));

        // Log all outgoing packets
        connection.addPacketInterceptor(new PacketInterceptor() {
            @Override
            public void interceptPacket(Packet packet) {
                logger.log(Level.INFO, "Sent: {0}", packet.toXML());
            }
        }, new PacketTypeFilter(Message.class));

        connection.login(senderId + "@gcm.googleapis.com", apiKey);
    }

    /**
     * XMPP Packet Extension for GCM Cloud Connection Server.
     */
    private static final class GcmPacketExtension extends DefaultPacketExtension {

        private final String json;

        public GcmPacketExtension(String json) {
            super(GCM_ELEMENT_NAME, GCM_NAMESPACE);
            this.json = json;
        }

        public String getJson() {
            return json;
        }

        @Override
        public String toXML() {
            return String.format("<%s xmlns=\"%s\">%s</%s>",
                    GCM_ELEMENT_NAME, GCM_NAMESPACE,
                    StringUtils.escapeForXML(json), GCM_ELEMENT_NAME);
        }

        public Packet toPacket() {
            Message message = new Message();
            message.addExtension(this);
            return message;
        }
    }

    private static final class LoggingConnectionListener
            implements ConnectionListener {

        @Override
        public void connected(XMPPConnection xmppConnection) {
            logger.info("Connected.");
        }

        @Override
        public void authenticated(XMPPConnection xmppConnection) {
            logger.info("Authenticated.");
        }

        @Override
        public void reconnectionSuccessful() {
            logger.info("Reconnecting..");
        }

        @Override
        public void reconnectionFailed(Exception e) {
            logger.log(Level.INFO, "Reconnection failed.. ", e);
        }

        @Override
        public void reconnectingIn(int seconds) {
            logger.log(Level.INFO, "Reconnecting in %d secs", seconds);
        }

        @Override
        public void connectionClosedOnError(Exception e) {
            logger.info("Connection closed on error.");
        }

        @Override
        public void connectionClosed() {
            logger.info("Connection closed.");
        }
    }
}

3. Spring Sender 컴포넌트 작성

위에서 작성한 SmackCssClient는 한번 초기화 되면 구글 서버와 연결을 맺고 커넥션을 지속합니다. 이 하나의 커넥션으로 다수의 메시지를 보낼 수 있으며 외부의 요인에 의해 연결이 끊어질 경우 자동으로 다시 재연결을 하게 됩니다. 그러므로 Spring에서 컴포넌트로 등록하여 이 객체(빈)의 관리를 컨테이너에 맞기고 생성 직후 커넥션을 맺고 비동기 매소드인 send를 통해 딜레이 없이 메시지를 발송할 수 있도록 하였습니다. (@Async가 사용가능하도록 구현되어있어야 합니다)

페이로드를 만드는 부분에서 키로 msg를 사용하는데 그것은 현재의 프로젝트에 맞춰 적절히 구현하시면 됩니다.

@Component
public class GcmCcsSender {

    private static final Logger LOG = LoggerFactory.getLogger(GcmCcsSender.class);

    private static final long senderId = {SENDER_ID};
    private static final String password = "{PASSWORD}";
    private SmackCcsClient mCssClient = new SmackCcsClient();

    @PostConstruct
    public void init() {
        try {
            mCssClient.connect(senderId, password);
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
        }
    }

    @Async
    public void send(String registrationId, String message) {
        try {
            Map<String, String> payload = new HashMap<String, String>();
            payload.put("msg", message);

            String jsonMessage = mCssClient.createJsonMessage(registrationId, mCssClient.nextMessageId(), payload, "", 10000L, true);
            mCssClient.sendDownstreamMessage(jsonMessage);
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
        }
    }
}

 4. 메시지 발송하기

다음은 메시지 발송을 위한 단순한 예시입니다. 적당한 방법으로 서버에서 수집한 사용자들의 registrationId를 이용하여 메시지를 발송하시면 됩니다.

@RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private GcmCcsSender mGcmCcsSender;

    @RequestMapping(value = "/{registrationId}", method = RequestMethod.GET)
    public void monsters(@PathVariable("registrationId") String registrationId) {
        String message = "TEST MESSAGE";

        mGcmCcsSender.send(registrationId, message);
    }
}

참고 : http://developer.android.com/google/gcm/ccs.html