Cómo autenticar usuarios con Go

Esta sección del instructivo de Bookshelf para Go muestra cómo crear un flujo de inicio de sesión para los usuarios y cómo utilizar esa información de perfil para proporcionar funcionalidades personalizadas a los usuarios.

Con Google Identity Platform, puedes acceder fácilmente a la información de los usuarios y, a la vez, garantizar que Google administre las credenciales de acceso de manera segura. Con OAuth 2.0, brindar un flujo de acceso a todos los usuarios de tu app es fácil y, además, permite a tu aplicación acceder a la información del perfil básico de los usuarios autenticados.

Esta página forma parte de un instructivo de varias páginas. Para comenzar desde el principio y leer las instrucciones de configuración, visita la app de Bookshelf para Go.

Cómo crear un ID de cliente de aplicación web

Mediante un ID de cliente de aplicación web, tu app puede autorizar usuarios y acceder a las API de Google en nombre de ellos.

  1. En Google Cloud Platform Console, dirígete a Credenciales.

    Credenciales

  2. Haz clic en la pantalla de consentimiento de OAuth. En el nombre del producto, ingresa Go Bookshelf App.

  3. En Dominios autorizados, agrega el nombre de tu aplicación de App Engine como [YOUR_PROJECT_ID].appspot.com. Reemplaza [YOUR_PROJECT_ID] por el ID de tu proyecto de GCP.

  4. Llena todos los campos opcionales relevantes. Haz clic en Guardar.

  5. Haz clic en Crear credenciales > ID de cliente de OAuth.

  6. En la lista desplegable Tipo de aplicación, haz clic en Aplicación web.

  7. En el campo Nombre, ingresa Go Bookshelf Client.

  8. En el campo URI de redireccionamiento autorizados, ingresa las siguientes URL, una a la vez.

    http://localhost:8080/oauth2callback
    http://[YOUR_PROJECT_ID].appspot.com/oauth2callback
    https://[YOUR_PROJECT_ID].appspot.com/oauth2callback

  9. Haz clic en Crear.

  10. Copia el ID de cliente y el secreto de cliente y guárdalos para después.

Configuraciones

  1. Ve al directorio que contiene el código de muestra:

    Linux/macOS

    cd $GOPATH/src/github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf
    

    Windows

    cd %GOPATH%\src\github.com\GoogleCloudPlatform\golang-samples\getting-started\bookshelf
    

  2. Abre config.go para editar.

  3. Borra los comentarios de esta línea:

    // oauthConfig = configureOAuthClient("clientid", "clientsecret")
    
  4. Reemplaza "clientid" y "clientsecret" por el ID de cliente y el secreto de cliente que creaste anteriormente.

  5. Guarda y cierra config.go.

  6. Edita app/app.yaml. En la variable de entorno OAUTH2_CALLBACK, reemplaza <your-project-id> por el ID de tu proyecto.

Cómo ejecutar la app en la máquina local

  1. Ejecuta la app para iniciar un servidor web local:

    cd app
    go run app.go auth.go template.go
    
  2. En el navegador web, ingresa la siguiente dirección:

    http://localhost:8080

Ahora podrás navegar por las páginas web de la app, acceder con la cuenta de Google, agregar libros y ver los libros que agregaste mediante el vínculo Mis libros en la barra de navegación superior.

Presiona Control+C para salir del servidor web local.

Cómo implementar la app en el entorno flexible de App Engine

  1. En el directorio app, ingresa este comando para implementar la muestra:

    gcloud app deploy
    
  2. En el navegador web, ingresa la siguiente dirección. Reemplaza [YOUR_PROJECT_ID] por el ID del proyecto:

    https://[YOUR_PROJECT_ID].appspot.com
    

Si actualizas tu app, podrás implementar la versión actualizada mediante el mismo comando que usaste para implementar la app por primera vez. La implementación nueva crea una versión nueva de tu app y la convierte a la versión predeterminada. Las versiones anteriores de la app se conservan, al igual que sus instancias de VM asociadas. Ten en cuenta que todas estas instancias de VM y versiones de la app son recursos facturables.

Para reducir costos, borra las versiones no predeterminadas de la app.

Para borrar una versión de una app, haz lo siguiente:

  1. En GCP Console, dirígete a la página Versiones de App Engine.

    Ir a la página de Versiones

  2. Haz clic en la casilla de verificación junto a la versión de app no predeterminada que deseas borrar.
  3. Haz clic en el botón Borrar en la parte superior de la página para borrar la versión de la app.

Para obtener toda la información acerca de la limpieza de los recursos facturables, consulta la sección Limpieza en el paso final de este instructivo.

Estructura de la aplicación

El siguiente diagrama muestra los componentes de la aplicación y la manera en que se conectan entre sí.

Estructura de muestra de Auth

Comprensión del código

En esta sección, se explica el código de la aplicación y su funcionamiento.

Acerca de las sesiones

Antes de que tu aplicación pueda autenticar usuarios, necesitas un modo de almacenar la información del usuario actual en una sesión. El kit de herramientas web Gorilla incluye una interfaz Store, que cuenta con varias implementaciones. La implementación CookieStore almacena datos de sesión en una cookie segura, y la implementación FilesystemStore almacena los datos de sesión en el sistema de archivos. El almacenamiento de los datos de la sesión en el sistema de archivos no es adecuado para una aplicación que se puede ejecutar desde varias instancias de VM, ya que la sesión que se registra en una instancia puede diferir de otras instancias. Por este motivo, la muestra de Bookshelf utiliza cookies encriptadas (CookieStore). Los datos de la sesión encriptada se almacenan en el navegador del usuario y pueden ser desencriptados por cualquier instancia que ejecute el código de la aplicación. La implementación CookieStore se puede usar en entornos de producción donde hayas implementado varias instancias de tu aplicación, pero ten en cuenta que los usuarios perderán la información de su sesión cuando cierren el navegador.

// Configure storage method for session-wide information.
// Update "something-very-secret" with a hard to guess string or byte sequence.
cookieStore := sessions.NewCookieStore([]byte("something-very-secret"))
cookieStore.Options = &sessions.Options{
	HttpOnly: true,
}
SessionStore = cookieStore

Cómo autenticar usuarios

La autenticación del usuario implica dos pasos básicos que en conjunto se denominan flujo de servicio web:

  1. Redireccionamiento del usuario al servicio de autorización de Google.
  2. Procesamiento de la respuesta cuando Google redirecciona al usuario de vuelta a la aplicación.

Aquí se describen los dos pasos en detalle:

  1. Redireccionamiento a Google.

    Tu aplicación envía al usuario al servicio de autorización de Google. La aplicación usa tu ID de cliente y los alcances de OAuth que necesite para generar una URL. Los alcances indican los elementos de la información del usuario a los que tu aplicación tiene permitido acceder. Existen múltiples servicios de Google con diferentes alcances para cada servicio. Por ejemplo, hay un alcance que permite acceso de solo lectura a Google Drive y otro alcance que permite acceso de lectura y escritura. Esta muestra solo requiere los alcances básicos email y profile, que garantizan el acceso de la aplicación a la dirección de correo electrónico y a la información básica del perfil del usuario.

    func configureOAuthClient(clientID, clientSecret string) *oauth2.Config {
    	redirectURL := os.Getenv("OAUTH2_CALLBACK")
    	if redirectURL == "" {
    		redirectURL = "http://localhost:8080/oauth2callback"
    	}
    	return &oauth2.Config{
    		ClientID:     clientID,
    		ClientSecret: clientSecret,
    		RedirectURL:  redirectURL,
    		Scopes:       []string{"email", "profile"},
    		Endpoint:     google.Endpoint,
    	}
    }

    La función loginHandler redirige la ruta de la URL /login al servicio de autorizaciones de Google:

    // loginHandler initiates an OAuth flow to authenticate the user.
    func loginHandler(w http.ResponseWriter, r *http.Request) *appError {
    	sessionID := uuid.Must(uuid.NewV4()).String()
    
    	oauthFlowSession, err := bookshelf.SessionStore.New(r, sessionID)
    	if err != nil {
    		return appErrorf(err, "could not create oauth session: %v", err)
    	}
    	oauthFlowSession.Options.MaxAge = 10 * 60 // 10 minutes
    
    	redirectURL, err := validateRedirectURL(r.FormValue("redirect"))
    	if err != nil {
    		return appErrorf(err, "invalid redirect URL: %v", err)
    	}
    	oauthFlowSession.Values[oauthFlowRedirectKey] = redirectURL
    
    	if err := oauthFlowSession.Save(r, w); err != nil {
    		return appErrorf(err, "could not save session: %v", err)
    	}
    
    	// Use the session ID for the "state" parameter.
    	// This protects against CSRF (cross-site request forgery).
    	// See https://godoc.org/golang.org/x/oauth2#Config.AuthCodeURL for more detail.
    	url := bookshelf.OAuthConfig.AuthCodeURL(sessionID, oauth2.ApprovalForce,
    		oauth2.AccessTypeOnline)
    	http.Redirect(w, r, url, http.StatusFound)
    	return nil
    }

    El primer parámetro de la función AuthCodeURL es el parámetro state. Este se utiliza para mantener seguro el proceso de inicio de sesión al evitar la falsificación de solicitudes entre sitios.

    Tu aplicación redirecciona al usuario a la URL generada. Se le solicita que permita que tu aplicación acceda a los alcances especificados. Una vez que el usuario acepta, se redirecciona a tu aplicación.

  2. Procesa la respuesta de autorización.

    El servicio de autorización de Google envía al usuario a tu aplicación a través de la ruta URL /oauth2callback, junto con el código de autorización especificado en el valor de forma code. Este código y los secretos de cliente se pueden intercambiar para obtener información del usuario y sus credenciales. Este paso del flujo ocurre en su totalidad en el lado del servidor, sin necesidad de redireccionar al usuario. Este paso adicional es necesario para verificar que el código de autorización sea auténtico y que es tu aplicación la que solicita credenciales.

    // oauthCallbackHandler completes the OAuth flow, retreives the user's profile
    // information and stores it in a session.
    func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) *appError {
    	oauthFlowSession, err := bookshelf.SessionStore.Get(r, r.FormValue("state"))
    	if err != nil {
    		return appErrorf(err, "invalid state parameter. try logging in again.")
    	}
    
    	redirectURL, ok := oauthFlowSession.Values[oauthFlowRedirectKey].(string)
    	// Validate this callback request came from the app.
    	if !ok {
    		return appErrorf(err, "invalid state parameter. try logging in again.")
    	}
    
    	code := r.FormValue("code")
    	tok, err := bookshelf.OAuthConfig.Exchange(context.Background(), code)
    	if err != nil {
    		return appErrorf(err, "could not get auth token: %v", err)
    	}
    
    	session, err := bookshelf.SessionStore.New(r, defaultSessionID)
    	if err != nil {
    		return appErrorf(err, "could not get default session: %v", err)
    	}
    
    	ctx := context.Background()
    	profile, err := fetchProfile(ctx, tok)
    	if err != nil {
    		return appErrorf(err, "could not fetch Google profile: %v", err)
    	}
    
    	session.Values[oauthTokenSessionKey] = tok
    	// Strip the profile to only the fields we need. Otherwise the struct is too big.
    	session.Values[googleProfileSessionKey] = stripProfile(profile)
    	if err := session.Save(r, w); err != nil {
    		return appErrorf(err, "could not save session: %v", err)
    	}
    
    	http.Redirect(w, r, redirectURL, http.StatusFound)
    	return nil
    }

    Después de obtener las credenciales del usuario, puedes recuperar su información de perfil a través de la API de Google+. La llamada a plusService.People.Get("me") recupera el perfil para el usuario autenticado actual. El perfil se almacena en la sesión en googleProfileSessionKey (codificado mediante encoding/gob):

    // fetchProfile retrieves the Google+ profile of the user associated with the
    // provided OAuth token.
    func fetchProfile(ctx context.Context, tok *oauth2.Token) (*plus.Person, error) {
    	client := oauth2.NewClient(ctx, bookshelf.OAuthConfig.TokenSource(ctx, tok))
    	plusService, err := plus.New(client)
    	if err != nil {
    		return nil, err
    	}
    	return plusService.People.Get("me").Do()
    }

    Debido a que la información del perfil se almacena en la sesión, la aplicación puede recuperarla sin necesidad de utilizar la API de Google+.

    // profileFromSession retreives the Google+ profile from the default session.
    // Returns nil if the profile cannot be retreived (e.g. user is logged out).
    func profileFromSession(r *http.Request) *Profile {
    	session, err := bookshelf.SessionStore.Get(r, defaultSessionID)
    	if err != nil {
    		return nil
    	}
    	tok, ok := session.Values[oauthTokenSessionKey].(*oauth2.Token)
    	if !ok || !tok.Valid() {
    		return nil
    	}
    	profile, ok := session.Values[googleProfileSessionKey].(*Profile)
    	if !ok {
    		return nil
    	}
    	return profile
    }

    Por ejemplo, el perfil se pasa a la plantilla base para representar el nombre del usuario registrado y la foto del perfil en cada página:

    // Execute writes the template using the provided data, adding login and user
    // information to the base template.
    func (tmpl *appTemplate) Execute(w http.ResponseWriter, r *http.Request, data interface{}) *appError {
    	d := struct {
    		Data        interface{}
    		AuthEnabled bool
    		Profile     *Profile
    		LoginURL    string
    		LogoutURL   string
    	}{
    		Data:        data,
    		AuthEnabled: bookshelf.OAuthConfig != nil,
    		LoginURL:    "/login?redirect=" + r.URL.RequestURI(),
    		LogoutURL:   "/logout?redirect=" + r.URL.RequestURI(),
    	}
    
    	if d.AuthEnabled {
    		// Ignore any errors.
    		d.Profile = profileFromSession(r)
    	}
    
    	if err := tmpl.t.Execute(w, d); err != nil {
    		return appErrorf(err, "could not write template: %v", err)
    	}
    	return nil
    }
    {{if .AuthEnabled}}
      {{if .Profile}}
      <form method="post" action="{{.LogoutURL}}" class="navbar-form navbar-right">
        <button class="btn btn-default">Log out</button>
      </form>
      <div class="navbar-text navbar-right">
        {{if .Profile.ImageURL}}
          <img class="img-circle" width="24" src="{{.Profile.ImageURL}}">
        {{end}}
        <span>{{.Profile.DisplayName}}</span>
      </div>
      {{else}}
      <div class="navbar-text navbar-right">
        <a href="{{.LoginURL}}">Log in</a>
      </div>
      {{end}}
    {{end}}

Personalización

Ahora que la información del usuario que inició sesión está disponible a través del middleware, podrás hacer un seguimiento de los libros que agrega cada usuario a la base de datos.

// If the form didn't carry the user information for the creator, populate it
// from the currently logged in user (or mark as anonymous).
if book.CreatedByID == "" {
	user := profileFromSession(r)
	if user != nil {
		// Logged in.
		book.CreatedBy = user.DisplayName
		book.CreatedByID = user.ID
	} else {
		// Not logged in.
		book.SetCreatorAnonymous()
	}
}

Y dado que esa información se almacena en la base de datos, puedes mostrar al usuario qué libros ha agregado personalmente.

// listMineHandler displays a list of books created by the currently
// authenticated user.
func listMineHandler(w http.ResponseWriter, r *http.Request) *appError {
	user := profileFromSession(r)
	if user == nil {
		http.Redirect(w, r, "/login?redirect=/books/mine", http.StatusFound)
		return nil
	}

	books, err := bookshelf.DB.ListBooksCreatedBy(user.ID)
	if err != nil {
		return appErrorf(err, "could not list books: %v", err)
	}

	return listTmpl.Execute(w, r, books)
}

Este código utiliza un método de base de datos llamado ListBooksCreatedBy. La implementación depende del backend de base de datos que elijas.

Datastore

En Cloud Datastore, debes crear índices adicionales para una consulta que filtre más de un campo. Esta consulta, en la función ListBooksCreatedBy, filtra tanto en CreatedByID como en Title.

// ListBooksCreatedBy returns a list of books, ordered by title, filtered by
// the user who created the book entry.
func (db *datastoreDB) ListBooksCreatedBy(userID string) ([]*Book, error) {
	ctx := context.Background()
	if userID == "" {
		return db.ListBooks()
	}

	books := make([]*Book, 0)
	q := datastore.NewQuery("Book").
		Filter("CreatedByID =", userID).
		Order("Title")

	keys, err := db.client.GetAll(ctx, q, &books)

	if err != nil {
		return nil, fmt.Errorf("datastoredb: could not list books: %v", err)
	}

	for i, k := range keys {
		books[i].ID = k.ID
	}

	return books, nil
}

Para crear los índices, ingresa los siguientes comandos:

Linux/macOS

cd $GOPATH/src/github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf
cd app
gcloud datastore create-indexes index.yaml

Windows

cd %GOPATH%\src\github.com\GoogleCloudPlatform\golang-samples\getting-started\bookshelf
cd app
gcloud datastore create-indexes index.yaml
indexes:

# This index enables filtering by "CreatedByID" and sort by "Title".
- kind: Book
  properties:
  - name: CreatedByID
    direction: asc
  - name: Title
    direction: asc

Cloud SQL

const listByStatement = `
  SELECT * FROM books
  WHERE createdById = ? ORDER BY title`

// ListBooksCreatedBy returns a list of books, ordered by title, filtered by
// the user who created the book entry.
func (db *mysqlDB) ListBooksCreatedBy(userID string) ([]*Book, error) {
	if userID == "" {
		return db.ListBooks()
	}

	rows, err := db.listBy.Query(userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var books []*Book
	for rows.Next() {
		book, err := scanBook(rows)
		if err != nil {
			return nil, fmt.Errorf("mysql: could not read row: %v", err)
		}

		books = append(books, book)
	}

	return books, nil
}

MongoDB

// ListBooksCreatedBy returns a list of books, ordered by title, filtered by
// the user who created the book entry.
func (db *mongoDB) ListBooksCreatedBy(userID string) ([]*Book, error) {
	var result []*Book
	if err := db.c.Find(bson.D{{Name: "createdbyid", Value: userID}}).Sort("title").All(&result); err != nil {
		return nil, err
	}
	return result, nil
}
¿Te ha resultado útil esta página? Enviar comentarios: