wip: encriptacion

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-07 17:25:16 -06:00
parent aac383fc83
commit 50e028827f
9 changed files with 343 additions and 17 deletions

View File

@ -0,0 +1,182 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Log;
class EncryptionHelper
{
/**
* Encrypt the given data (arrays/objects).
*/
public static function encryptData($data)
{
try {
return Crypt::encryptString(json_encode($data));
} catch (\Exception $e) {
throw new \RuntimeException("Error al encriptar los datos: " . $e->getMessage());
}
}
/**
* Decrypt the given data (arrays/objects).
*/
public static function decryptData($encryptedData)
{
try {
$decrypted = Crypt::decryptString($encryptedData);
return json_decode($decrypted, true);
} catch (DecryptException $e) {
Log::error('Error al desencriptar los datos: ' . $e->getMessage());
return null;
} catch (\Exception $e) {
Log::error('Error inesperado al desencriptar los datos: ' . $e->getMessage());
return null;
}
}
/**
* Encrypt a simple string (for tokens, passwords, etc.)
*/
public static function encryptString(string $string): string
{
try {
return Crypt::encryptString($string);
} catch (\Exception $e) {
throw new \RuntimeException("Error al encriptar el string: " . $e->getMessage());
}
}
/**
* Decrypt a simple string
*/
public static function decryptString(string $encryptedString): ?string
{
try {
return Crypt::decryptString($encryptedString);
} catch (DecryptException $e) {
Log::error('Error al desencriptar el string: ' . $e->getMessage());
return null;
} catch (\Exception $e) {
Log::error('Error inesperado al desencriptar el string: ' . $e->getMessage());
return null;
}
}
/**
* Encrypt using a custom key (independent of APP_KEY)
* Useful for tokens that should survive APP_KEY rotation
*/
public static function encryptWithCustomKey(string $string, string $key): string
{
try {
$cipher = 'AES-256-CBC';
$ivLength = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($ivLength);
$encrypted = openssl_encrypt($string, $cipher, $key, 0, $iv);
if ($encrypted === false) {
throw new \RuntimeException('Encryption failed');
}
// Combinar IV + encrypted data y codificar en base64
return base64_encode($iv . $encrypted);
} catch (\Exception $e) {
throw new \RuntimeException("Error al encriptar con clave personalizada: " . $e->getMessage());
}
}
/**
* Decrypt using a custom key (independent of APP_KEY)
*/
public static function decryptWithCustomKey(string $encryptedString, string $key): ?string
{
try {
$cipher = 'AES-256-CBC';
$ivLength = openssl_cipher_iv_length($cipher);
// Decodificar y separar IV + encrypted data
$data = base64_decode($encryptedString);
if ($data === false) {
return null;
}
$iv = substr($data, 0, $ivLength);
$encrypted = substr($data, $ivLength);
$decrypted = openssl_decrypt($encrypted, $cipher, $key, 0, $iv);
if ($decrypted === false) {
Log::error('Error al desencriptar con clave personalizada');
return null;
}
return $decrypted;
} catch (\Exception $e) {
Log::error('Error inesperado al desencriptar con clave personalizada: ' . $e->getMessage());
return null;
}
}
/**
* Verify if a plain value matches a value encrypted with custom key
*/
public static function verifyWithCustomKey(string $plainValue, string $encryptedValue, string $key): bool
{
try {
$decrypted = self::decryptWithCustomKey($encryptedValue, $key);
return $decrypted === $plainValue;
} catch (\Exception $e) {
return false;
}
}
/**
* Generate a hash for searchable encrypted data
*/
public static function hash(string $data, string $algorithm = 'sha256'): string
{
return hash($algorithm, $data);
}
/**
* Encrypt multiple fields in an array
*/
public static function encryptFields(array $data, array $fields): array
{
foreach ($fields as $field) {
if (isset($data[$field])) {
$data[$field] = self::encryptData($data[$field]);
}
}
return $data;
}
/**
* Decrypt multiple fields in an array
*/
public static function decryptFields(array $data, array $fields): array
{
foreach ($fields as $field) {
if (isset($data[$field])) {
$data[$field] = self::decryptData($data[$field]);
}
}
return $data;
}
/**
* Verify if a plain value matches an encrypted value
*/
public static function verifyEncrypted(string $plainValue, string $encryptedValue): bool
{
try {
$decrypted = self::decryptString($encryptedValue);
return $decrypted === $plainValue;
} catch (\Exception $e) {
return false;
}
}
}

View File

@ -32,8 +32,13 @@ public function index(Request $request)
$arcos = $query->orderBy('created_at', 'desc')->get();
// Agregar token desencriptado a cada arco
$arcos->each(function($arco) {
$arco->api_token_plain = $arco->obtenerTokenDesencriptado();
});
return ApiResponse::OK->response([
'arcos' => $arcos
'arcos' => $arcos,
]);
}
@ -48,15 +53,22 @@ public function store(Request $request)
'ip_address' => 'required|ip|unique:arcos,ip_address',
'ubicacion' => 'nullable|string|max:255',
'descripcion' => 'nullable|string',
'antena_1' => 'nullable|string',
'antena_2' => 'nullable|string',
'antena_3' => 'nullable|string',
'antena_4' => 'nullable|string',
'activo' => 'boolean'
]);
$arco = Arco::create($validated);
// Obtener el token en texto plano para mostrárselo al usuario
$plainToken = $arco->obtenerTokenDesencriptado();
return ApiResponse::CREATED->response([
'message' => 'Arco creado exitosamente',
'arco' => $arco,
'api_token' => $arco->api_token
'api_token' => $plainToken // Token en texto plano para que el arco lo use
]);
}
@ -100,7 +112,10 @@ public function update(Request $request, int $id)
'ip_address' => 'sometimes|required|ip|unique:arcos,ip_address,' . $id,
'ubicacion' => 'nullable|string|max:255',
'descripcion' => 'nullable|string',
'activo' => 'boolean'
'antena_1' => 'nullable|string',
'antena_2' => 'nullable|string',
'antena_3' => 'nullable|string',
'antena_4' => 'nullable|string'
]);
$arco->update($validated);

View File

@ -15,15 +15,16 @@ class ArcoTokenMiddleware
*/
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
$tokenPlano = $request->bearerToken();
if (!$token) {
if (!$tokenPlano) {
return ApiResponse::UNAUTHORIZED->response([
'message' => 'Token de arco no proporcionado'
]);
}
$arco = Arco::buscarPorToken($token);
// Buscar arco por token plano (el método desencripta internamente)
$arco = Arco::buscarPorToken($tokenPlano);
if (!$arco) {
return ApiResponse::UNAUTHORIZED->response([
@ -37,6 +38,20 @@ public function handle(Request $request, Closure $next): Response
]);
}
// Validación de IP deshabilitada para desarrollo
// TODO: Habilitar en producción o configurar nginx para enviar X-Forwarded-For
// $requestIp = $request->ip();
// if ($arco->ip_address !== $requestIp) {
// return ApiResponse::FORBIDDEN->response([
// 'message' => 'Token no autorizado para esta IP',
// 'detail' => [
// 'ip_registrada_arco' => $arco->ip_address,
// 'ip_del_request' => $requestIp,
// 'arco_nombre' => $arco->nombre
// ]
// ]);
// }
// Agregar el arco al request para uso posterior
$request->merge(['arco_autenticado' => $arco]);

View File

@ -3,7 +3,7 @@
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Helpers\EncryptionHelper;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
@ -25,7 +25,11 @@ class Arco extends Model
'nombre',
'ip_address',
'ubicacion',
'activo'
'activo',
'antena_1',
'antena_2',
'antena_3',
'antena_4'
];
protected $casts = [
@ -44,7 +48,8 @@ protected static function boot()
static::creating(function ($arco) {
if (!$arco->api_token) {
$arco->api_token = Str::random(64);
$plainToken = Str::random(64);
$arco->api_token = EncryptionHelper::encryptString($plainToken);
}
});
}
@ -74,20 +79,39 @@ public static function buscarPorIp(string $ip): ?self
}
/**
* Buscar arco por API token
* Buscar arco por API token (recibe token plano, busca desencriptando)
*/
public static function buscarPorToken(string $token): ?self
public static function buscarPorToken(string $tokenPlano): ?self
{
return self::where('api_token', $token)->first();
$arcos = self::all();
foreach ($arcos as $arco) {
// Desencriptar el token de la BD y comparar con el token plano recibido
if (EncryptionHelper::verifyEncrypted($tokenPlano, $arco->api_token)) {
return $arco;
}
}
return null;
}
/**
* Regenerar API token
* Regenerar token encriptado
*/
public function regenerarToken(): string
{
$this->api_token = Str::random(64);
$plainToken = Str::random(64);
$this->api_token = EncryptionHelper::encryptString($plainToken);
$this->save();
return $this->api_token;
return $plainToken;
}
/**
* Obtener token desencriptado
*/
public function obtenerTokenDesencriptado(): ?string
{
return EncryptionHelper::decryptString($this->api_token);
}
}

View File

@ -17,6 +17,7 @@ class Detection extends Model
public $timestamps = false;
protected $fillable = [
'arco_id',
'epc',
'vin',
'placa',
@ -29,4 +30,12 @@ class Detection extends Model
protected $casts = [
'fecha_deteccion' => 'datetime:Y-m-d H:i:s'
];
/**
* Relación con el modelo Arco
*/
public function arco()
{
return $this->belongsTo(Arco::class, 'arco_id');
}
}

View File

@ -45,14 +45,14 @@ private function consultarNuevoVehiculo(string $epc, string $fastId): array
$datosVehiculo = $this->consultaRepuveCons->consultarVehiculoPorTag($fastId);
if (!$datosVehiculo || !$datosVehiculo['vin']) {
Log::warning('Vehículo NO encontrado en API externa', [
Log::warning('Vehículo NO encontrado.', [
'epc' => $epc,
'fast_id' => $fastId
]);
return [
'success' => false,
'message' => 'No se encontró información del vehículo en la API externa'
'message' => 'No se encontró información del vehículo.'
];
}

View File

@ -38,6 +38,15 @@
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'arco.token' => \App\Http\Middleware\ArcoTokenMiddleware::class,
]);
// Configurar proxies confiables para detectar IP real en Docker
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO
);
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ServiceUnavailableHttpException $e, Request $request) {

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$indexes = DB::select("SHOW INDEX FROM arcos WHERE Column_name = 'api_token'");
foreach ($indexes as $index) {
$indexName = $index->Key_name;
try {
DB::statement("ALTER TABLE arcos DROP INDEX `{$indexName}`");
} catch (\Exception $e) {
}
}
DB::statement('ALTER TABLE arcos MODIFY COLUMN api_token TEXT NULL');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement('ALTER TABLE arcos MODIFY COLUMN api_token VARCHAR(64) NULL');
Schema::table('arcos', function (Blueprint $table) {
$table->unique('api_token');
$table->index('api_token');
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('arcos', function (Blueprint $table) {
// Agregar 4 campos para antenas (todas opcionales/nullable)
$table->string('antena_1')->nullable()->after('activo');
$table->string('antena_2')->nullable()->after('antena_1');
$table->string('antena_3')->nullable()->after('antena_2');
$table->string('antena_4')->nullable()->after('antena_3');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('arcos', function (Blueprint $table) {
$table->dropColumn(['antena_1', 'antena_2', 'antena_3', 'antena_4']);
});
}
};