Permite que el usuario acceda

Esta es la segunda explicación de la serie de instructivos sobre complementos de Classroom.

En esta explicación, agregarás el Acceso con Google a la aplicación web. Este es un comportamiento obligatorio para los complementos de Classroom. Usa las credenciales de este flujo de autorización para todas las llamadas futuras a la API.

En el transcurso de esta explicación, completarás las siguientes tareas:

  • Configura tu app web para que mantenga los datos de la sesión dentro de un iframe.
  • Implementa el flujo de acceso de servidor a servidor de OAuth 2.0 de Google.
  • Realiza una llamada a la API de OAuth 2.0.
  • Crea rutas adicionales para admitir la autorización, la salida y la prueba de llamadas a la API.

Cuando termines, podrás autorizar por completo a los usuarios en tu app web y realizar llamadas a las APIs de Google.

Comprende el flujo de autorización

Las APIs de Google usan el protocolo OAuth 2.0 para la autenticación y la autorización. La descripción completa de la implementación de OAuth de Google está disponible en la Guía de OAuth de Google Identity.

Las credenciales de tu aplicación se administran en Google Cloud. Una vez que se hayan creado, implementa un proceso de cuatro pasos para autenticar y autorizar a un usuario:

  1. Solicitar autorización Proporciona una URL de devolución de llamada como parte de esta solicitud. Cuando termines, recibirás una URL de autorización.
  2. Redirecciona al usuario a la URL de autorización. La página resultante le informa al usuario los permisos que requiere tu app y le solicita que permita el acceso. Cuando se complete, se redireccionará al usuario a la URL de devolución de llamada.
  3. Recibir un código de autorización en tu ruta de devolución de llamada Intercambia el código de autorización por un token de acceso y un token de actualización.
  4. Realiza llamadas a una API de Google con los tokens.

Obtén credenciales de OAuth 2.0

Asegúrate de haber creado y descargado credenciales de OAuth como se describe en la página Descripción general. Tu proyecto debe usar estas credenciales para permitir que el usuario acceda.

Implementa el flujo de autorización

Agrega lógica y rutas a nuestra app web para realizar el flujo descrito, incluidas estas funciones:

  • Inicia el flujo de autorización cuando llegues a la página de destino.
  • Solicita autorización y controla la respuesta del servidor de autorización.
  • Borra las credenciales almacenadas.
  • Revoca los permisos de la app.
  • Probar una llamada a la API

Inicia la autorización

Modifica tu página de destino para iniciar el flujo de autorización si es necesario. El complemento puede estar en dos estados posibles: hay tokens guardados en la sesión actual o necesitas obtener tokens del servidor de OAuth 2.0. Realiza una llamada a la API de prueba si hay tokens en la sesión o, de lo contrario, solicita al usuario que inicie sesión.

Python

Abre el archivo routes.py. Primero, establece algunas constantes y nuestra configuración de cookies según las recomendaciones de seguridad de iframe.

# The file that contains the OAuth 2.0 client_id and client_secret.
CLIENT_SECRETS_FILE = "client_secret.json"

# The OAuth 2.0 access scopes to request.
# These scopes must match the scopes in your Google Cloud project's OAuth Consent
# Screen: https://console.cloud.google.com/apis/credentials/consent
SCOPES = [
    "openid",
    "https://www.googleapis.com/auth/userinfo.profile",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/classroom.addons.teacher",
    "https://www.googleapis.com/auth/classroom.addons.student"
]

# Flask cookie configurations.
app.config.update(
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="None",
)

Ve a la ruta de destino del complemento (es /classroom-addon en el archivo de ejemplo). Agrega lógica para renderizar una página de acceso si la sesión no contiene la clave "credentials".

@app.route("/classroom-addon")
def classroom_addon():
    if "credentials" not in flask.session:
        return flask.render_template("authorization.html")

    return flask.render_template(
        "addon-discovery.html",
        message="You've reached the addon discovery page.")

Java

El código de esta explicación se encuentra en el módulo step_02_sign_in.

Abre el archivo application.properties y agrega la configuración de la sesión que siga las recomendaciones de seguridad de iframe.

# iFrame security recommendations call for cookies to have the HttpOnly and
# secure attribute set
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true

# Ensures that the session is maintained across the iframe and sign-in pop-up.
server.servlet.session.cookie.same-site=none

Crea una clase de servicio (AuthService.java en el módulo step_02_sign_in) para controlar la lógica detrás de los extremos en el archivo del controlador y configurar el URI de redireccionamiento, la ubicación del archivo de secretos de cliente y los permisos que requiere tu complemento. El URI de redireccionamiento se usa para redireccionar a los usuarios a un URI específico después de que autorizan tu app. Consulta la sección Configuración del proyecto de README.md en el código fuente para obtener información sobre dónde colocar el archivo client_secret.json.

@Service
public class AuthService {
    private static final String REDIRECT_URI = "https://localhost:5000/callback";
    private static final String CLIENT_SECRET_FILE = "client_secret.json";
    private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
    private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();

    private static final String[] REQUIRED_SCOPES = {
        "https://www.googleapis.com/auth/userinfo.profile",
        "https://www.googleapis.com/auth/userinfo.email",
        "https://www.googleapis.com/auth/classroom.addons.teacher",
        "https://www.googleapis.com/auth/classroom.addons.student"
    };

    /** Creates and returns a Collection object with all requested scopes.
    *   @return Collection of scopes requested by the application.
    */
    public static Collection<String> getScopes() {
        return new ArrayList<>(Arrays.asList(REQUIRED_SCOPES));
    }
}

Abre el archivo del controlador (AuthController.java en el módulo step_02_sign_in) y agrega lógica a la ruta de destino para renderizar la página de acceso si la sesión no contiene la clave credentials.

@GetMapping(value = {"/start-auth-flow"})
public String startAuthFlow(Model model) {
    try {
        return "authorization";
    } catch (Exception e) {
        return onError(e.getMessage(), model);
    }
}

@GetMapping(value = {"/addon-discovery"})
public String addon_discovery(HttpSession session, Model model) {
    try {
        if (session == null || session.getAttribute("credentials") == null) {
            return startAuthFlow(model);
        }
        return "addon-discovery";
    } catch (Exception e) {
        return onError(e.getMessage(), model);
    }
}

Tu página de autorización debe contener un vínculo o un botón para que el usuario "acceda". Si el usuario hace clic en este vínculo, se lo redireccionará a la ruta authorize.

Solicitar autorización

Para solicitar la autorización, construye y redirecciona al usuario a una URL de autenticación. Esta URL incluye varios datos, como los permisos solicitados, la ruta de destino para después de la autorización y el ID de cliente de la app web. Puedes ver estos campos en esta URL de autorización de ejemplo.

Python

Agrega la siguiente importación a tu archivo routes.py.

import google_auth_oauthlib.flow

Crea una nueva ruta /authorize. Crea una instancia de google_auth_oauthlib.flow.Flow. Te recomendamos que uses el método from_client_secrets_file incluido para hacerlo.

@app.route("/authorize")
def authorize():
    # Create flow instance to manage the OAuth 2.0 Authorization Grant Flow
    # steps.
    flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
        CLIENT_SECRETS_FILE, scopes=SCOPES)

Establece el redirect_uri de flow, que es la ruta a la que deseas que los usuarios regresen después de autorizar tu app. Esta es /callback en el siguiente ejemplo.

# The URI created here must exactly match one of the authorized redirect
# URIs for the OAuth 2.0 client, which you configured in the API Console. If
# this value doesn't match an authorized URI, you will get a
# "redirect_uri_mismatch" error.
flow.redirect_uri = flask.url_for("callback", _external=True)

Usa el objeto de flujo para construir authorization_url y state. Almacena el state en la sesión, que se usa para verificar la autenticidad de la respuesta del servidor más adelante. Por último, redirecciona al usuario a la authorization_url.

authorization_url, state = flow.authorization_url(
    # Enable offline access so that you can refresh an access token without
    # re-prompting the user for permission. Recommended for web server apps.
    access_type="offline",
    # Enable incremental authorization. Recommended as a best practice.
    include_granted_scopes="true")

# Store the state so the callback can verify the auth server response.
flask.session["state"] = state

# Redirect the user to the OAuth authorization URL.
return flask.redirect(authorization_url)

Java

Agrega los siguientes métodos al archivo AuthService.java para crear una instancia del objeto de flujo y, luego, úsalo para recuperar la URL de autorización:

  • El método getClientSecrets() lee el archivo de secreto del cliente y construye un objeto GoogleClientSecrets.
  • El método getFlow() crea una instancia de GoogleAuthorizationCodeFlow.
  • El método authorize() usa el objeto GoogleAuthorizationCodeFlow, el parámetro state y el URI de redireccionamiento para recuperar la URL de autorización. El parámetro state se usa para verificar la autenticidad de la respuesta del servidor de autorización. Luego, el método muestra un mapa con la URL de autorización y el parámetro state.
/** Reads the client secret file downloaded from Google Cloud.
 *   @return GoogleClientSecrets read in from client secret file. */
public GoogleClientSecrets getClientSecrets() throws Exception {
    try {
        InputStream in = SignInApplication.class.getClassLoader()
            .getResourceAsStream(CLIENT_SECRET_FILE);
        if (in == null) {
            throw new FileNotFoundException("Client secret file not found: "
                +   CLIENT_SECRET_FILE);
        }
        GoogleClientSecrets clientSecrets = GoogleClientSecrets
            .load(JSON_FACTORY, new InputStreamReader(in));
        return clientSecrets;
    } catch (Exception e) {
        throw e;
    }
}

/** Builds and returns authorization code flow.
*   @return GoogleAuthorizationCodeFlow object used to retrieve an access
*   token and refresh token for the application.
*   @throws Exception if reading client secrets or building code flow object
*   is unsuccessful.
*/
public GoogleAuthorizationCodeFlow getFlow() throws Exception {
    try {
        GoogleAuthorizationCodeFlow authorizationCodeFlow =
            new GoogleAuthorizationCodeFlow.Builder(
                HTTP_TRANSPORT,
                JSON_FACTORY,
                getClientSecrets(),
                getScopes())
                .setAccessType("offline")
                .build();
        return authorizationCodeFlow;
    } catch (Exception e) {
        throw e;
    }
}

/** Builds and returns a map with the authorization URL, which allows the
*   user to give the app permission to their account, and the state parameter,
*   which is used to prevent cross site request forgery.
*   @return map with authorization URL and state parameter.
*   @throws Exception if building the authorization URL is unsuccessful.
*/
public HashMap authorize() throws Exception {
    HashMap<String, String> authDataMap = new HashMap<>();
    try {
        String state = new BigInteger(130, new SecureRandom()).toString(32);
        authDataMap.put("state", state);

        GoogleAuthorizationCodeFlow flow = getFlow();
        String authUrl = flow
            .newAuthorizationUrl()
            .setState(state)
            .setRedirectUri(REDIRECT_URI)
            .build();
        String url = authUrl;
        authDataMap.put("url", url);

        return authDataMap;
    } catch (Exception e) {
        throw e;
    }
}

Usa la inserción de constructor para crear una instancia de la clase de servicio en la clase del controlador.

/** Declare AuthService to be used in the Controller class constructor. */
private final AuthService authService;

/** AuthController constructor. Uses constructor injection to instantiate
*   the AuthService and UserRepository classes.
*   @param authService the service class that handles the implementation logic
*   of requests.
*/
public AuthController(AuthService authService) {
    this.authService = authService;
}

Agrega el extremo /authorize a la clase del controlador. Este extremo llama al método authorize() de AuthService para recuperar el parámetro state y la URL de autorización. Luego, el extremo almacena el parámetro state en la sesión y redirecciona a los usuarios a la URL de autorización.

/** Redirects the sign-in pop-up to the authorization URL.
*   @param response the current response to pass information to.
*   @param session the current session.
*   @throws Exception if redirection to the authorization URL is unsuccessful.
*/
@GetMapping(value = {"/authorize"})
public void authorize(HttpServletResponse response, HttpSession session)
    throws Exception {
    try {
        HashMap authDataMap = authService.authorize();
        String authUrl = authDataMap.get("url").toString();
        String state = authDataMap.get("state").toString();
        session.setAttribute("state", state);
        response.sendRedirect(authUrl);
    } catch (Exception e) {
        throw e;
    }
}

Controla la respuesta del servidor

Después de autorizar, el usuario regresa a la ruta redirect_uri del paso anterior. En el ejemplo anterior, esta ruta es /callback.

Recibirás un code en la respuesta cuando el usuario regrese de la página de autorización. Luego, intercambia el código por tokens de acceso y actualización:

Python

Agrega las siguientes importaciones a tu archivo del servidor de Flask.

import google.oauth2.credentials
import googleapiclient.discovery

Agrega la ruta a tu servidor. Crea otra instancia de google_auth_oauthlib.flow.Flow, pero esta vez reutiliza el estado guardado en el paso anterior.

@app.route("/callback")
def callback():
    state = flask.session["state"]

    flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
        CLIENT_SECRETS_FILE, scopes=SCOPES, state=state)
    flow.redirect_uri = flask.url_for("callback", _external=True)

A continuación, solicita tokens de acceso y actualización. Por suerte, el objeto flow también contiene el método fetch_token para lograrlo. El método espera los argumentos code o authorization_response. Usa authorization_response, ya que es la URL completa de la solicitud.

authorization_response = flask.request.url
flow.fetch_token(authorization_response=authorization_response)

Ahora tienes las credenciales completas. Almacenarlos en la sesión para que se puedan recuperar en otros métodos o rutas y, luego, redireccionar a una página de destino del complemento

credentials = flow.credentials
flask.session["credentials"] = {
    "token": credentials.token,
    "refresh_token": credentials.refresh_token,
    "token_uri": credentials.token_uri,
    "client_id": credentials.client_id,
    "client_secret": credentials.client_secret,
    "scopes": credentials.scopes
}

# Close the pop-up by rendering an HTML page with a script that redirects
# the owner and closes itself. This can be done with a bit of JavaScript:
# <script>
#     window.opener.location.href = "{{ url_for('classroom_addon') }}";
#     window.close();
# </script>
return flask.render_template("close-me.html")

Java

Agrega un método a tu clase de servicio que devuelva el objeto Credentials pasando el código de autorización recuperado del redireccionamiento que realiza la URL de autorización. Este objeto Credentials se usará más adelante para recuperar el token de acceso y el token de actualización.

/** Returns the required credentials to access Google APIs.
*   @param authorizationCode the authorization code provided by the
*   authorization URL that's used to obtain credentials.
*   @return the credentials that were retrieved from the authorization flow.
*   @throws Exception if retrieving credentials is unsuccessful.
*/
public Credential getAndSaveCredentials(String authorizationCode) throws Exception {
    try {
        GoogleAuthorizationCodeFlow flow = getFlow();
        GoogleClientSecrets googleClientSecrets = getClientSecrets();
        TokenResponse tokenResponse = flow.newTokenRequest(authorizationCode)
            .setClientAuthentication(new ClientParametersAuthentication(
                googleClientSecrets.getWeb().getClientId(),
                googleClientSecrets.getWeb().getClientSecret()))
            .setRedirectUri(REDIRECT_URI)
            .execute();
        Credential credential = flow.createAndStoreCredential(tokenResponse, null);
        return credential;
    } catch (Exception e) {
        throw e;
    }
}

Agrega un extremo para tu URI de redireccionamiento al controlador. Recupera el código de autorización y el parámetro state de la solicitud. Compara este parámetro state con el atributo state almacenado en la sesión. Si coinciden, continúa con el flujo de autorización. Si no coinciden, se muestra un error.

Luego, llama al método getAndSaveCredentials de AuthService y pasa el código de autorización como parámetro. Después de recuperar el objeto Credentials, almacénalo en la sesión. Luego, cierra el diálogo y redirecciona al usuario a la página de destino del complemento.

/** Handles the redirect URL to grant the application access to the user's
*   account.
*   @param request the current request used to obtain the authorization code
*   and state parameter from.
*   @param session the current session.
*   @param response the current response to pass information to.
*   @param model the Model interface to pass error information that's
*   displayed on the error page.
*   @return the close-pop-up template if authorization is successful, or the
*   onError method to handle and display the error message.
*/
@GetMapping(value = {"/callback"})
public String callback(HttpServletRequest request, HttpSession session,
    HttpServletResponse response, Model model) {
    try {
        String authCode = request.getParameter("code");
        String requestState = request.getParameter("state");
        String sessionState = session.getAttribute("state").toString();
        if (!requestState.equals(sessionState)) {
            response.setStatus(401);
            return onError("Invalid state parameter.", model);
        }
        Credential credentials = authService.getAndSaveCredentials(authCode);
        session.setAttribute("credentials", credentials);
        return "close-pop-up";
    } catch (Exception e) {
        return onError(e.getMessage(), model);
    }
}

Prueba una llamada a la API

Ahora que se completó el flujo, puedes realizar llamadas a las APIs de Google.

Por ejemplo, solicita la información del perfil del usuario. Puedes solicitar la información del usuario desde la API de OAuth 2.0.

Python

Lee la documentación de la API de descubrimiento de OAuth 2.0 y úsala para obtener un objeto UserInfo propagado.

# Retrieve the credentials from the session data and construct a
# Credentials instance.
credentials = google.oauth2.credentials.Credentials(
    **flask.session["credentials"])

# Construct the OAuth 2.0 v2 discovery API library.
user_info_service = googleapiclient.discovery.build(
    serviceName="oauth2", version="v2", credentials=credentials)

# Request and store the username in the session.
# This allows it to be used in other methods or in an HTML template.
flask.session["username"] = (
    user_info_service.userinfo().get().execute().get("name"))

Java

Crea un método en la clase de servicio que compile un objeto UserInfo con Credentials como parámetro.

/** Obtains the Userinfo object by passing in the required credentials.
*   @param credentials retrieved from the authorization flow.
*   @return the Userinfo object for the currently signed-in user.
*   @throws IOException if creating UserInfo service or obtaining the
*   Userinfo object is unsuccessful.
*/
public Userinfo getUserInfo(Credential credentials) throws IOException {
    try {
        Oauth2 userInfoService = new Oauth2.Builder(
            new NetHttpTransport(),
            new GsonFactory(),
            credentials).build();
        Userinfo userinfo = userInfoService.userinfo().get().execute();
        return userinfo;
    } catch (Exception e) {
        throw e;
    }
}

Agrega el extremo /test al controlador que muestra el correo electrónico del usuario.

/** Returns the test request page with the user's email.
*   @param session the current session.
*   @param model the Model interface to pass error information that's
*   displayed on the error page.
*   @return the test page that displays the current user's email or the
*   onError method to handle and display the error message.
*/
@GetMapping(value = {"/test"})
public String test(HttpSession session, Model model) {
    try {
        Credential credentials = (Credential) session.getAttribute("credentials");
        Userinfo userInfo = authService.getUserInfo(credentials);
        String userInfoEmail = userInfo.getEmail();
        if (userInfoEmail != null) {
            model.addAttribute("userEmail", userInfoEmail);
        } else {
            return onError("Could not get user email.", model);
        }
        return "test";
    } catch (Exception e) {
        return onError(e.getMessage(), model);
    }
}

Borrar credenciales

Para "borrar" las credenciales de un usuario, quítalas de la sesión actual. Esto te permite probar el enrutamiento en la página de destino del complemento.

Te recomendamos que muestres una indicación de que el usuario salió antes de redirigirlo a la página de destino del complemento. Tu app debe pasar por el flujo de autorización para obtener credenciales nuevas, pero no se les solicita a los usuarios que vuelvan a autorizar tu app.

Python

@app.route("/clear")
def clear_credentials():
    if "credentials" in flask.session:
        del flask.session["credentials"]
        del flask.session["username"]

    return flask.render_template("signed-out.html")

Como alternativa, usa flask.session.clear(), pero esto puede tener efectos no deseados si tienes otros valores almacenados en la sesión.

Java

En el controlador, agrega un extremo /clear.

/** Clears the credentials in the session and returns the sign-out
*   confirmation page.
*   @param session the current session.
*   @return the sign-out confirmation page.
*/
@GetMapping(value = {"/clear"})
public String clear(HttpSession session) {
    try {
        if (session != null && session.getAttribute("credentials") != null) {
            session.removeAttribute("credentials");
        }
        return "sign-out";
    } catch (Exception e) {
        return onError(e.getMessage(), model);
    }
}

Cómo revocar el permiso de la app

Un usuario puede revocar el permiso de tu app enviando una solicitud de POST a https://oauth2.googleapis.com/revoke. La solicitud debe contener el token de acceso del usuario.

Python

import requests

@app.route("/revoke")
def revoke():
    if "credentials" not in flask.session:
        return flask.render_template("addon-discovery.html",
                            message="You need to authorize before " +
                            "attempting to revoke credentials.")

    credentials = google.oauth2.credentials.Credentials(
        **flask.session["credentials"])

    revoke = requests.post(
        "https://oauth2.googleapis.com/revoke",
        params={"token": credentials.token},
        headers={"content-type": "application/x-www-form-urlencoded"})

    if "credentials" in flask.session:
        del flask.session["credentials"]
        del flask.session["username"]

    status_code = getattr(revoke, "status_code")
    if status_code == 200:
        return flask.render_template("authorization.html")
    else:
        return flask.render_template(
            "index.html", message="An error occurred during revocation!")

Java

Agrega un método a la clase de servicio que realice una llamada al extremo de revocación.

/** Revokes the app's permissions to the user's account.
*   @param credentials retrieved from the authorization flow.
*   @return response entity returned from the HTTP call to obtain response
*   information.
*   @throws RestClientException if the POST request to the revoke endpoint is
*   unsuccessful.
*/
public ResponseEntity<String> revokeCredentials(Credential credentials) throws RestClientException {
    try {
        String accessToken = credentials.getAccessToken();
        String url = "https://oauth2.googleapis.com/revoke?token=" + accessToken;

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
        HttpEntity<Object> httpEntity = new HttpEntity<Object>(httpHeaders);
        ResponseEntity<String> responseEntity = new RestTemplate().exchange(
            url,
            HttpMethod.POST,
            httpEntity,
            String.class);
        return responseEntity;
    } catch (RestClientException e) {
        throw e;
    }
}

Agrega un extremo, /revoke, al controlador que borre la sesión y redireccione al usuario a la página de autorización si la revocación se realizó correctamente.

/** Revokes the app's permissions and returns the authorization page.
*   @param session the current session.
*   @return the authorization page.
*   @throws Exception if revoking access is unsuccessful.
*/
@GetMapping(value = {"/revoke"})
public String revoke(HttpSession session) throws Exception {
    try {
        if (session != null && session.getAttribute("credentials") != null) {
            Credential credentials = (Credential) session.getAttribute("credentials");
            ResponseEntity responseEntity = authService.revokeCredentials(credentials);
            Integer httpStatusCode = responseEntity.getStatusCodeValue();

            if (httpStatusCode != 200) {
                return onError("There was an issue revoking access: " +
                    responseEntity.getStatusCode(), model);
            }
            session.removeAttribute("credentials");
        }
        return startAuthFlow(model);
    } catch (Exception e) {
        return onError(e.getMessage(), model);
    }
}

Prueba el complemento

Accede a Google Classroom como uno de tus usuarios de prueba de profesor. Navega a la pestaña Trabajo en clase y crea una nueva Tarea. Haz clic en el botón Complementos debajo del área de texto y, luego, selecciona el complemento. Se abre el iframe y el complemento carga el URI de configuración de archivos adjuntos que especificaste en la página Configuración de la app del SDK de GWM.

¡Felicitaciones! Ya puedes continuar con el siguiente paso: controlar las visitas repetidas a tu complemento.