diff --git a/README.md b/README.md index 7c19835..8e72cf4 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,22 @@ llamada. Cada cultivo puede afinar sus avisos (`crop_channels`): al instante o e resumen diario a una hora fija y hasta 10 chats con los que se comparte. El preparador de resúmenes revisa cada minuto los pendientes, sin cron en el servidor. +## Conexiones para asistentes de IA (MCP) + +Cada persona crea desde la PWA (Perfil › Conexiones) tokens de conexión `spt_…` para clientes externos, como el +servidor [SmartPot-MCP](https://github.com/SmartPotTech/SmartPot-MCP): `GET/POST /api/v1/connections` y +`DELETE /api/v1/connections/{id}`. El token solo se devuelve al crearlo; la base guarda su SHA-256, un prefijo para +reconocerlo y el último uso. El guard lo acepta igual que la sesión, pero con límites: + +| Alcance | Puede | +|-----------|-----------------------------------------------------------| +| `READ` | Solo consultas (`GET`) | +| `CONTROL` | Además crear, editar, borrar y accionar actuadores | + +Ninguna conexión administra conexiones, cambia la cuenta, configura canales de aviso, ve credenciales de +dispositivos ni obtiene el rol de administrador. Revocar o vencer la conexión la corta al instante (401), y borrar la +cuenta borra sus conexiones. Máximo 10 por cuenta. + ## Cultivos Reales y Virtuales Al crear un cultivo se elige, una sola vez, de dónde vienen sus lecturas. Uno **real** recibe la clave del dispositivo diff --git a/cmd/api/main.go b/cmd/api/main.go index 71b71cd..664d0f0 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -22,6 +22,7 @@ import ( "github.com/SmartPotTech/SmartPot-API-Go/internal/channels/telegram" "github.com/SmartPotTech/SmartPot-API-Go/internal/commands" "github.com/SmartPotTech/SmartPot-API-Go/internal/config" + "github.com/SmartPotTech/SmartPot-API-Go/internal/connections" "github.com/SmartPotTech/SmartPot-API-Go/internal/crops" "github.com/SmartPotTech/SmartPot-API-Go/internal/health" "github.com/SmartPotTech/SmartPot-API-Go/internal/httpserver" @@ -154,6 +155,10 @@ func run() error { userService.AddAccountDeleter(notificationService.DeleteAllForUser) userService.AddAccountDeleter(tokenRepo.DeleteByUserID) userService.AddAccountDeleter(cropService.DeleteAllOwnedBy) + // Conexiones personales (clientes MCP y similares): el guard las acepta además de la sesión. + connectionService := connections.NewService(postgres.NewAccessTokenRepository(pool.Pool), clock) + tokenService.UseConnections(connectionService) + userService.AddAccountDeleter(connectionService.DeleteAllForUser) authService := auth.NewService(userRepo, tokenRepo, tokenService, mailer, notificationService, clock, cfg.Security.PasswordResetExpiration) @@ -243,6 +248,7 @@ func run() error { overview.NewHandler(overviewService).Register(authed) virtualdevices.NewHandler(virtualDeviceService).Register(authed) channels.NewHandler(channelService, cropChannelService).Register(authed) + connections.NewHandler(connectionService).Register(authed) // Completa el tipo, la forma y el lugar de los cultivos anteriores (idempotente). runCropBackfill(logger, vdRepo, cropService) diff --git a/internal/connections/handler.go b/internal/connections/handler.go new file mode 100644 index 0000000..bc02e4f --- /dev/null +++ b/internal/connections/handler.go @@ -0,0 +1,60 @@ +package connections + +import ( + "net/http" + + "github.com/SmartPotTech/SmartPot-API-Go/internal/httpserver" + "github.com/SmartPotTech/SmartPot-API-Go/internal/security" + "github.com/labstack/echo/v4" +) + +// Handler expone las rutas de conexiones. Solo se administran con la sesión de la PWA: el guard rechaza estas +// rutas cuando la petición llega con un token de conexión. +type Handler struct { + svc *Service +} + +// NewHandler crea el controlador de conexiones. +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +// Register monta las rutas bajo el grupo autenticado /api/v1. +func (h *Handler) Register(g *echo.Group) { + g.GET("/connections", h.list) + g.POST("/connections", h.create) + g.DELETE("/connections/:id", h.revoke) +} + +func (h *Handler) list(c echo.Context) error { + items, err := h.svc.List(c.Request().Context(), security.CurrentUserID(c)) + if err != nil { + return err + } + out := make([]Response, 0, len(items)) + for _, t := range items { + out = append(out, ToResponse(t)) + } + return c.JSON(http.StatusOK, out) +} + +func (h *Handler) create(c echo.Context) error { + var req CreateRequest + if err := httpserver.Bind(c, &req); err != nil { + return err + } + created, err := h.svc.Create(c.Request().Context(), security.CurrentUserID(c), req) + if err != nil { + return err + } + // El token solo viaja en esta respuesta: que ningún intermediario la guarde. + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusCreated, created) +} + +func (h *Handler) revoke(c echo.Context) error { + if err := h.svc.Revoke(c.Request().Context(), security.CurrentUserID(c), c.Param("id")); err != nil { + return err + } + return c.NoContent(http.StatusNoContent) +} diff --git a/internal/connections/model.go b/internal/connections/model.go new file mode 100644 index 0000000..875785a --- /dev/null +++ b/internal/connections/model.go @@ -0,0 +1,116 @@ +// Package connections gestiona los tokens de conexión personales: credenciales que una persona crea desde la PWA +// para que un cliente externo (un servidor MCP, un script) use la API en su nombre, con un alcance acotado y sin +// compartir su contraseña. Cada conexión se revoca por separado. +package connections + +import ( + "encoding/json" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/SmartPotTech/SmartPot-API-Go/internal/platform/validation" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Scope es lo que una conexión puede hacer. +type Scope string + +const ( + // ScopeRead solo consulta (GET). + ScopeRead Scope = "READ" + // ScopeControl además crea, edita, borra y acciona actuadores. + ScopeControl Scope = "CONTROL" +) + +// TokenPrefix identifica a simple vista un token de conexión (y lo distingue de un JWT de sesión). +const TokenPrefix = "spt_" + +// UnmarshalJSON rechaza un alcance desconocido. +func (s *Scope) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + switch Scope(raw) { + case ScopeRead, ScopeControl: + *s = Scope(raw) + return nil + default: + return fmt.Errorf("alcance inválido: %q", raw) + } +} + +// AccessToken es una conexión guardada. Del token solo se conserva el SHA-256 y un prefijo para reconocerlo. +type AccessToken struct { + ID primitive.ObjectID + UserID primitive.ObjectID + Name string + TokenHash string + Prefix string + Scope Scope + CreatedAt time.Time + LastUsedAt *time.Time + ExpiresAt *time.Time +} + +// Expired indica si la conexión venció en el instante dado. +func (t AccessToken) Expired(now time.Time) bool { + return t.ExpiresAt != nil && !now.Before(*t.ExpiresAt) +} + +// CreateRequest pide una conexión nueva. +type CreateRequest struct { + Name string `json:"name"` + Scope Scope `json:"scope"` + ExpiresInDays *int `json:"expiresInDays"` +} + +// Validate comprueba nombre, alcance y vigencia. +func (r CreateRequest) Validate() validation.Fields { + v := validation.New() + name := strings.TrimSpace(r.Name) + if name == "" { + v.Add("name", "Ponle un nombre a la conexión") + } else if utf8.RuneCountInString(name) > 60 { + v.Add("name", "El nombre admite como máximo 60 caracteres") + } + if r.Scope != ScopeRead && r.Scope != ScopeControl { + v.Add("scope", "Elige solo lectura o control") + } + if r.ExpiresInDays != nil && (*r.ExpiresInDays < 1 || *r.ExpiresInDays > 365) { + v.Add("expiresInDays", "La vigencia debe estar entre 1 y 365 días") + } + return v.Result() +} + +// Response es la vista pública de una conexión (nunca incluye el token). +type Response struct { + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Scope Scope `json:"scope"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +// CreatedResponse devuelve la conexión recién creada con su token, que solo se muestra esta vez. +type CreatedResponse struct { + Connection Response `json:"connection"` + Token string `json:"token"` +} + +// ToResponse mapea la entidad a su vista pública. +func ToResponse(t AccessToken) Response { + return Response{ + ID: t.ID.Hex(), + Name: t.Name, + Prefix: t.Prefix, + Scope: t.Scope, + CreatedAt: t.CreatedAt, + LastUsedAt: t.LastUsedAt, + ExpiresAt: t.ExpiresAt, + } +} diff --git a/internal/connections/port.go b/internal/connections/port.go new file mode 100644 index 0000000..3f46f38 --- /dev/null +++ b/internal/connections/port.go @@ -0,0 +1,19 @@ +package connections + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Repository es el puerto de almacenamiento de las conexiones; lo implementa el adaptador de PostgreSQL. +type Repository interface { + Insert(ctx context.Context, t *AccessToken) error + ListByUser(ctx context.Context, userID primitive.ObjectID) ([]AccessToken, error) + CountByUser(ctx context.Context, userID primitive.ObjectID) (int64, error) + FindByHash(ctx context.Context, hash string) (*AccessToken, error) + DeleteByIDAndUser(ctx context.Context, id, userID primitive.ObjectID) (int64, error) + DeleteByUser(ctx context.Context, userID primitive.ObjectID) error + TouchLastUsed(ctx context.Context, id primitive.ObjectID, at time.Time) error +} diff --git a/internal/connections/service.go b/internal/connections/service.go new file mode 100644 index 0000000..9fd6d1b --- /dev/null +++ b/internal/connections/service.go @@ -0,0 +1,149 @@ +package connections + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" + "time" + + "github.com/SmartPotTech/SmartPot-API-Go/internal/platform/apperr" + "github.com/SmartPotTech/SmartPot-API-Go/internal/security" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +const ( + // MaxPerUser limita las conexiones vivas de una cuenta. + MaxPerUser = 10 + // touchEvery evita escribir en la base en cada petición solo para actualizar el último uso. + touchEvery = time.Minute + // prefixLength es lo que se muestra del token para reconocerlo (spt_ + 8 caracteres). + prefixLength = len(TokenPrefix) + 8 +) + +// Service crea, lista, revoca y verifica conexiones. +type Service struct { + repo Repository + clock func() time.Time +} + +// NewService crea el servicio de conexiones. +func NewService(repo Repository, clock func() time.Time) *Service { + return &Service{repo: repo, clock: clock} +} + +// Create genera una conexión y devuelve su token, que no se vuelve a mostrar. +func (s *Service) Create(ctx context.Context, userID string, req CreateRequest) (*CreatedResponse, error) { + uid, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return nil, apperr.Unauthorized("Inicia sesión para continuar") + } + count, err := s.repo.CountByUser(ctx, uid) + if err != nil { + return nil, err + } + if count >= MaxPerUser { + return nil, apperr.Conflict("Llegaste al máximo de 10 conexiones; revoca alguna para crear otra") + } + raw, err := newToken() + if err != nil { + return nil, err + } + now := s.clock() + t := AccessToken{ + ID: primitive.NewObjectID(), + UserID: uid, + Name: strings.TrimSpace(req.Name), + TokenHash: HashToken(raw), + Prefix: raw[:prefixLength], + Scope: req.Scope, + CreatedAt: now, + } + if req.ExpiresInDays != nil { + expires := now.Add(time.Duration(*req.ExpiresInDays) * 24 * time.Hour) + t.ExpiresAt = &expires + } + if err := s.repo.Insert(ctx, &t); err != nil { + return nil, err + } + return &CreatedResponse{Connection: ToResponse(t), Token: raw}, nil +} + +// List devuelve las conexiones de la cuenta, de la más nueva a la más antigua. +func (s *Service) List(ctx context.Context, userID string) ([]AccessToken, error) { + uid, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return nil, apperr.Unauthorized("Inicia sesión para continuar") + } + return s.repo.ListByUser(ctx, uid) +} + +// Revoke borra una conexión de la cuenta; desde ese momento su token deja de servir. +func (s *Service) Revoke(ctx context.Context, userID, id string) error { + uid, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return apperr.Unauthorized("Inicia sesión para continuar") + } + oid, err := primitive.ObjectIDFromHex(id) + if err != nil { + return apperr.NotFound("La conexión no existe") + } + n, err := s.repo.DeleteByIDAndUser(ctx, oid, uid) + if err != nil { + return err + } + if n == 0 { + return apperr.NotFound("La conexión no existe") + } + return nil +} + +// DeleteAllForUser borra las conexiones al eliminar la cuenta. +func (s *Service) DeleteAllForUser(ctx context.Context, userID string) error { + uid, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return nil + } + return s.repo.DeleteByUser(ctx, uid) +} + +// AuthenticateConnection verifica un token de conexión y devuelve a quién representa y con qué alcance. +func (s *Service) AuthenticateConnection(ctx context.Context, raw string) (security.ConnectionPrincipal, error) { + invalid := apperr.Unauthorized("El token de conexión no es válido o fue revocado") + if !strings.HasPrefix(raw, TokenPrefix) || len(raw) < prefixLength { + return security.ConnectionPrincipal{}, invalid + } + t, err := s.repo.FindByHash(ctx, HashToken(raw)) + if err != nil { + return security.ConnectionPrincipal{}, err + } + now := s.clock() + if t == nil || t.Expired(now) { + return security.ConnectionPrincipal{}, invalid + } + if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) >= touchEvery { + // El último uso es informativo: si falla la escritura, la petición sigue. + _ = s.repo.TouchLastUsed(ctx, t.ID, now) + } + return security.ConnectionPrincipal{ + ConnectionID: t.ID.Hex(), + UserID: t.UserID.Hex(), + Scope: string(t.Scope), + }, nil +} + +// HashToken es el SHA-256 en hexadecimal: el token tiene 256 bits aleatorios, así que no hace falta sal. +func HashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func newToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return TokenPrefix + base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/internal/connections/service_test.go b/internal/connections/service_test.go new file mode 100644 index 0000000..763ad77 --- /dev/null +++ b/internal/connections/service_test.go @@ -0,0 +1,170 @@ +package connections + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/SmartPotTech/SmartPot-API-Go/internal/platform/apperr" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type memRepo struct { + items map[primitive.ObjectID]AccessToken + touches int +} + +func newMemRepo() *memRepo { return &memRepo{items: map[primitive.ObjectID]AccessToken{}} } + +func (m *memRepo) Insert(_ context.Context, t *AccessToken) error { + m.items[t.ID] = *t + return nil +} + +func (m *memRepo) ListByUser(_ context.Context, uid primitive.ObjectID) ([]AccessToken, error) { + var out []AccessToken + for _, t := range m.items { + if t.UserID == uid { + out = append(out, t) + } + } + return out, nil +} + +func (m *memRepo) CountByUser(ctx context.Context, uid primitive.ObjectID) (int64, error) { + l, _ := m.ListByUser(ctx, uid) + return int64(len(l)), nil +} + +func (m *memRepo) FindByHash(_ context.Context, hash string) (*AccessToken, error) { + for _, t := range m.items { + if t.TokenHash == hash { + return &t, nil + } + } + return nil, nil +} + +func (m *memRepo) DeleteByIDAndUser(_ context.Context, id, uid primitive.ObjectID) (int64, error) { + if t, ok := m.items[id]; ok && t.UserID == uid { + delete(m.items, id) + return 1, nil + } + return 0, nil +} + +func (m *memRepo) DeleteByUser(_ context.Context, uid primitive.ObjectID) error { + for id, t := range m.items { + if t.UserID == uid { + delete(m.items, id) + } + } + return nil +} + +func (m *memRepo) TouchLastUsed(_ context.Context, id primitive.ObjectID, at time.Time) error { + t := m.items[id] + t.LastUsedAt = &at + m.items[id] = t + m.touches++ + return nil +} + +func status(err error) int { + var ae *apperr.Error + if errors.As(err, &ae) { + return ae.Status + } + return 0 +} + +func TestCreateAuthenticateAndRevoke(t *testing.T) { + now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) + repo := newMemRepo() + svc := NewService(repo, func() time.Time { return now }) + user := primitive.NewObjectID().Hex() + ctx := context.Background() + + created, err := svc.Create(ctx, user, CreateRequest{Name: " Asistente ", Scope: ScopeRead}) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(created.Token, TokenPrefix) || len(created.Token) < 40 { + t.Fatalf("token con formato inesperado: %q", created.Token) + } + if created.Connection.Name != "Asistente" || !strings.HasPrefix(created.Token, created.Connection.Prefix) { + t.Fatalf("conexión mal armada: %+v", created.Connection) + } + for _, stored := range repo.items { + if strings.Contains(stored.TokenHash, created.Token) { + t.Fatal("el token no debe guardarse en claro") + } + } + + p, err := svc.AuthenticateConnection(ctx, created.Token) + if err != nil || p.UserID != user || p.Scope != "READ" { + t.Fatalf("autenticación: %+v, %v", p, err) + } + if _, err := svc.AuthenticateConnection(ctx, created.Token); err != nil || repo.touches != 1 { + t.Fatalf("el último uso se escribe a lo sumo una vez por minuto (escrituras: %d)", repo.touches) + } + if _, err := svc.AuthenticateConnection(ctx, "spt_inventado-pero-largo-de-sobra"); status(err) != 401 { + t.Fatalf("token desconocido debe dar 401, dio %v", err) + } + + if err := svc.Revoke(ctx, primitive.NewObjectID().Hex(), created.Connection.ID); status(err) != 404 { + t.Fatalf("otra cuenta no puede revocarla: %v", err) + } + if err := svc.Revoke(ctx, user, created.Connection.ID); err != nil { + t.Fatal(err) + } + if _, err := svc.AuthenticateConnection(ctx, created.Token); status(err) != 401 { + t.Fatalf("una conexión revocada debe dar 401, dio %v", err) + } +} + +func TestExpiredConnectionAndLimit(t *testing.T) { + now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) + clock := now + repo := newMemRepo() + svc := NewService(repo, func() time.Time { return clock }) + user := primitive.NewObjectID().Hex() + ctx := context.Background() + days := 1 + created, err := svc.Create(ctx, user, CreateRequest{Name: "Temporal", Scope: ScopeControl, ExpiresInDays: &days}) + if err != nil { + t.Fatal(err) + } + clock = now.Add(25 * time.Hour) + if _, err := svc.AuthenticateConnection(ctx, created.Token); status(err) != 401 { + t.Fatalf("una conexión vencida debe dar 401, dio %v", err) + } + for i := 1; i < MaxPerUser; i++ { + if _, err := svc.Create(ctx, user, CreateRequest{Name: "Otra", Scope: ScopeRead}); err != nil { + t.Fatal(err) + } + } + if _, err := svc.Create(ctx, user, CreateRequest{Name: "Una más", Scope: ScopeRead}); status(err) != 409 { + t.Fatalf("superar el máximo debe dar 409, dio %v", err) + } +} + +func TestCreateRequestValidation(t *testing.T) { + bad := 0 + cases := []CreateRequest{ + {Name: "", Scope: ScopeRead}, + {Name: strings.Repeat("a", 61), Scope: ScopeRead}, + {Name: "Ok", Scope: "ADMIN"}, + {Name: "Ok", Scope: ScopeRead, ExpiresInDays: &bad}, + } + for _, c := range cases { + if len(c.Validate()) == 0 { + t.Errorf("se esperaba error de validación para %+v", c) + } + } + if f := (CreateRequest{Name: "Asistente", Scope: ScopeControl}).Validate(); len(f) != 0 { + t.Errorf("petición válida rechazada: %v", f) + } +} diff --git a/internal/httpserver/context.go b/internal/httpserver/context.go index 30fe004..76a89c1 100644 --- a/internal/httpserver/context.go +++ b/internal/httpserver/context.go @@ -26,6 +26,9 @@ type Identity struct { IssuedAt time.Time ExpiresAt time.Time Authenticated bool + // ConnectionID y Scope solo se llenan cuando la petición llega con un token de conexión (no con la sesión). + ConnectionID string + Scope string } // TelemetryTrace almacena las trazas de telemetría de la petición HTTP en curso. diff --git a/internal/openapi/openapi.go b/internal/openapi/openapi.go index 021acfe..777034d 100644 --- a/internal/openapi/openapi.go +++ b/internal/openapi/openapi.go @@ -76,6 +76,10 @@ var routes = []route{ {"PUT", "/api/v1/notifications/read-all", "Notificaciones", "Marcar todas como leídas", false}, {"DELETE", "/api/v1/notifications/{id}", "Notificaciones", "Eliminar una notificación", false}, + {"GET", "/api/v1/connections", "Conexiones", "Listar las conexiones (tokens spt_ para clientes MCP)", false}, + {"POST", "/api/v1/connections", "Conexiones", "Crear una conexión (el token solo se devuelve aquí)", false}, + {"DELETE", "/api/v1/connections/{id}", "Conexiones", "Revocar una conexión", false}, + {"GET", "/api/v1/crops/{cropId}/virtual-device", "Dispositivo virtual", "Ver el dispositivo virtual", false}, {"PUT", "/api/v1/crops/{cropId}/virtual-device", "Dispositivo virtual", "Configurar el dispositivo virtual", false}, {"DELETE", "/api/v1/crops/{cropId}/virtual-device", "Dispositivo virtual", "Pausar el dispositivo virtual", false}, diff --git a/internal/security/guard.go b/internal/security/guard.go index 282d902..84f629f 100644 --- a/internal/security/guard.go +++ b/internal/security/guard.go @@ -1,6 +1,8 @@ package security import ( + "context" + "net/http" "strings" "github.com/SmartPotTech/SmartPot-API-Go/internal/httpserver" @@ -14,9 +16,30 @@ const ( RoleUser = "USER" ) -// Guard devuelve el middleware que exige un token JWT válido e inyecta la -// identidad y las credenciales verificadas en el RequestContext tipado en -// tiempo de compilación (sin usar mapas de strings ni conversiones any). +// ConnectionPrincipal es a quién representa un token de conexión y con qué alcance. +type ConnectionPrincipal struct { + ConnectionID string + UserID string + Scope string +} + +// ConnectionAuthenticator verifica tokens de conexión (lo implementa el paquete connections; la interfaz vive +// aquí para no crear un ciclo de imports). +type ConnectionAuthenticator interface { + AuthenticateConnection(ctx context.Context, raw string) (ConnectionPrincipal, error) +} + +// connectionTokenPrefix distingue un token de conexión de un JWT de sesión. +const connectionTokenPrefix = "spt_" + +// UseConnections habilita que el guard acepte tokens de conexión además de la sesión. +func (s *TokenService) UseConnections(a ConnectionAuthenticator) { + s.connections = a +} + +// Guard devuelve el middleware que exige un token JWT válido (o un token de conexión, si está habilitado) e +// inyecta la identidad y las credenciales verificadas en el RequestContext tipado en tiempo de compilación (sin +// usar mapas de strings ni conversiones any). func (s *TokenService) Guard() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -24,7 +47,11 @@ func (s *TokenService) Guard() echo.MiddlewareFunc { if !strings.HasPrefix(header, "Bearer ") { return apperr.Unauthorized("Inicia sesión para continuar") } - claims, err := s.Parse(strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))) + raw := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + if s.connections != nil && strings.HasPrefix(raw, connectionTokenPrefix) { + return s.guardConnection(c, raw, next) + } + claims, err := s.Parse(raw) if err != nil { return apperr.Unauthorized("Tu sesión no es válida o expiró. Inicia sesión de nuevo") } @@ -42,6 +69,53 @@ func (s *TokenService) Guard() echo.MiddlewareFunc { } } +func (s *TokenService) guardConnection(c echo.Context, raw string, next echo.HandlerFunc) error { + p, err := s.connections.AuthenticateConnection(c.Request().Context(), raw) + if err != nil { + return err + } + if err := connectionAllowed(c.Request().Method, c.Request().URL.Path, p.Scope); err != nil { + return err + } + rc := httpserver.FromContext(c) + // Una conexión nunca obtiene el rol de administrador, aunque la cuenta lo tenga. + rc.SetIdentity(httpserver.Identity{ + UserID: p.UserID, + Role: RoleUser, + ConnectionID: p.ConnectionID, + Scope: p.Scope, + }) + return next(rc) +} + +// connectionAllowed acota lo que puede hacer un token de conexión: la cuenta, las conexiones, los canales de +// aviso y las credenciales de los dispositivos solo se manejan desde la PWA, y el alcance de lectura no escribe. +func connectionAllowed(method, path, scope string) error { + read := method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions + switch { + case strings.HasPrefix(path, "/api/v1/connections"): + return apperr.Forbidden("Las conexiones solo se administran desde la aplicación") + case strings.HasPrefix(path, "/api/v1/users/me") && !read: + return apperr.Forbidden("Los datos de la cuenta solo se cambian desde la aplicación") + case strings.HasPrefix(path, "/api/v1/channels") && !read: + return apperr.Forbidden("Los canales de aviso solo se configuran desde la aplicación") + case hasSegment(path, "device"): + return apperr.Forbidden("Las credenciales del dispositivo solo se consultan desde la aplicación") + case scope != "CONTROL" && !read: + return apperr.Forbidden("Esta conexión es de solo lectura") + } + return nil +} + +func hasSegment(path, segment string) bool { + for _, part := range strings.Split(path, "/") { + if part == segment { + return true + } + } + return false +} + // RequireRole exige un rol concreto; responde 403 como la versión en Java. func RequireRole(role string) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { diff --git a/internal/security/guard_connection_test.go b/internal/security/guard_connection_test.go new file mode 100644 index 0000000..08f4206 --- /dev/null +++ b/internal/security/guard_connection_test.go @@ -0,0 +1,88 @@ +package security + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/SmartPotTech/SmartPot-API-Go/internal/httpserver" + "github.com/SmartPotTech/SmartPot-API-Go/internal/platform/apperr" + "github.com/labstack/echo/v4" +) + +type fakeConnections map[string]ConnectionPrincipal + +func (f fakeConnections) AuthenticateConnection(_ context.Context, raw string) (ConnectionPrincipal, error) { + if p, ok := f[raw]; ok { + return p, nil + } + return ConnectionPrincipal{}, apperr.Unauthorized("El token de conexión no es válido o fue revocado") +} + +func TestGuardConnectionTokens(t *testing.T) { + svc := NewTokenService(testSecret, time.Hour) + svc.UseConnections(fakeConnections{ + "spt_lectura": {ConnectionID: "c1", UserID: "user-1", Scope: "READ"}, + "spt_control": {ConnectionID: "c2", UserID: "user-1", Scope: "CONTROL"}, + }) + e := httpserver.New(discardLogger()) + grp := e.Group("/api/v1") + grp.Use(svc.Guard()) + ok := func(c echo.Context) error { + id := CurrentIdentity(c) + if id.Role != RoleUser || id.ConnectionID == "" { + t.Errorf("identidad de conexión incompleta: %+v", id) + } + return c.String(http.StatusOK, CurrentUserID(c)) + } + grp.GET("/crops", ok) + grp.POST("/crops/:id/commands", ok) + grp.GET("/crops/:id/device", ok) + grp.GET("/connections", ok) + grp.PUT("/users/me", ok) + grp.GET("/users/me", ok) + grp.GET("/admin", ok, RequireRole(RoleAdmin)) + + cases := []struct { + name, method, path, token string + want int + }{ + {"token desconocido", http.MethodGet, "/api/v1/crops", "spt_otro", http.StatusUnauthorized}, + {"lectura consulta", http.MethodGet, "/api/v1/crops", "spt_lectura", http.StatusOK}, + {"lectura no acciona", http.MethodPost, "/api/v1/crops/abc/commands", "spt_lectura", http.StatusForbidden}, + {"control acciona", http.MethodPost, "/api/v1/crops/abc/commands", "spt_control", http.StatusOK}, + {"credenciales del dispositivo", http.MethodGet, "/api/v1/crops/abc/device", "spt_control", http.StatusForbidden}, + {"no administra conexiones", http.MethodGet, "/api/v1/connections", "spt_control", http.StatusForbidden}, + {"no cambia la cuenta", http.MethodPut, "/api/v1/users/me", "spt_control", http.StatusForbidden}, + {"sí lee la cuenta", http.MethodGet, "/api/v1/users/me", "spt_lectura", http.StatusOK}, + {"nunca es administrador", http.MethodGet, "/api/v1/admin", "spt_control", http.StatusForbidden}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, nil) + req.Header.Set("Authorization", "Bearer "+tc.token) + e.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Fatalf("código %d, se esperaba %d (%s)", rec.Code, tc.want, rec.Body.String()) + } + }) + } +} + +func TestGuardRejectsConnectionTokensWhenDisabled(t *testing.T) { + svc := NewTokenService(testSecret, time.Hour) + e := httpserver.New(discardLogger()) + grp := e.Group("/api/v1") + grp.Use(svc.Guard()) + grp.GET("/crops", func(c echo.Context) error { return c.NoContent(http.StatusOK) }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/crops", nil) + req.Header.Set("Authorization", "Bearer spt_lo-que-sea") + e.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("código %d, se esperaba 401", rec.Code) + } +} diff --git a/internal/security/jwt.go b/internal/security/jwt.go index c948f3b..a5a4e50 100644 --- a/internal/security/jwt.go +++ b/internal/security/jwt.go @@ -20,6 +20,8 @@ type TokenService struct { secret []byte expiration time.Duration now func() time.Time + // connections, si existe, permite que el guard acepte tokens de conexión. + connections ConnectionAuthenticator } // NewTokenService crea el servicio con el secreto y la vigencia del token. diff --git a/internal/storage/postgres/connections.go b/internal/storage/postgres/connections.go new file mode 100644 index 0000000..d244e80 --- /dev/null +++ b/internal/storage/postgres/connections.go @@ -0,0 +1,111 @@ +package postgres + +import ( + "context" + "errors" + "time" + + "github.com/SmartPotTech/SmartPot-API-Go/internal/connections" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// AccessTokenRepository implementa connections.Repository sobre PostgreSQL (tabla access_tokens). +type AccessTokenRepository struct { + pool *pgxpool.Pool +} + +// NewAccessTokenRepository crea el adaptador de conexiones. +func NewAccessTokenRepository(pool *pgxpool.Pool) *AccessTokenRepository { + return &AccessTokenRepository{pool: pool} +} + +const accessTokenColumns = "id, user_id, name, token_hash, prefix, scope, created_at, last_used_at, expires_at" + +func scanAccessToken(row pgx.Row) (*connections.AccessToken, error) { + var ( + t connections.AccessToken + id, userID string + scope string + ) + if err := row.Scan(&id, &userID, &t.Name, &t.TokenHash, &t.Prefix, &scope, &t.CreatedAt, &t.LastUsedAt, + &t.ExpiresAt); err != nil { + return nil, err + } + var err error + if t.ID, err = parseOID(id); err != nil { + return nil, err + } + if t.UserID, err = parseOID(userID); err != nil { + return nil, err + } + t.Scope = connections.Scope(scope) + return &t, nil +} + +// Insert guarda una conexión nueva. +func (r *AccessTokenRepository) Insert(ctx context.Context, t *connections.AccessToken) error { + _, err := r.pool.Exec(ctx, + "INSERT INTO access_tokens ("+accessTokenColumns+") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + hexOf(t.ID), hexOf(t.UserID), t.Name, t.TokenHash, t.Prefix, string(t.Scope), t.CreatedAt, t.LastUsedAt, + t.ExpiresAt) + return err +} + +// ListByUser devuelve las conexiones de una cuenta, de la más nueva a la más antigua. +func (r *AccessTokenRepository) ListByUser(ctx context.Context, userID primitive.ObjectID) ([]connections.AccessToken, error) { + rows, err := r.pool.Query(ctx, + "SELECT "+accessTokenColumns+" FROM access_tokens WHERE user_id = $1 ORDER BY created_at DESC", hexOf(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]connections.AccessToken, 0) + for rows.Next() { + t, err := scanAccessToken(rows) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +// CountByUser cuenta las conexiones de una cuenta. +func (r *AccessTokenRepository) CountByUser(ctx context.Context, userID primitive.ObjectID) (int64, error) { + var n int64 + err := r.pool.QueryRow(ctx, "SELECT count(*) FROM access_tokens WHERE user_id = $1", hexOf(userID)).Scan(&n) + return n, err +} + +// FindByHash busca una conexión por el hash de su token, o nil si no existe. +func (r *AccessTokenRepository) FindByHash(ctx context.Context, hash string) (*connections.AccessToken, error) { + t, err := scanAccessToken(r.pool.QueryRow(ctx, + "SELECT "+accessTokenColumns+" FROM access_tokens WHERE token_hash = $1", hash)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return t, err +} + +// DeleteByIDAndUser revoca una conexión de la cuenta; devuelve cuántas filas borró. +func (r *AccessTokenRepository) DeleteByIDAndUser(ctx context.Context, id, userID primitive.ObjectID) (int64, error) { + tag, err := r.pool.Exec(ctx, "DELETE FROM access_tokens WHERE id = $1 AND user_id = $2", hexOf(id), hexOf(userID)) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// DeleteByUser borra todas las conexiones de una cuenta. +func (r *AccessTokenRepository) DeleteByUser(ctx context.Context, userID primitive.ObjectID) error { + _, err := r.pool.Exec(ctx, "DELETE FROM access_tokens WHERE user_id = $1", hexOf(userID)) + return err +} + +// TouchLastUsed registra el último uso de una conexión. +func (r *AccessTokenRepository) TouchLastUsed(ctx context.Context, id primitive.ObjectID, at time.Time) error { + _, err := r.pool.Exec(ctx, "UPDATE access_tokens SET last_used_at = $2 WHERE id = $1", hexOf(id), at) + return err +}