Add PairingService with safe alphabet and 10-min TTL
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
using backend.Pairing;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class PairingServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Generated_codes_use_only_safe_alphabet_and_are_8_chars()
|
||||||
|
{
|
||||||
|
var svc = new PairingService();
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
var code = svc.GenerateCode();
|
||||||
|
code.Length.Should().Be(8);
|
||||||
|
code.Should().MatchRegex("^[A-HJ-KM-NP-Z2-9]+$");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generated_codes_are_not_all_identical()
|
||||||
|
{
|
||||||
|
var svc = new PairingService();
|
||||||
|
var codes = Enumerable.Range(0, 20).Select(_ => svc.GenerateCode()).ToHashSet();
|
||||||
|
codes.Count.Should().BeGreaterThan(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using backend.Data;
|
||||||
|
using backend.Devices;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace backend.Pairing;
|
||||||
|
|
||||||
|
public record PairResult(Guid DeviceId, string DeviceToken);
|
||||||
|
|
||||||
|
public class PairingService
|
||||||
|
{
|
||||||
|
private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O, 0, I, 1, L
|
||||||
|
private const int CodeLength = 8;
|
||||||
|
public static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
private readonly AppDbContext? _db;
|
||||||
|
private readonly DeviceTokenService? _tokens;
|
||||||
|
|
||||||
|
public PairingService() { }
|
||||||
|
|
||||||
|
public PairingService(AppDbContext db, DeviceTokenService tokens)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tokens = tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateCode()
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[CodeLength];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
Span<char> chars = stackalloc char[CodeLength];
|
||||||
|
for (var i = 0; i < CodeLength; i++)
|
||||||
|
chars[i] = Alphabet[bytes[i] % Alphabet.Length];
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PairingCode> IssueAsync(Guid ownerUserId, string deviceName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_db is null) throw new InvalidOperationException("PairingService not constructed with DB context.");
|
||||||
|
var entity = new PairingCode
|
||||||
|
{
|
||||||
|
Code = GenerateCode(),
|
||||||
|
OwnerUserId = ownerUserId,
|
||||||
|
DeviceName = deviceName,
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeTtl),
|
||||||
|
};
|
||||||
|
_db.PairingCodes.Add(entity);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PairResult?> ConsumeAsync(string code, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_db is null || _tokens is null)
|
||||||
|
throw new InvalidOperationException("PairingService not constructed with DB context + token service.");
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var pc = await _db.PairingCodes.FirstOrDefaultAsync(p => p.Code == code, ct);
|
||||||
|
if (pc is null || pc.ConsumedAt is not null || pc.ExpiresAt < now)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var (plaintext, hash) = _tokens.Generate();
|
||||||
|
var defaults = await _db.SystemSettings.FirstAsync(ct);
|
||||||
|
|
||||||
|
var device = new Device
|
||||||
|
{
|
||||||
|
OwnerUserId = pc.OwnerUserId,
|
||||||
|
Name = pc.DeviceName,
|
||||||
|
PairedAt = now,
|
||||||
|
TokenHash = hash,
|
||||||
|
};
|
||||||
|
device.Config = new DeviceConfig
|
||||||
|
{
|
||||||
|
DeviceId = device.Id,
|
||||||
|
SystemPrompt = defaults.DefaultSystemPrompt,
|
||||||
|
Voice = defaults.DefaultVoice,
|
||||||
|
Model = defaults.DefaultModel,
|
||||||
|
IdleTimeoutSeconds = defaults.DefaultIdleTimeoutSeconds,
|
||||||
|
};
|
||||||
|
_db.Devices.Add(device);
|
||||||
|
pc.ConsumedAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new PairResult(device.Id, plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user