Add user identity system with channel pairing and web /link command
Introduce Luna.Identity project with user identity service, pairing code generation, and channel linking. Channels (Telegram, CLI) now verify link status before routing messages and prompt users to pair via the web interface. WebChannel handles /link <channel-type> <code> commands to complete the pairing flow. Fix inverted expiration check in IsPairingCodeExpired that caused codes to be treated as expired immediately after creation.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
using Luna.Identity.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Luna.Identity.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
extension(IServiceCollection services)
|
||||
{
|
||||
public IServiceCollection AddUserIdentity()
|
||||
{
|
||||
services.AddSingleton<IUserIdentityService, UserIdentityService>();
|
||||
services.AddSingleton<IPairingCodeGenerator, PairingCodeGenerator>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Luna.Channels.Abstractions\Luna.Channels.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Luna.Identity.Abstractions\Luna.Identity.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Luna.Shared\Luna.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
using Luna.Identity.Abstractions;
|
||||
|
||||
namespace Luna.Identity;
|
||||
|
||||
public class PairingCodeGenerator : IPairingCodeGenerator
|
||||
{
|
||||
private const int TimeToLiveMinutes = 5;
|
||||
private static readonly char[] CodeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".ToCharArray();
|
||||
|
||||
public PairingCode Generate(string channelType, string userId)
|
||||
=> new(
|
||||
Code: GenerateCode(),
|
||||
ChannelType: channelType,
|
||||
UserId: userId,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
TimeToLive: TimeSpan.FromMinutes(TimeToLiveMinutes));
|
||||
|
||||
private string GenerateCode()
|
||||
=> new(Enumerable.Range(0, 6)
|
||||
.Select(_ => CodeAlphabet[Random.Shared.Next(CodeAlphabet.Length)])
|
||||
.ToArray());
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Luna.Identity.Abstractions;
|
||||
|
||||
namespace Luna.Identity;
|
||||
|
||||
public class UserIdentityService(IPairingCodeGenerator pairingCodeGenerator) : IUserIdentityService
|
||||
{
|
||||
// Todo: User creation
|
||||
private readonly UserIdentity currentUser = new(
|
||||
UserId: Guid.NewGuid().ToString(),
|
||||
UserName: "Erik");
|
||||
|
||||
private readonly ConcurrentDictionary<string, IList<PairingCode>> pairingCodes = new();
|
||||
private readonly ConcurrentDictionary<string, IList<ChannelLink>> linkedChannels = new();
|
||||
|
||||
public UserIdentity GetCurrentUser() => currentUser;
|
||||
|
||||
public string StartChannelLink(string userId, string channelType)
|
||||
{
|
||||
if (!pairingCodes.ContainsKey(userId))
|
||||
pairingCodes[userId] = new List<PairingCode>();
|
||||
|
||||
var existingCode = pairingCodes[userId]
|
||||
.SingleOrDefault(c => c.ChannelType == channelType);
|
||||
|
||||
switch (existingCode)
|
||||
{
|
||||
case {} when !IsPairingCodeExpired(existingCode):
|
||||
return existingCode.Code;
|
||||
case {} when IsPairingCodeExpired(existingCode):
|
||||
pairingCodes[userId].Remove(existingCode);
|
||||
return CreateNewPairingCode(userId, channelType);
|
||||
default:
|
||||
return CreateNewPairingCode(userId, channelType);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteChannelLink(string userId, string channelType, string code)
|
||||
{
|
||||
if (!linkedChannels.ContainsKey(userId))
|
||||
linkedChannels[userId] = new List<ChannelLink>();
|
||||
|
||||
if (linkedChannels[userId].Any(channel => channel.ChannelType == channelType && channel.UserId == userId))
|
||||
throw new InvalidOperationException($"Channel {channelType} already linked to user with id {userId}");
|
||||
|
||||
if (!pairingCodes.TryGetValue(userId, out var userCodes))
|
||||
throw new InvalidOperationException($"No active pairing codes for user with id {userId}");
|
||||
|
||||
var userCode = userCodes.SingleOrDefault(c => c.ChannelType == channelType && c.Code == code);
|
||||
if (userCode is null)
|
||||
throw new InvalidOperationException($"Pairing code {code} did not match any active codes for channel {channelType}");
|
||||
|
||||
if (IsPairingCodeExpired(userCode))
|
||||
throw new InvalidOperationException($"Pairing code {code} is already expired");
|
||||
|
||||
linkedChannels[userId].Add(new ChannelLink(
|
||||
ChannelType: channelType,
|
||||
ChannelUserId: userId,
|
||||
UserId: userId,
|
||||
LinkedAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public bool IsChannelLinked(string userId, string channelType)
|
||||
=> linkedChannels.TryGetValue(userId, out var channelLinks)
|
||||
&& channelLinks.Any(link => link.ChannelType == channelType && link.UserId == userId);
|
||||
|
||||
|
||||
private bool IsPairingCodeExpired(PairingCode code)
|
||||
=> code.CreatedAt + code.TimeToLive < DateTimeOffset.UtcNow;
|
||||
|
||||
private string CreateNewPairingCode(string userId, string channelType)
|
||||
{
|
||||
var code = pairingCodeGenerator.Generate(channelType, userId);
|
||||
pairingCodes[userId].Add(code);
|
||||
|
||||
return code.Code;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user