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