Permite que el usuario acceda

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

En esta explicación, agregarás 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 esta explicación, completarás lo siguiente:

  • Configura tu aplicación web para que mantenga los datos de sesión dentro de un iframe.
  • Implementar el flujo de acceso de servidor a servidor de Google OAuth 2.0
  • Emite 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, puedes autorizar por completo a los usuarios en tu app web y emitir llamadas a las APIs de Google.

Comprende el flujo de autorización

Las API 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 autorizar y autenticar a un usuario:

  1. Solicitar autorización Proporciona una URL de devolución de llamada como parte de esta solicitud. Cuando se complete, recibirás una URL de autorización.
  2. Redireccionar al usuario a la URL de autorización La página resultante informa al usuario los permisos que requiere tu app y le solicita que permitan el acceso. Cuando se completa, se enruta 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. Realizar llamadas a una API de Google con los tokens

Obtén credenciales de OAuth 2.0

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

Implementa el flujo de autorización

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

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

Iniciar autorización

Si es necesario, modifica tu página de destino para iniciar el flujo de autorización. El complemento puede estar en dos estados posibles: hay tokens guardados en la sesión actual o debes 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 acceda.

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 “credenciales”.

@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 para esta explicación se puede encontrar en el módulo step_02_sign_in.

Abre el archivo application.properties y agrega una 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 configura el URI de redireccionamiento, la ubicación del archivo de secretos del cliente y los permisos que requiere el complemento. El URI de redireccionamiento se usa para redirigir a los usuarios a un URI específico después de que autoricen 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 tu 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". Cuando se haga clic, se debería redireccionar al usuario a la ruta authorize.

Solicitar autorización

Para solicitar autorización, crea y redirecciona al usuario a una URL de autenticación. Esta URL incluye varios datos, como los alcances solicitados, la ruta de destino para después de la autorización y el ID de cliente de la aplicación web. Puedes encontrarlas 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)

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

# 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 el authorization_url y el state. Almacena el state en la sesión; se usa para verificar la autenticidad de la respuesta del servidor más adelante. Por último, redirecciona al usuario a 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 del 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 inyección de constructor para crear una instancia de la clase de servicio en la clase de 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 la autorización, el usuario vuelve a la ruta redirect_uri del paso anterior. En el ejemplo anterior, esta ruta es /callback.

Cuando el usuario vuelve de la página de autorización, recibes una code en la respuesta. Luego, intercambia el código por los tokens de acceso y actualización:

Python

Agrega las siguientes importaciones a tu archivo de servidor 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 vuelve a usar 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 acceso y tokens de actualización. Afortunadamente, 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. Almacena estos elementos en la sesión para que puedan recuperarse en otros métodos o rutas y, luego, redirecciona 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 muestre el objeto Credentials pasando el código de autorización recuperado del redireccionamiento realizado por la URL de autorización. Este objeto Credentials se usa 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, muestra un error.

Luego, llama al método AuthService getAndSaveCredentials y pasa el código de autorización como un parámetro. Después de recuperar el objeto Credentials, almacénalo en la sesión. Luego, cierra el cuadro de 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

Una vez completado el flujo, podrás emitir llamadas a las APIs de Google.

Por ejemplo, solicita la información de 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 Ú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

Puedes "borrar" las credenciales de los usuarios quitándolos de la sesión actual. Esto te permite probar el enrutamiento en la página de destino del complemento.

Recomendamos mostrar una indicación de que el usuario salió de su cuenta antes de redireccionarlo 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 solicita a los usuarios que vuelvan a autorizarla.

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 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 los usuarios de la prueba de Teacher. 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 tu complemento. Se abrirá el iframe y el complemento cargará 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.