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,75 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindConexion es una cuenta de correo real (Gmail u Outlook) conectada por
|
||||
// OAuth a un tenant, para que el agente pueda enviar y leer correo en su
|
||||
// nombre (tools enviar_correo/leer_bandeja, ver pkg/services/umind_agent_service.go).
|
||||
// AccessTokenEnc/RefreshTokenEnc viajan cifrados en reposo (ver
|
||||
// pkg/services/umind_secrets.go) — a diferencia del site_key del widget,
|
||||
// estos SÍ son secretos: quien los tenga puede leer/mandar correo como el
|
||||
// dueño de la cuenta.
|
||||
type UmindConexion struct {
|
||||
gorm.Model
|
||||
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
||||
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft
|
||||
Email string `json:"email" gorm:"column:email;size:255"`
|
||||
AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"`
|
||||
RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"`
|
||||
ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"`
|
||||
Scopes string `json:"scopes" gorm:"column:scopes;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (UmindConexion) TableName() string { return "umind_conexiones" }
|
||||
|
||||
func CreateUmindConexion(c *UmindConexion) error {
|
||||
return app.Http.Database.DB.Create(c).Error
|
||||
}
|
||||
|
||||
func GetUmindConexionesByTenant(tenantID uint) ([]UmindConexion, error) {
|
||||
var items []UmindConexion
|
||||
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindConexionByID(id uint) (*UmindConexion, error) {
|
||||
var c UmindConexion
|
||||
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetUmindConexionActiva retorna la primera conexión activa del tenant —
|
||||
// hoy se soporta una sola cuenta de correo conectada por tenant, no una
|
||||
// bandeja por proveedor a la vez.
|
||||
func GetUmindConexionActiva(tenantID uint) (*UmindConexion, error) {
|
||||
var c UmindConexion
|
||||
err := app.Http.Database.DB.Where("tenant_id = ? AND activo = ?", tenantID, true).First(&c).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// DesactivarConexionesDelTenant se llama antes de crear una conexión nueva —
|
||||
// hoy se soporta una sola cuenta de correo activa por tenant a la vez.
|
||||
func DesactivarConexionesDelTenant(tenantID uint) error {
|
||||
return app.Http.Database.DB.Model(&UmindConexion{}).
|
||||
Where("tenant_id = ? AND activo = ?", tenantID, true).
|
||||
Update("activo", false).Error
|
||||
}
|
||||
|
||||
func UpdateUmindConexion(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindConexion{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteUmindConexion(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindConexion{}, id).Error
|
||||
}
|
||||
@@ -82,9 +82,51 @@ func umindTools(tenantID uint) []agentTool {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if conexion, err := models.GetUmindConexionActiva(tenantID); err == nil && conexion != nil {
|
||||
tools = append(tools, umindEmailTools()...)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// umindEmailTools son las tools de correo, disponibles solo cuando el
|
||||
// tenant tiene una cuenta conectada (UmindConexion activa) — nombres
|
||||
// genéricos porque al modelo no le importa si detrás hay Gmail u Outlook.
|
||||
func umindEmailTools() []agentTool {
|
||||
return []agentTool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: agentToolFunc{
|
||||
Name: "enviar_correo",
|
||||
Description: "Envía un correo electrónico desde la cuenta de correo conectada del negocio.",
|
||||
Parameters: agentToolParam{
|
||||
Type: "object",
|
||||
Properties: map[string]agentToolParam{
|
||||
"destinatario": {Type: "string", Description: "Email del destinatario"},
|
||||
"asunto": {Type: "string", Description: "Asunto del correo"},
|
||||
"cuerpo": {Type: "string", Description: "Cuerpo del correo en texto plano"},
|
||||
},
|
||||
Required: []string{"destinatario", "asunto", "cuerpo"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function",
|
||||
Function: agentToolFunc{
|
||||
Name: "leer_bandeja",
|
||||
Description: "Busca correos recibidos en la bandeja conectada del negocio (ej. revisar si llegó un comprobante o la respuesta de un cliente).",
|
||||
Parameters: agentToolParam{
|
||||
Type: "object",
|
||||
Properties: map[string]agentToolParam{
|
||||
"consulta": {Type: "string", Description: "Qué buscar: remitente, palabras clave del asunto o del cuerpo"},
|
||||
},
|
||||
Required: []string{"consulta"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// executeUmindTool ejecuta buscar_conocimiento (RAG interno) o, si el nombre
|
||||
// no matchea, busca una UmindHerramienta custom del tenant y hace el POST al
|
||||
// webhook configurado. Devuelve el resultado ya serializado, en el mismo
|
||||
@@ -111,6 +153,10 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
|
||||
return string(b)
|
||||
}
|
||||
|
||||
if name == "enviar_correo" || name == "leer_bandeja" {
|
||||
return executeUmindEmailTool(tenantID, name, args)
|
||||
}
|
||||
|
||||
herramienta, err := models.GetUmindHerramientaByNombre(tenantID, name)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error": "herramienta desconocida: %s"}`, name)
|
||||
@@ -131,6 +177,60 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
|
||||
return resultado
|
||||
}
|
||||
|
||||
// executeUmindEmailTool despacha enviar_correo/leer_bandeja a Gmail o
|
||||
// Microsoft Graph según el proveedor de la conexión activa del tenant,
|
||||
// refrescando el token primero si hace falta.
|
||||
func executeUmindEmailTool(tenantID uint, name string, args map[string]interface{}) string {
|
||||
conexion, err := models.GetUmindConexionActiva(tenantID)
|
||||
if err != nil {
|
||||
return `{"error": "no hay ninguna cuenta de correo conectada"}`
|
||||
}
|
||||
if err := RefrescarSiVence(conexion); err != nil {
|
||||
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
|
||||
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "enviar_correo":
|
||||
destinatario, _ := args["destinatario"].(string)
|
||||
asunto, _ := args["asunto"].(string)
|
||||
cuerpo, _ := args["cuerpo"].(string)
|
||||
if strings.TrimSpace(destinatario) == "" || strings.TrimSpace(cuerpo) == "" {
|
||||
return `{"error": "destinatario y cuerpo son requeridos"}`
|
||||
}
|
||||
var envErr error
|
||||
if conexion.Proveedor == UmindOAuthGoogle {
|
||||
envErr = EnviarCorreoGoogle(conexion, destinatario, asunto, cuerpo)
|
||||
} else {
|
||||
envErr = EnviarCorreoMicrosoft(conexion, destinatario, asunto, cuerpo)
|
||||
}
|
||||
if envErr != nil {
|
||||
log.Printf("[UMIND] Error enviando correo (tenant %d): %v", tenantID, envErr)
|
||||
return `{"error": "no se pudo enviar el correo"}`
|
||||
}
|
||||
return `{"ok": true}`
|
||||
|
||||
case "leer_bandeja":
|
||||
consulta, _ := args["consulta"].(string)
|
||||
var resultados []CorreoResumen
|
||||
var lecErr error
|
||||
if conexion.Proveedor == UmindOAuthGoogle {
|
||||
resultados, lecErr = LeerBandejaGoogle(conexion, consulta, 5)
|
||||
} else {
|
||||
resultados, lecErr = LeerBandejaMicrosoft(conexion, consulta, 5)
|
||||
}
|
||||
if lecErr != nil {
|
||||
log.Printf("[UMIND] Error leyendo bandeja (tenant %d): %v", tenantID, lecErr)
|
||||
return `{"error": "no se pudo leer la bandeja"}`
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{"resultados": resultados})
|
||||
return string(b)
|
||||
|
||||
default:
|
||||
return `{"error": "herramienta desconocida"}`
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessWidgetMessage procesa un mensaje del widget de uMind y devuelve la
|
||||
// respuesta del agente. Es el equivalente de ProcessAgentMessage pero
|
||||
// multi-tenant y con un toolset acotado a RAG (sin herramientas internas).
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// EnviarCorreoGoogle manda un correo de texto plano vía Gmail API en nombre
|
||||
// de la cuenta conectada. Asume que conexion ya pasó por RefrescarSiVence.
|
||||
func EnviarCorreoGoogle(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
mime := fmt.Sprintf("To: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\n%s",
|
||||
destinatario, asunto, cuerpo)
|
||||
raw := base64.RawURLEncoding.EncodeToString([]byte(mime))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"raw": raw})
|
||||
req, err := http.NewRequest(http.MethodPost, "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo contactar Gmail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LeerBandejaGoogle busca mensajes en Gmail (sintaxis de búsqueda de Gmail,
|
||||
// ej: "from:cliente@ejemplo.com") y devuelve un resumen liviano de cada uno.
|
||||
func LeerBandejaGoogle(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
|
||||
if limite <= 0 || limite > 10 {
|
||||
limite = 10
|
||||
}
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
listURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?q=%s&maxResults=%d",
|
||||
url.QueryEscape(consulta), limite)
|
||||
var lista struct {
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := gmailGetJSON(listURL, accessToken, &lista); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultados := make([]CorreoResumen, 0, len(lista.Messages))
|
||||
for _, m := range lista.Messages {
|
||||
detalleURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date", m.ID)
|
||||
var msg struct {
|
||||
Snippet string `json:"snippet"`
|
||||
Payload struct {
|
||||
Headers []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
} `json:"headers"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := gmailGetJSON(detalleURL, accessToken, &msg); err != nil {
|
||||
continue // un mensaje individual que falla no debe tirar abajo toda la búsqueda
|
||||
}
|
||||
r := CorreoResumen{Extracto: msg.Snippet}
|
||||
for _, h := range msg.Payload.Headers {
|
||||
switch h.Name {
|
||||
case "From":
|
||||
r.De = h.Value
|
||||
case "Subject":
|
||||
r.Asunto = h.Value
|
||||
case "Date":
|
||||
r.Fecha = h.Value
|
||||
}
|
||||
}
|
||||
resultados = append(resultados, r)
|
||||
}
|
||||
return resultados, nil
|
||||
}
|
||||
|
||||
func gmailGetJSON(url, accessToken string, out interface{}) error {
|
||||
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 fmt.Errorf("no se pudo contactar Gmail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// EnviarCorreoMicrosoft manda un correo de texto plano vía Microsoft Graph
|
||||
// (POST /me/sendMail) en nombre de la cuenta conectada.
|
||||
func EnviarCorreoMicrosoft(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"subject": asunto,
|
||||
"body": map[string]string{"contentType": "Text", "content": cuerpo},
|
||||
"toRecipients": []map[string]interface{}{
|
||||
{"emailAddress": map[string]string{"address": destinatario}},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, "https://graph.microsoft.com/v1.0/me/sendMail", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LeerBandejaMicrosoft busca mensajes en la bandeja vía Microsoft Graph
|
||||
// ($search sobre asunto/cuerpo/remitente) y devuelve un resumen liviano.
|
||||
func LeerBandejaMicrosoft(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
|
||||
if limite <= 0 || limite > 10 {
|
||||
limite = 10
|
||||
}
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
q := fmt.Sprintf("https://graph.microsoft.com/v1.0/me/messages?$search=%s&$top=%d&$select=from,subject,receivedDateTime,bodyPreview",
|
||||
url.QueryEscape(`"`+consulta+`"`), limite)
|
||||
req, err := http.NewRequest(http.MethodGet, q, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
// $search requiere este header ("eventual consistency") en Microsoft Graph.
|
||||
req.Header.Set("ConsistencyLevel", "eventual")
|
||||
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Value []struct {
|
||||
From struct {
|
||||
EmailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
} `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedDateTime string `json:"receivedDateTime"`
|
||||
BodyPreview string `json:"bodyPreview"`
|
||||
} `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultados := make([]CorreoResumen, 0, len(out.Value))
|
||||
for _, m := range out.Value {
|
||||
resultados = append(resultados, CorreoResumen{
|
||||
De: m.From.EmailAddress.Address,
|
||||
Asunto: m.Subject,
|
||||
Fecha: m.ReceivedDateTime,
|
||||
Extracto: m.BodyPreview,
|
||||
})
|
||||
}
|
||||
return resultados, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/config"
|
||||
)
|
||||
|
||||
func TestFirmarYVerificarState(t *testing.T) {
|
||||
app.Http = &config.AppConfig{Server: config.ServerConfig{Key: "clave-de-prueba-no-real"}}
|
||||
|
||||
state, err := firmarState(42)
|
||||
if err != nil {
|
||||
t.Fatalf("firmarState: %v", err)
|
||||
}
|
||||
tenantID, err := verificarState(state)
|
||||
if err != nil {
|
||||
t.Fatalf("verificarState de un state válido falló: %v", err)
|
||||
}
|
||||
if tenantID != 42 {
|
||||
t.Errorf("tenantID = %d, esperaba 42", tenantID)
|
||||
}
|
||||
|
||||
if _, err := verificarState(state + "x"); err == nil {
|
||||
t.Error("un state alterado fue aceptado")
|
||||
}
|
||||
if _, err := verificarState("formato.invalido"); err == nil {
|
||||
t.Error("un state con formato inválido fue aceptado")
|
||||
}
|
||||
if _, err := verificarState("noesnumero.aabbcc.deadbeef"); err == nil {
|
||||
t.Error("un tenant_id no numérico fue aceptado")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user