Accept Nostr auth

This commit is contained in:
Kieran 2023-10-13 20:07:35 +01:00
parent 5b955142aa
commit 6f28c3f293
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
13 changed files with 231 additions and 14 deletions

View File

@ -7,6 +7,7 @@ using VoidCat.Database;
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
using VoidCat.Services.Files; using VoidCat.Services.Files;
using VoidCat.Services.Users;
using File = VoidCat.Database.File; using File = VoidCat.Database.File;
namespace VoidCat.Controllers namespace VoidCat.Controllers
@ -21,12 +22,13 @@ namespace VoidCat.Controllers
private readonly FileInfoManager _fileInfo; private readonly FileInfoManager _fileInfo;
private readonly IUserUploadsStore _userUploads; private readonly IUserUploadsStore _userUploads;
private readonly IUserStore _userStore; private readonly IUserStore _userStore;
private readonly UserManager _userManager;
private readonly ITimeSeriesStatsReporter _timeSeriesStats; private readonly ITimeSeriesStatsReporter _timeSeriesStats;
private readonly VoidSettings _settings; private readonly VoidSettings _settings;
public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaymentStore payment, public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaymentStore payment,
IPaymentFactory paymentFactory, FileInfoManager fileInfo, IUserUploadsStore userUploads, IPaymentFactory paymentFactory, FileInfoManager fileInfo, IUserUploadsStore userUploads,
ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings) ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings, UserManager userManager)
{ {
_storage = storage; _storage = storage;
_metadata = metadata; _metadata = metadata;
@ -37,6 +39,7 @@ namespace VoidCat.Controllers
_timeSeriesStats = timeSeriesStats; _timeSeriesStats = timeSeriesStats;
_userStore = userStore; _userStore = userStore;
_settings = settings; _settings = settings;
_userManager = userManager;
} }
/// <summary> /// <summary>
@ -68,6 +71,13 @@ namespace VoidCat.Controllers
} }
var uid = HttpContext.GetUserId(); 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 mime = Request.Headers.GetHeader("V-Content-Type");
var filename = Request.Headers.GetHeader("V-Filename"); var filename = Request.Headers.GetHeader("V-Filename");

View File

@ -110,5 +110,10 @@ public enum UserAuthType
/// <summary> /// <summary>
/// Lightning node challenge /// Lightning node challenge
/// </summary> /// </summary>
Lightning = 3 Lightning = 3,
/// <summary>
/// Nostr login
/// </summary>
Nostr = 4,
} }

View File

@ -29,10 +29,16 @@ public static class Extensions
public static Guid? GetUserId(this HttpContext context) 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; 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<string>? GetUserRoles(this HttpContext context) public static IEnumerable<string>? GetUserRoles(this HttpContext context)
{ {
return context?.User?.Claims?.Where(a => a.Type == ClaimTypes.Role) return context?.User?.Claims?.Where(a => a.Type == ClaimTypes.Role)

View File

@ -9,4 +9,5 @@ public static class Roles
public static class Policies public static class Policies
{ {
public const string RequireAdmin = "RequireAdmin"; public const string RequireAdmin = "RequireAdmin";
public const string RequireNostr = "RequireNostr";
} }

View 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));
}
}

View 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;
}
}

View File

@ -10,13 +10,15 @@ public class UserManager
private readonly IUserStore _store; private readonly IUserStore _store;
private readonly IEmailVerification _emailVerification; private readonly IEmailVerification _emailVerification;
private readonly OAuthFactory _oAuthFactory; private readonly OAuthFactory _oAuthFactory;
private readonly NostrProfileService _nostrProfile;
private static bool _checkFirstRegister; private static bool _checkFirstRegister;
public UserManager(IUserStore store, IEmailVerification emailVerification, OAuthFactory oAuthFactory) public UserManager(IUserStore store, IEmailVerification emailVerification, OAuthFactory oAuthFactory, NostrProfileService nostrProfile)
{ {
_store = store; _store = store;
_emailVerification = emailVerification; _emailVerification = emailVerification;
_oAuthFactory = oAuthFactory; _oAuthFactory = oAuthFactory;
_nostrProfile = nostrProfile;
} }
/// <summary> /// <summary>
@ -108,6 +110,42 @@ public class UserManager
return user; 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) private async Task SetupNewUser(User newUser)
{ {
// automatically set first user to admin // automatically set first user to admin
@ -127,8 +165,11 @@ public class UserManager
} }
await _store.Add(newUser); await _store.Add(newUser);
if (!newUser.Flags.HasFlag(UserFlags.EmailVerified))
{
await _emailVerification.SendNewCode(newUser); await _emailVerification.SendNewCode(newUser);
} }
}
private async Task HandleLogin(User user) private async Task HandleLogin(User user)
{ {

View File

@ -10,6 +10,7 @@ public static class UsersStartup
{ {
services.AddTransient<UserManager>(); services.AddTransient<UserManager>();
services.AddTransient<OAuthFactory>(); services.AddTransient<OAuthFactory>();
services.AddTransient<NostrProfileService>();
if (settings.HasDiscord()) if (settings.HasDiscord())
{ {

View File

@ -39,6 +39,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="NBitcoin" Version="7.0.25" /> <PackageReference Include="NBitcoin" Version="7.0.25" />
<PackageReference Include="nClam" Version="7.0.0" /> <PackageReference Include="nClam" Version="7.0.0" />
<PackageReference Include="Nostr.Client" Version="1.4.3" />
<PackageReference Include="Npgsql" Version="7.0.2" /> <PackageReference Include="Npgsql" Version="7.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.0.0" /> <PackageReference Include="prometheus-net.AspNetCore" Version="8.0.0" />

View File

@ -1,6 +1,9 @@
using System.Reflection; using System.Reflection;
using System.Security.Claims;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.HttpLogging;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
@ -133,7 +136,12 @@ public static class VoidStartup
services.AddHealthChecks(); services.AddHealthChecks();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) services.AddTransient<NostrAuthHandler>();
services.AddAuthentication(o =>
{
o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
o.AddScheme<NostrAuthHandler>(NostrAuth.Scheme, "Nostr");
})
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
options.TokenValidationParameters = new() 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<RazorPartialToStringRenderer>(); services.AddTransient<RazorPartialToStringRenderer>();
services.AddAnalytics(voidSettings); services.AddAnalytics(voidSettings);

View File

@ -1,6 +1,6 @@
{ {
"name": "@void-cat/api", "name": "@void-cat/api",
"version": "1.0.7", "version": "1.0.8",
"description": "void.cat API package", "description": "void.cat API package",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -16,10 +16,12 @@ import { XHRUploader } from "./xhr-uploader";
export class VoidApi { export class VoidApi {
readonly #uri: string readonly #uri: string
readonly #auth?: string readonly #auth?: string
readonly #scheme?: string
constructor(uri: string, auth?: string) { constructor(uri: string, auth?: string, scheme?: string) {
this.#uri = uri; this.#uri = uri;
this.#auth = auth; this.#auth = auth;
this.#scheme = scheme;
} }
async #req<T>(method: string, url: string, body?: object): Promise<T> { async #req<T>(method: string, url: string, body?: object): Promise<T> {
@ -27,7 +29,7 @@ export class VoidApi {
"Accept": "application/json" "Accept": "application/json"
}; };
if (this.#auth) { if (this.#auth) {
headers["Authorization"] = `Bearer ${this.#auth}`; headers["Authorization"] = `${this.#scheme ?? "Bearer"} ${this.#auth}`;
} }
if (body) { if (body) {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";

View File

@ -1,11 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": "src", "baseUrl": "src",
"target": "ES2020", "target": "ESNext",
"moduleResolution": "node", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"module": "CommonJS", "module": "NodeNext",
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
@ -14,5 +14,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"allowJs": true "allowJs": true
}, },
"include": ["./src/**/*.ts"],
"files": ["./src/index.ts"] "files": ["./src/index.ts"]
} }