diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index fe736af..fcf7159 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -7,6 +7,7 @@ using VoidCat.Database; using VoidCat.Model; using VoidCat.Services.Abstractions; using VoidCat.Services.Files; +using VoidCat.Services.Users; using File = VoidCat.Database.File; namespace VoidCat.Controllers @@ -21,12 +22,13 @@ namespace VoidCat.Controllers private readonly FileInfoManager _fileInfo; private readonly IUserUploadsStore _userUploads; private readonly IUserStore _userStore; + private readonly UserManager _userManager; private readonly ITimeSeriesStatsReporter _timeSeriesStats; private readonly VoidSettings _settings; public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaymentStore payment, IPaymentFactory paymentFactory, FileInfoManager fileInfo, IUserUploadsStore userUploads, - ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings) + ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings, UserManager userManager) { _storage = storage; _metadata = metadata; @@ -37,6 +39,7 @@ namespace VoidCat.Controllers _timeSeriesStats = timeSeriesStats; _userStore = userStore; _settings = settings; + _userManager = userManager; } /// @@ -68,6 +71,13 @@ namespace VoidCat.Controllers } var uid = HttpContext.GetUserId(); + var pubkey = HttpContext.GetPubKey(); + if (uid == default && !string.IsNullOrEmpty(pubkey)) + { + var nostrUser = await _userManager.LoginOrRegister(pubkey); + uid = nostrUser.Id; + } + var mime = Request.Headers.GetHeader("V-Content-Type"); var filename = Request.Headers.GetHeader("V-Filename"); diff --git a/VoidCat/Database/User.cs b/VoidCat/Database/User.cs index 76afbd4..e912505 100644 --- a/VoidCat/Database/User.cs +++ b/VoidCat/Database/User.cs @@ -110,5 +110,10 @@ public enum UserAuthType /// /// Lightning node challenge /// - Lightning = 3 + Lightning = 3, + + /// + /// Nostr login + /// + Nostr = 4, } diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index 521c3cc..4064d91 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -29,10 +29,16 @@ public static class Extensions public static Guid? GetUserId(this HttpContext context) { - var claimSub = context?.User?.Claims?.FirstOrDefault(a => a.Type == ClaimTypes.NameIdentifier)?.Value; + var claimSub = context.User.Claims.FirstOrDefault(a => a.Type == ClaimTypes.NameIdentifier)?.Value; return Guid.TryParse(claimSub, out var g) ? g : null; } + public static string? GetPubKey(this HttpContext context) + { + var claim = context.User.Claims.FirstOrDefault(a => a.Type == ClaimTypes.Name); + return claim?.Value; + } + public static IEnumerable? GetUserRoles(this HttpContext context) { return context?.User?.Claims?.Where(a => a.Type == ClaimTypes.Role) diff --git a/VoidCat/Model/Roles.cs b/VoidCat/Model/Roles.cs index de15645..8a49ae6 100644 --- a/VoidCat/Model/Roles.cs +++ b/VoidCat/Model/Roles.cs @@ -9,4 +9,5 @@ public static class Roles public static class Policies { public const string RequireAdmin = "RequireAdmin"; + public const string RequireNostr = "RequireNostr"; } \ No newline at end of file diff --git a/VoidCat/Services/NostrAuth.cs b/VoidCat/Services/NostrAuth.cs new file mode 100644 index 0000000..54b6095 --- /dev/null +++ b/VoidCat/Services/NostrAuth.cs @@ -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 +{ + public NostrAuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : + base(options, logger, encoder, clock) + { + } + + protected override async Task 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(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)); + } +} diff --git a/VoidCat/Services/Users/NostrProfileService.cs b/VoidCat/Services/Users/NostrProfileService.cs new file mode 100644 index 0000000..7073cc6 --- /dev/null +++ b/VoidCat/Services/Users/NostrProfileService.cs @@ -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 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(await req.Content.ReadAsStringAsync(), NostrSerializer.Settings); + if (ev != default) + { + return JsonConvert.DeserializeObject(ev.Content!, NostrSerializer.Settings); + } + } + } + catch (Exception ex) + { + // ignored + } + + return default; + } +} diff --git a/VoidCat/Services/Users/UserManager.cs b/VoidCat/Services/Users/UserManager.cs index cad7edd..56621a4 100644 --- a/VoidCat/Services/Users/UserManager.cs +++ b/VoidCat/Services/Users/UserManager.cs @@ -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; } /// @@ -108,6 +110,42 @@ public class UserManager return user; } + /// + /// Login or Register with nostr pubkey + /// + /// Hex public key + /// + /// + public async ValueTask 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); } -} \ No newline at end of file +} diff --git a/VoidCat/Services/Users/UsersStartup.cs b/VoidCat/Services/Users/UsersStartup.cs index be95f77..f5f0913 100644 --- a/VoidCat/Services/Users/UsersStartup.cs +++ b/VoidCat/Services/Users/UsersStartup.cs @@ -10,6 +10,7 @@ public static class UsersStartup { services.AddTransient(); services.AddTransient(); + services.AddTransient(); if (settings.HasDiscord()) { diff --git a/VoidCat/VoidCat.csproj b/VoidCat/VoidCat.csproj index a0336d1..8cb90bd 100644 --- a/VoidCat/VoidCat.csproj +++ b/VoidCat/VoidCat.csproj @@ -39,6 +39,7 @@ + diff --git a/VoidCat/VoidStartup.cs b/VoidCat/VoidStartup.cs index 85a0326..343f2b3 100644 --- a/VoidCat/VoidStartup.cs +++ b/VoidCat/VoidStartup.cs @@ -1,6 +1,9 @@ using System.Reflection; +using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; using Microsoft.AspNetCore.HttpLogging; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; @@ -133,7 +136,12 @@ public static class VoidStartup services.AddHealthChecks(); - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + services.AddTransient(); + services.AddAuthentication(o => + { + o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + o.AddScheme(NostrAuth.Scheme, "Nostr"); + }) .AddJwtBearer(options => { options.TokenValidationParameters = new() @@ -147,7 +155,15 @@ public static class VoidStartup }; }); - services.AddAuthorization((opt) => { opt.AddPolicy(Policies.RequireAdmin, (auth) => { auth.RequireRole(Roles.Admin); }); }); + services.AddAuthorization((opt) => + { + opt.AddPolicy(Policies.RequireNostr, new AuthorizationPolicy(new[] + { + new ClaimsAuthorizationRequirement(ClaimTypes.Name, null) + }, new[] {NostrAuth.Scheme})); + + opt.AddPolicy(Policies.RequireAdmin, auth => { auth.RequireRole(Roles.Admin); }); + }); services.AddTransient(); services.AddAnalytics(voidSettings); diff --git a/VoidCat/spa/src/api/package.json b/VoidCat/spa/src/api/package.json index d2564fb..e7e2340 100644 --- a/VoidCat/spa/src/api/package.json +++ b/VoidCat/spa/src/api/package.json @@ -1,6 +1,6 @@ { "name": "@void-cat/api", - "version": "1.0.7", + "version": "1.0.8", "description": "void.cat API package", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/VoidCat/spa/src/api/src/api.ts b/VoidCat/spa/src/api/src/api.ts index 0fa3338..e07a48b 100644 --- a/VoidCat/spa/src/api/src/api.ts +++ b/VoidCat/spa/src/api/src/api.ts @@ -16,10 +16,12 @@ import { XHRUploader } from "./xhr-uploader"; export class VoidApi { readonly #uri: string readonly #auth?: string + readonly #scheme?: string - constructor(uri: string, auth?: string) { + constructor(uri: string, auth?: string, scheme?: string) { this.#uri = uri; this.#auth = auth; + this.#scheme = scheme; } async #req(method: string, url: string, body?: object): Promise { @@ -27,7 +29,7 @@ export class VoidApi { "Accept": "application/json" }; if (this.#auth) { - headers["Authorization"] = `Bearer ${this.#auth}`; + headers["Authorization"] = `${this.#scheme ?? "Bearer"} ${this.#auth}`; } if (body) { headers["Content-Type"] = "application/json"; diff --git a/VoidCat/spa/src/api/tsconfig.json b/VoidCat/spa/src/api/tsconfig.json index 43fdccb..7843d49 100644 --- a/VoidCat/spa/src/api/tsconfig.json +++ b/VoidCat/spa/src/api/tsconfig.json @@ -1,11 +1,11 @@ { "compilerOptions": { "baseUrl": "src", - "target": "ES2020", - "moduleResolution": "node", + "target": "ESNext", + "moduleResolution": "NodeNext", "esModuleInterop": true, "noImplicitOverride": true, - "module": "CommonJS", + "module": "NodeNext", "strict": true, "declaration": true, "declarationMap": true, @@ -14,5 +14,6 @@ "skipLibCheck": true, "allowJs": true }, + "include": ["./src/**/*.ts"], "files": ["./src/index.ts"] }