mirror of
https://git.v0l.io/Kieran/void.cat.git
synced 2025-11-15 06:16:27 +01:00
Accept Nostr auth
This commit is contained in:
91
VoidCat/Services/NostrAuth.cs
Normal file
91
VoidCat/Services/NostrAuth.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
|
||||
namespace VoidCat.Services;
|
||||
|
||||
public static class NostrAuth
|
||||
{
|
||||
public const string Scheme = "Nostr";
|
||||
}
|
||||
|
||||
public class NostrAuthOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
}
|
||||
|
||||
public class NostrAuthHandler : AuthenticationHandler<NostrAuthOptions>
|
||||
{
|
||||
public NostrAuthHandler(IOptionsMonitor<NostrAuthOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) :
|
||||
base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.FirstOrDefault()?.Trim();
|
||||
if (string.IsNullOrEmpty(auth))
|
||||
{
|
||||
return AuthenticateResult.Fail("Missing Authorization header");
|
||||
}
|
||||
|
||||
if (!auth.StartsWith(NostrAuth.Scheme))
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid auth scheme");
|
||||
}
|
||||
|
||||
var token = auth[6..];
|
||||
var bToken = Convert.FromBase64String(token);
|
||||
if (string.IsNullOrEmpty(token) || bToken.Length == 0 || bToken[0] != '{')
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid token");
|
||||
}
|
||||
|
||||
var ev = JsonConvert.DeserializeObject<NostrEvent>(Encoding.UTF8.GetString(bToken), NostrSerializer.Settings);
|
||||
if (ev == default)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid nostr event");
|
||||
}
|
||||
|
||||
if (!ev.IsSignatureValid())
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid nostr event, invalid sig");
|
||||
}
|
||||
|
||||
if (ev.Kind != (NostrKind)27_235)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid nostr event, wrong kind");
|
||||
}
|
||||
|
||||
var diffTime = Math.Abs((ev.CreatedAt!.Value - DateTime.UtcNow).TotalSeconds);
|
||||
if (diffTime > 60d)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid nostr event, timestamp out of range");
|
||||
}
|
||||
|
||||
var urlTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "u");
|
||||
var methodTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "method");
|
||||
if (string.IsNullOrEmpty(urlTag?.AdditionalData[0] as string) ||
|
||||
!new Uri((urlTag.AdditionalData[0] as string)!).AbsolutePath.Equals(Request.Path, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid nostr event, url tag invalid");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(methodTag?.AdditionalData[0] as string) ||
|
||||
!((methodTag.AdditionalData[0] as string)?.Equals(Request.Method, StringComparison.InvariantCultureIgnoreCase) ?? false))
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid nostr event, method tag invalid");
|
||||
}
|
||||
|
||||
var principal = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, ev.Pubkey!)
|
||||
});
|
||||
|
||||
return AuthenticateResult.Success(new(new ClaimsPrincipal(new[] {principal}), Scheme.Name));
|
||||
}
|
||||
}
|
||||
42
VoidCat/Services/Users/NostrProfileService.cs
Normal file
42
VoidCat/Services/Users/NostrProfileService.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Messages.Metadata;
|
||||
using VoidCat.Model;
|
||||
|
||||
namespace VoidCat.Services.Users;
|
||||
|
||||
public class NostrProfileService
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly VoidSettings _settings;
|
||||
|
||||
public NostrProfileService(HttpClient client, VoidSettings settings)
|
||||
{
|
||||
_client = client;
|
||||
_settings = settings;
|
||||
_client.Timeout = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
public async Task<NostrMetadata?> FetchProfile(string pubkey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var req = await _client.GetAsync($"https://api.snort.social/api/v1/raw/p/{pubkey}");
|
||||
if (req.IsSuccessStatusCode)
|
||||
{
|
||||
var ev = JsonConvert.DeserializeObject<NostrEvent>(await req.Content.ReadAsStringAsync(), NostrSerializer.Settings);
|
||||
if (ev != default)
|
||||
{
|
||||
return JsonConvert.DeserializeObject<NostrMetadata>(ev.Content!, NostrSerializer.Settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,15 @@ public class UserManager
|
||||
private readonly IUserStore _store;
|
||||
private readonly IEmailVerification _emailVerification;
|
||||
private readonly OAuthFactory _oAuthFactory;
|
||||
private readonly NostrProfileService _nostrProfile;
|
||||
private static bool _checkFirstRegister;
|
||||
|
||||
public UserManager(IUserStore store, IEmailVerification emailVerification, OAuthFactory oAuthFactory)
|
||||
public UserManager(IUserStore store, IEmailVerification emailVerification, OAuthFactory oAuthFactory, NostrProfileService nostrProfile)
|
||||
{
|
||||
_store = store;
|
||||
_emailVerification = emailVerification;
|
||||
_oAuthFactory = oAuthFactory;
|
||||
_nostrProfile = nostrProfile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -108,6 +110,42 @@ public class UserManager
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Login or Register with nostr pubkey
|
||||
/// </summary>
|
||||
/// <param name="pubkey">Hex public key</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
public async ValueTask<User> LoginOrRegister(string pubkey)
|
||||
{
|
||||
var uid = await _store.LookupUser(pubkey);
|
||||
if (uid.HasValue)
|
||||
{
|
||||
var existingUser = await _store.Get(uid.Value);
|
||||
if (existingUser?.AuthType == UserAuthType.Nostr)
|
||||
{
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Auth failure, user type does not match!");
|
||||
}
|
||||
|
||||
var profile = await _nostrProfile.FetchProfile(pubkey);
|
||||
var newUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AuthType = UserAuthType.Nostr,
|
||||
Created = DateTime.UtcNow,
|
||||
Avatar = profile?.Picture,
|
||||
DisplayName = profile?.Name ?? "Nostrich",
|
||||
Email = pubkey,
|
||||
Flags = UserFlags.EmailVerified // always mark as email verififed
|
||||
};
|
||||
|
||||
await SetupNewUser(newUser);
|
||||
return newUser;
|
||||
}
|
||||
|
||||
private async Task SetupNewUser(User newUser)
|
||||
{
|
||||
// automatically set first user to admin
|
||||
@@ -127,7 +165,10 @@ public class UserManager
|
||||
}
|
||||
|
||||
await _store.Add(newUser);
|
||||
await _emailVerification.SendNewCode(newUser);
|
||||
if (!newUser.Flags.HasFlag(UserFlags.EmailVerified))
|
||||
{
|
||||
await _emailVerification.SendNewCode(newUser);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogin(User user)
|
||||
@@ -135,4 +176,4 @@ public class UserManager
|
||||
user.LastLogin = DateTime.UtcNow;
|
||||
await _store.UpdateLastLogin(user.Id, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public static class UsersStartup
|
||||
{
|
||||
services.AddTransient<UserManager>();
|
||||
services.AddTransient<OAuthFactory>();
|
||||
services.AddTransient<NostrProfileService>();
|
||||
|
||||
if (settings.HasDiscord())
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user