feat: conexiones OAuth (Gmail/Outlook) para el agente + rediseño del orquestador
Backend: - UmindConexion: cuenta de correo conectada por tenant vía OAuth2 (golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales. - Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft, /callback/:proveedor intercambia el code (state autoverificable por HMAC, sin tabla de estados pendientes), refresh on-demand antes de cada uso. - Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft Graph directo (sin el SDK pesado de Google). - Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un mensaje claro, no en silencio. Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el navbar + lista de página completa), modo oscuro vía prefers-color-scheme, tabs en pill, y la nueva tab "Conexiones". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b2f6b518b6
commit
5ba41786d6
@@ -0,0 +1,266 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"golang.org/x/oauth2/microsoft"
|
||||
)
|
||||
|
||||
const (
|
||||
UmindOAuthGoogle = "google"
|
||||
UmindOAuthMicrosoft = "microsoft"
|
||||
)
|
||||
|
||||
// CorreoResumen es el formato común en el que enviar_correo/leer_bandeja
|
||||
// devuelven un mensaje al agente, sin importar el proveedor real detrás.
|
||||
type CorreoResumen struct {
|
||||
De string `json:"de"`
|
||||
Asunto string `json:"asunto"`
|
||||
Fecha string `json:"fecha"`
|
||||
Extracto string `json:"extracto"`
|
||||
}
|
||||
|
||||
var umindOAuthHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// firmarState / verificarState arman el parámetro state del flujo OAuth
|
||||
// autoverificable (tenantID + nonce + HMAC con APP_KEY) — evita necesitar una
|
||||
// tabla de "estados pendientes": si la firma es válida, el state no fue
|
||||
// alterado desde que lo generamos nosotros.
|
||||
func firmarState(tenantID uint) (string, error) {
|
||||
nonce := make([]byte, 8)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := fmt.Sprintf("%d.%s", tenantID, hex.EncodeToString(nonce))
|
||||
mac := hmac.New(sha256.New, []byte(app.Http.Server.Key))
|
||||
mac.Write([]byte(payload))
|
||||
return payload + "." + hex.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func verificarState(state string) (uint, error) {
|
||||
partes := strings.Split(state, ".")
|
||||
if len(partes) != 3 {
|
||||
return 0, fmt.Errorf("formato de state inválido")
|
||||
}
|
||||
payload := partes[0] + "." + partes[1]
|
||||
mac := hmac.New(sha256.New, []byte(app.Http.Server.Key))
|
||||
mac.Write([]byte(payload))
|
||||
esperada := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(esperada), []byte(partes[2])) {
|
||||
return 0, fmt.Errorf("firma de state inválida")
|
||||
}
|
||||
tenantID, err := strconv.ParseUint(partes[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("tenant_id inválido en state: %w", err)
|
||||
}
|
||||
return uint(tenantID), nil
|
||||
}
|
||||
|
||||
func redirectURLOAuth(proveedor string) string {
|
||||
return strings.TrimRight(app.Http.Server.Url, "/") + "/app/umind/conexiones/callback/" + proveedor
|
||||
}
|
||||
|
||||
func oauth2ConfigPara(proveedor string) (*oauth2.Config, error) {
|
||||
switch proveedor {
|
||||
case UmindOAuthGoogle:
|
||||
if app.Http.OAuth.GoogleClientID == "" || app.Http.OAuth.GoogleClientSecret == "" {
|
||||
return nil, fmt.Errorf("Google OAuth no está configurado en el servidor (GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET)")
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: app.Http.OAuth.GoogleClientID,
|
||||
ClientSecret: app.Http.OAuth.GoogleClientSecret,
|
||||
RedirectURL: redirectURLOAuth(proveedor),
|
||||
Scopes: []string{
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
},
|
||||
Endpoint: google.Endpoint,
|
||||
}, nil
|
||||
case UmindOAuthMicrosoft:
|
||||
if app.Http.OAuth.MSClientID == "" || app.Http.OAuth.MSClientSecret == "" {
|
||||
return nil, fmt.Errorf("Microsoft OAuth no está configurado en el servidor (MS_OAUTH_CLIENT_ID/MS_OAUTH_CLIENT_SECRET)")
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: app.Http.OAuth.MSClientID,
|
||||
ClientSecret: app.Http.OAuth.MSClientSecret,
|
||||
RedirectURL: redirectURLOAuth(proveedor),
|
||||
Scopes: []string{
|
||||
"offline_access", "openid", "email",
|
||||
"https://graph.microsoft.com/Mail.Send",
|
||||
"https://graph.microsoft.com/Mail.Read",
|
||||
},
|
||||
Endpoint: microsoft.AzureADEndpoint("common"),
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("proveedor desconocido: %s", proveedor)
|
||||
}
|
||||
}
|
||||
|
||||
// IniciarConexionOAuth arma la URL de autorización a la que hay que
|
||||
// redirigir al staff. prompt=consent en Google fuerza a que siempre vuelva
|
||||
// un refresh_token (si no, Google solo lo manda la primera vez que el
|
||||
// usuario autoriza la app, nunca más).
|
||||
func IniciarConexionOAuth(proveedor string, tenantID uint) (string, error) {
|
||||
cfg, err := oauth2ConfigPara(proveedor)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
state, err := firmarState(tenantID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
|
||||
if proveedor == UmindOAuthGoogle {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
}
|
||||
return cfg.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// CompletarConexionOAuth intercambia el code por tokens, identifica la
|
||||
// cuenta conectada y guarda la conexión cifrada. Reemplaza cualquier
|
||||
// conexión previa activa del tenant (una cuenta de correo a la vez).
|
||||
func CompletarConexionOAuth(proveedor, code, state string) (*models.UmindConexion, error) {
|
||||
tenantID, err := verificarState(state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("state inválido: %w", err)
|
||||
}
|
||||
cfg, err := oauth2ConfigPara(proveedor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := cfg.Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo intercambiar el código de autorización: %w", err)
|
||||
}
|
||||
if tok.RefreshToken == "" {
|
||||
return nil, fmt.Errorf("el proveedor no devolvió un refresh_token — revocá el acceso de esta app en tu cuenta y volvé a conectar")
|
||||
}
|
||||
|
||||
email, err := obtenerEmailDeCuenta(proveedor, tok.AccessToken)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND_OAUTH] no se pudo obtener el email de la cuenta conectada (%s): %v", proveedor, err)
|
||||
}
|
||||
|
||||
accessEnc, err := CifrarSecretoUmind(tok.AccessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refreshEnc, err := CifrarSecretoUmind(tok.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := models.DesactivarConexionesDelTenant(tenantID); err != nil {
|
||||
log.Printf("[UMIND_OAUTH] no se pudieron desactivar conexiones previas del tenant %d: %v", tenantID, err)
|
||||
}
|
||||
conexion := &models.UmindConexion{
|
||||
TenantID: tenantID,
|
||||
Proveedor: proveedor,
|
||||
Email: email,
|
||||
AccessTokenEnc: accessEnc,
|
||||
RefreshTokenEnc: refreshEnc,
|
||||
ExpiraEn: tok.Expiry,
|
||||
Scopes: strings.Join(cfg.Scopes, " "),
|
||||
Activo: true,
|
||||
}
|
||||
if err := models.CreateUmindConexion(conexion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conexion, nil
|
||||
}
|
||||
|
||||
func obtenerEmailDeCuenta(proveedor, accessToken string) (string, error) {
|
||||
var url string
|
||||
switch proveedor {
|
||||
case UmindOAuthGoogle:
|
||||
url = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
case UmindOAuthMicrosoft:
|
||||
url = "https://graph.microsoft.com/v1.0/me"
|
||||
default:
|
||||
return "", fmt.Errorf("proveedor desconocido: %s", proveedor)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct {
|
||||
Email string `json:"email"` // Google
|
||||
Mail string `json:"mail"` // Microsoft
|
||||
UserPrincipalName string `json:"userPrincipalName"` // Microsoft, fallback si "mail" viene vacío
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Email != "" {
|
||||
return out.Email, nil
|
||||
}
|
||||
if out.Mail != "" {
|
||||
return out.Mail, nil
|
||||
}
|
||||
return out.UserPrincipalName, nil
|
||||
}
|
||||
|
||||
// RefrescarSiVence renueva el access token si está vencido o a menos de 2
|
||||
// minutos de vencer, y persiste el nuevo valor cifrado. Se llama justo antes
|
||||
// de usar la conexión (enviar/leer correo), no por un cron aparte — ver nota
|
||||
// de alcance en el plan: si en la práctica hace falta refresco proactivo, se
|
||||
// agrega por pkg/services/cron_service.go sin tocar esta función.
|
||||
func RefrescarSiVence(conexion *models.UmindConexion) error {
|
||||
if time.Now().Add(2 * time.Minute).Before(conexion.ExpiraEn) {
|
||||
return nil
|
||||
}
|
||||
cfg, err := oauth2ConfigPara(conexion.Proveedor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refreshToken, err := DescifrarSecretoUmind(conexion.RefreshTokenEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descifrar el refresh_token: %w", err)
|
||||
}
|
||||
|
||||
nuevo, err := cfg.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken}).Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo refrescar el token: %w", err)
|
||||
}
|
||||
|
||||
accessEnc, err := CifrarSecretoUmind(nuevo.AccessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{"access_token_enc": accessEnc, "expira_en": nuevo.Expiry}
|
||||
if nuevo.RefreshToken != "" && nuevo.RefreshToken != refreshToken {
|
||||
if refreshEnc, err := CifrarSecretoUmind(nuevo.RefreshToken); err == nil {
|
||||
updates["refresh_token_enc"] = refreshEnc
|
||||
conexion.RefreshTokenEnc = refreshEnc
|
||||
}
|
||||
}
|
||||
if err := models.UpdateUmindConexion(conexion.ID, updates); err != nil {
|
||||
log.Printf("[UMIND_OAUTH] no se pudo persistir el refresh del token (conexión %d): %v", conexion.ID, err)
|
||||
}
|
||||
conexion.AccessTokenEnc = accessEnc
|
||||
conexion.ExpiraEn = nuevo.Expiry
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user