- 修复已登录用户访问 /remote/:token 路由被重定向到首页的问题
- 路由守卫优先检查静态路由,静态路由直接放行不走权限验证
- 后端生成的 accessUrl 使用 Hash 路由格式 (/#/remote/{token})
- 前端 remote-desktop-modal 中修正链接格式为 Hash 路由
- 新增远程桌面访问页面 /views/remote/index.vue
332 lines
12 KiB
C#
332 lines
12 KiB
C#
using AmtScanner.Api.Data;
|
||
using AmtScanner.Api.Models;
|
||
using AmtScanner.Api.Services;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using System.Security.Cryptography;
|
||
|
||
namespace AmtScanner.Api.Controllers;
|
||
|
||
[ApiController]
|
||
[Route("api/[controller]")]
|
||
public class RemoteDesktopController : ControllerBase
|
||
{
|
||
private readonly IGuacamoleService _guacamoleService;
|
||
private readonly AppDbContext _context;
|
||
private readonly ILogger<RemoteDesktopController> _logger;
|
||
|
||
public RemoteDesktopController(
|
||
IGuacamoleService guacamoleService,
|
||
AppDbContext context,
|
||
ILogger<RemoteDesktopController> logger)
|
||
{
|
||
_guacamoleService = guacamoleService;
|
||
_context = context;
|
||
_logger = logger;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成远程访问 Token(管理员使用)
|
||
/// </summary>
|
||
[HttpPost("generate-token/{deviceId}")]
|
||
public async Task<ActionResult<ApiResponse<GenerateTokenResponse>>> GenerateToken(
|
||
long deviceId,
|
||
[FromBody] GenerateTokenRequest request)
|
||
{
|
||
var device = await _context.AmtDevices.FindAsync(deviceId);
|
||
if (device == null)
|
||
return Ok(ApiResponse<GenerateTokenResponse>.Fail(404, "设备不存在"));
|
||
|
||
WindowsCredential? credential = null;
|
||
if (request.CredentialId.HasValue)
|
||
{
|
||
credential = await _context.WindowsCredentials.FindAsync(request.CredentialId.Value);
|
||
}
|
||
else
|
||
{
|
||
credential = await _context.WindowsCredentials.FirstOrDefaultAsync(c => c.IsDefault);
|
||
}
|
||
|
||
if (credential == null)
|
||
return Ok(ApiResponse<GenerateTokenResponse>.Fail(400, "请先配置 Windows 凭据"));
|
||
|
||
var token = GenerateRandomToken();
|
||
var expiresAt = DateTime.UtcNow.AddMinutes(request.ExpiresInMinutes ?? 30);
|
||
|
||
var accessToken = new RemoteAccessToken
|
||
{
|
||
Token = token,
|
||
DeviceId = deviceId,
|
||
WindowsCredentialId = credential.Id,
|
||
ExpiresAt = expiresAt,
|
||
MaxUseCount = request.MaxUseCount ?? 1,
|
||
Note = request.Note
|
||
};
|
||
|
||
_context.RemoteAccessTokens.Add(accessToken);
|
||
await _context.SaveChangesAsync();
|
||
|
||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||
var accessUrl = $"{baseUrl}/remote/{token}";
|
||
|
||
_logger.LogInformation("Generated remote access token for device {Ip}, expires at {ExpiresAt}",
|
||
device.IpAddress, expiresAt);
|
||
|
||
return Ok(ApiResponse<GenerateTokenResponse>.Success(new GenerateTokenResponse
|
||
{
|
||
Success = true,
|
||
Token = token,
|
||
AccessUrl = accessUrl,
|
||
ExpiresAt = expiresAt,
|
||
MaxUseCount = accessToken.MaxUseCount,
|
||
DeviceIp = device.IpAddress
|
||
}, "Token 生成成功"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 通过 Token 连接远程桌面
|
||
/// </summary>
|
||
[HttpGet("connect-by-token/{token}")]
|
||
public async Task<ActionResult<ApiResponse<RemoteDesktopResponse>>> ConnectByToken(string token)
|
||
{
|
||
var accessToken = await _context.RemoteAccessTokens
|
||
.Include(t => t.Device)
|
||
.Include(t => t.WindowsCredential)
|
||
.FirstOrDefaultAsync(t => t.Token == token);
|
||
|
||
if (accessToken == null)
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(404, "无效的访问链接"));
|
||
|
||
if (!accessToken.IsValid())
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(400, "访问链接已过期或已达到使用次数上限"));
|
||
|
||
if (accessToken.Device == null || accessToken.WindowsCredential == null)
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(400, "设备或凭据信息不完整"));
|
||
|
||
accessToken.UseCount++;
|
||
accessToken.UsedAt = DateTime.UtcNow;
|
||
await _context.SaveChangesAsync();
|
||
|
||
var guacToken = await _guacamoleService.GetAuthTokenAsync();
|
||
if (string.IsNullOrEmpty(guacToken))
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(503, "无法连接到 Guacamole 服务"));
|
||
|
||
var connectionName = $"AMT-{accessToken.Device.IpAddress}";
|
||
var connectionId = await _guacamoleService.CreateOrGetConnectionAsync(
|
||
guacToken, connectionName, accessToken.Device.IpAddress,
|
||
accessToken.WindowsCredential.Username, accessToken.WindowsCredential.Password);
|
||
|
||
if (string.IsNullOrEmpty(connectionId))
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(500, "创建远程连接失败"));
|
||
|
||
var connectionUrl = await _guacamoleService.GetConnectionUrlAsync(guacToken, connectionId);
|
||
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Success(new RemoteDesktopResponse
|
||
{
|
||
Success = true,
|
||
ConnectionUrl = connectionUrl,
|
||
ConnectionId = connectionId,
|
||
Token = guacToken,
|
||
DeviceIp = accessToken.Device.IpAddress
|
||
}));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 验证 Token 是否有效
|
||
/// </summary>
|
||
[HttpGet("validate-token/{token}")]
|
||
public async Task<ActionResult<ApiResponse<ValidateTokenResponse>>> ValidateToken(string token)
|
||
{
|
||
var accessToken = await _context.RemoteAccessTokens
|
||
.Include(t => t.Device)
|
||
.FirstOrDefaultAsync(t => t.Token == token);
|
||
|
||
if (accessToken == null)
|
||
return Ok(ApiResponse<ValidateTokenResponse>.Success(new ValidateTokenResponse { Valid = false, Error = "无效的访问链接" }));
|
||
|
||
if (!accessToken.IsValid())
|
||
return Ok(ApiResponse<ValidateTokenResponse>.Success(new ValidateTokenResponse { Valid = false, Error = "访问链接已过期或已达到使用次数上限" }));
|
||
|
||
return Ok(ApiResponse<ValidateTokenResponse>.Success(new ValidateTokenResponse
|
||
{
|
||
Valid = true,
|
||
DeviceIp = accessToken.Device?.IpAddress,
|
||
ExpiresAt = accessToken.ExpiresAt,
|
||
RemainingUses = accessToken.MaxUseCount > 0 ? accessToken.MaxUseCount - accessToken.UseCount : -1
|
||
}));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取设备的所有有效 Token
|
||
/// </summary>
|
||
[HttpGet("list-tokens/{deviceId}")]
|
||
public async Task<ActionResult<ApiResponse<List<TokenInfoDto>>>> GetDeviceTokens(long deviceId)
|
||
{
|
||
var tokens = await _context.RemoteAccessTokens
|
||
.Where(t => t.DeviceId == deviceId && t.ExpiresAt > DateTime.UtcNow)
|
||
.OrderByDescending(t => t.CreatedAt)
|
||
.Select(t => new TokenInfoDto
|
||
{
|
||
Id = t.Id,
|
||
Token = t.Token,
|
||
CreatedAt = t.CreatedAt,
|
||
ExpiresAt = t.ExpiresAt,
|
||
MaxUseCount = t.MaxUseCount,
|
||
UseCount = t.UseCount,
|
||
Note = t.Note
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Ok(ApiResponse<List<TokenInfoDto>>.Success(tokens));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 撤销 Token
|
||
/// </summary>
|
||
[HttpDelete("revoke-token/{tokenId}")]
|
||
public async Task<ActionResult<ApiResponse<object>>> RevokeToken(long tokenId)
|
||
{
|
||
var token = await _context.RemoteAccessTokens.FindAsync(tokenId);
|
||
if (token == null)
|
||
return Ok(ApiResponse<object>.Fail(404, "Token 不存在"));
|
||
|
||
_context.RemoteAccessTokens.Remove(token);
|
||
await _context.SaveChangesAsync();
|
||
return Ok(ApiResponse<object>.Success(null, "Token 已撤销"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清理过期 Token
|
||
/// </summary>
|
||
[HttpPost("cleanup-tokens")]
|
||
public async Task<ActionResult<ApiResponse<CleanupTokensResponse>>> CleanupExpiredTokens()
|
||
{
|
||
var count = await _context.RemoteAccessTokens
|
||
.Where(t => t.ExpiresAt < DateTime.UtcNow)
|
||
.ExecuteDeleteAsync();
|
||
return Ok(ApiResponse<CleanupTokensResponse>.Success(new CleanupTokensResponse { DeletedCount = count }, "已清理 " + count + " 个过期 Token"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 直接连接(需要输入凭据)
|
||
/// </summary>
|
||
[HttpPost("connect/{deviceId}")]
|
||
public async Task<ActionResult<ApiResponse<RemoteDesktopResponse>>> Connect(long deviceId, [FromBody] RdpCredentials credentials)
|
||
{
|
||
var device = await _context.AmtDevices.FindAsync(deviceId);
|
||
if (device == null)
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(404, "设备不存在"));
|
||
|
||
var guacToken = await _guacamoleService.GetAuthTokenAsync();
|
||
if (string.IsNullOrEmpty(guacToken))
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(503, "无法连接到 Guacamole 服务"));
|
||
|
||
var connectionName = $"AMT-{device.IpAddress}";
|
||
var connectionId = await _guacamoleService.CreateOrGetConnectionAsync(
|
||
guacToken, connectionName, device.IpAddress, credentials.Username, credentials.Password);
|
||
|
||
if (string.IsNullOrEmpty(connectionId))
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(500, "创建远程连接失败"));
|
||
|
||
var connectionUrl = await _guacamoleService.GetConnectionUrlAsync(guacToken, connectionId);
|
||
|
||
return Ok(ApiResponse<RemoteDesktopResponse>.Success(new RemoteDesktopResponse
|
||
{
|
||
Success = true,
|
||
ConnectionUrl = connectionUrl,
|
||
ConnectionId = connectionId,
|
||
Token = guacToken
|
||
}));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 测试 Guacamole 连接
|
||
/// </summary>
|
||
[HttpGet("test")]
|
||
public async Task<ActionResult<ApiResponse<TestConnectionResponse>>> TestConnection()
|
||
{
|
||
var token = await _guacamoleService.GetAuthTokenAsync();
|
||
if (string.IsNullOrEmpty(token))
|
||
return Ok(ApiResponse<TestConnectionResponse>.Fail(503, "无法连接到 Guacamole 服务"));
|
||
return Ok(ApiResponse<TestConnectionResponse>.Success(new TestConnectionResponse { Success = true, Message = "Guacamole 服务连接正常" }));
|
||
}
|
||
|
||
private static string GenerateRandomToken()
|
||
{
|
||
var bytes = new byte[24];
|
||
using var rng = RandomNumberGenerator.Create();
|
||
rng.GetBytes(bytes);
|
||
return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").Replace("=", "");
|
||
}
|
||
}
|
||
|
||
#region Request/Response Models
|
||
|
||
public class GenerateTokenRequest
|
||
{
|
||
public long? CredentialId { get; set; }
|
||
public int? ExpiresInMinutes { get; set; } = 30;
|
||
public int? MaxUseCount { get; set; } = 1;
|
||
public string? Note { get; set; }
|
||
}
|
||
|
||
public class GenerateTokenResponse
|
||
{
|
||
public bool Success { get; set; }
|
||
public string Token { get; set; } = string.Empty;
|
||
public string AccessUrl { get; set; } = string.Empty;
|
||
public DateTime ExpiresAt { get; set; }
|
||
public int MaxUseCount { get; set; }
|
||
public string? DeviceIp { get; set; }
|
||
public string? Error { get; set; }
|
||
}
|
||
|
||
public class ValidateTokenResponse
|
||
{
|
||
public bool Valid { get; set; }
|
||
public string? DeviceIp { get; set; }
|
||
public DateTime? ExpiresAt { get; set; }
|
||
public int RemainingUses { get; set; }
|
||
public string? Error { get; set; }
|
||
}
|
||
|
||
public class TokenInfoDto
|
||
{
|
||
public long Id { get; set; }
|
||
public string Token { get; set; } = string.Empty;
|
||
public DateTime CreatedAt { get; set; }
|
||
public DateTime ExpiresAt { get; set; }
|
||
public int MaxUseCount { get; set; }
|
||
public int UseCount { get; set; }
|
||
public string? Note { get; set; }
|
||
}
|
||
|
||
public class RdpCredentials
|
||
{
|
||
public string Username { get; set; } = string.Empty;
|
||
public string Password { get; set; } = string.Empty;
|
||
}
|
||
|
||
public class RemoteDesktopResponse
|
||
{
|
||
public bool Success { get; set; }
|
||
public string? ConnectionUrl { get; set; }
|
||
public string? ConnectionId { get; set; }
|
||
public string? Token { get; set; }
|
||
public string? DeviceIp { get; set; }
|
||
public string? Error { get; set; }
|
||
}
|
||
|
||
public class CleanupTokensResponse
|
||
{
|
||
public int DeletedCount { get; set; }
|
||
}
|
||
|
||
public class TestConnectionResponse
|
||
{
|
||
public bool Success { get; set; }
|
||
public string Message { get; set; } = string.Empty;
|
||
}
|
||
|
||
#endregion
|