From 21f4855cc6e692f70fd903f089a9853f3f1450bf Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 29 May 2026 16:02:34 +0200 Subject: [PATCH] Added auth --- WebApi/AppSettings.Development.json | 10 +++ WebApi/AppSettings.json | 10 +++ .../AuthenticationConfiguration.cs | 37 +++++++++++ .../AuthenticationExtensions.cs | 31 +++++++++ .../Authentication/JwtBearerEventsHandler.cs | 25 +++++++ .../Authentication/JwtBearerOptionsSetup.cs | 37 +++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 7 +- WebApi/OpenApi/OpenApiExtensions.cs | 14 ++++ WebApi/OpenApi/OpenApiOptionsSetup.cs | 66 +++++++++++++++++++ WebApi/Program.cs | 3 + WebApi/Scalar/ScalarExtensions.cs | 11 ++++ WebApi/Scalar/ScalarOptionsSetup.cs | 30 +++++++++ WebApi/WebApi.csproj | 1 + 13 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 WebApi/Authentication/AuthenticationConfiguration.cs create mode 100644 WebApi/Authentication/AuthenticationExtensions.cs create mode 100644 WebApi/Authentication/JwtBearerEventsHandler.cs create mode 100644 WebApi/Authentication/JwtBearerOptionsSetup.cs create mode 100644 WebApi/OpenApi/OpenApiExtensions.cs create mode 100644 WebApi/OpenApi/OpenApiOptionsSetup.cs create mode 100644 WebApi/Scalar/ScalarExtensions.cs create mode 100644 WebApi/Scalar/ScalarOptionsSetup.cs diff --git a/WebApi/AppSettings.Development.json b/WebApi/AppSettings.Development.json index db29d78..1cf5284 100644 --- a/WebApi/AppSettings.Development.json +++ b/WebApi/AppSettings.Development.json @@ -5,6 +5,16 @@ "Microsoft.AspNetCore": "Warning" } }, + "Authentication": { + "MetadataAddress": "http://localhost:8080/.well-known/openid-configuration", + "IssuerAddress": "http://localhost:8080", + "AudienceAddress": "372825689755287555", + "ClientId": "372825689755287555", + "AuthorizationEndpoint": "http://localhost:8080/oauth/v2/authorize?prompt=login", + "TokenEndpoint": "http://localhost:8080/oauth/v2/token", + "Scopes": [ "openid", "profile", "email" ], + "RequireHttpsMetadata": false + }, "Database": { "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" }, diff --git a/WebApi/AppSettings.json b/WebApi/AppSettings.json index f80dc37..738056d 100644 --- a/WebApi/AppSettings.json +++ b/WebApi/AppSettings.json @@ -6,6 +6,16 @@ } }, "AllowedHosts": "*", + "Authentication": { + "MetadataAddress": "", + "IssuerAddress": "", + "AudienceAddress": "", + "ClientId": "", + "AuthorizationEndpoint": "", + "TokenEndpoint": "", + "Scopes": [], + "RequireHttpsMetadata": true + }, "Database": { "ConnectionString": "" }, diff --git a/WebApi/Authentication/AuthenticationConfiguration.cs b/WebApi/Authentication/AuthenticationConfiguration.cs new file mode 100644 index 0000000..206a21f --- /dev/null +++ b/WebApi/Authentication/AuthenticationConfiguration.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebApi.Authentication; + +public sealed class AuthenticationConfiguration +{ + public const string SectionName = "Authentication"; + + [Required] + [Url] + public required string MetadataAddress { get; init; } + + [Required] + public required string IssuerAddress { get; init; } + + [Required] + public required string AudienceAddress { get; init; } + + [Required] + public required string ClientId { get; init; } + + public string? ClientSecret { get; init; } + + [Required] + [Url] + public required string AuthorizationEndpoint { get; init; } + + [Required] + [Url] + public required string TokenEndpoint { get; init; } + + [Required] + [MinLength(1)] + public required string[] Scopes { get; init; } + + public bool RequireHttpsMetadata { get; init; } = true; +} diff --git a/WebApi/Authentication/AuthenticationExtensions.cs b/WebApi/Authentication/AuthenticationExtensions.cs new file mode 100644 index 0000000..bc2da3e --- /dev/null +++ b/WebApi/Authentication/AuthenticationExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; + +namespace WebApi.Authentication; + +public static class AuthenticationExtensions +{ + public static IServiceCollection AddAuthenticationConfiguration( + this IServiceCollection services, + IConfiguration configuration) + { + services + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName)) + .ValidateDataAnnotations(); + + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + services.AddScoped(); + services.ConfigureOptions(); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + services.AddAuthorization(); + + return services; + } +} diff --git a/WebApi/Authentication/JwtBearerEventsHandler.cs b/WebApi/Authentication/JwtBearerEventsHandler.cs new file mode 100644 index 0000000..4819d10 --- /dev/null +++ b/WebApi/Authentication/JwtBearerEventsHandler.cs @@ -0,0 +1,25 @@ +using Mediator; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Musify.Application.Users.Commands; +using System.Security.Claims; + +namespace WebApi.Authentication; + +public sealed class JwtBearerEventsHandler(IMediator mediator) : JwtBearerEvents +{ + public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) + { + var principal = tokenValidatedContext.Principal!; + + var id = principal.FindFirstValue(ClaimTypes.NameIdentifier)!; + var guidId = Guid.Parse(id); + + var username = principal.FindFirstValue(ClaimTypes.Name)!; + var firstName = principal.FindFirstValue(ClaimTypes.GivenName); + var lastName = principal.FindFirstValue(ClaimTypes.Surname); + + await mediator.Send( + new CreateUserCommand(guidId, username, firstName, lastName), + tokenValidatedContext.HttpContext.RequestAborted); + } +} diff --git a/WebApi/Authentication/JwtBearerOptionsSetup.cs b/WebApi/Authentication/JwtBearerOptionsSetup.cs new file mode 100644 index 0000000..ea16ba0 --- /dev/null +++ b/WebApi/Authentication/JwtBearerOptionsSetup.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; + +namespace WebApi.Authentication; + +public sealed class JwtBearerOptionsSetup(IOptions options) + : IConfigureNamedOptions +{ + private readonly AuthenticationConfiguration _configuration = options.Value; + + public void Configure(string? name, JwtBearerOptions options) + { + if (name != JwtBearerDefaults.AuthenticationScheme) + { + return; + } + + Configure(options); + } + + public void Configure(JwtBearerOptions options) + { + options.MetadataAddress = _configuration.MetadataAddress; + options.RequireHttpsMetadata = _configuration.RequireHttpsMetadata; + options.Audience = _configuration.AudienceAddress; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = _configuration.IssuerAddress, + ValidAudience = _configuration.AudienceAddress, + RoleClaimType = ClaimTypes.Role, + NameClaimType = ClaimTypes.Name, + }; + options.EventsType = typeof(JwtBearerEventsHandler); + } +} diff --git a/WebApi/Extensions/ServiceCollectionExtensions.cs b/WebApi/Extensions/ServiceCollectionExtensions.cs index 94fcc81..a5d8df8 100644 --- a/WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/WebApi/Extensions/ServiceCollectionExtensions.cs @@ -3,7 +3,10 @@ using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Application.Configuration; using Musify.Infrastructure.Services; +using WebApi.Authentication; using WebApi.DataTransferObjects.PlayLists; +using WebApi.OpenApi; +using WebApi.Scalar; namespace WebApi.Extensions; @@ -19,7 +22,9 @@ public static class ServiceCollectionExtensions services.AddUploadIntentConfiguration(configuration); services.AddMassTransitClient(configuration); services.AddMediator(); - services.AddOpenApi(); + services.AddAuthenticationConfiguration(configuration); + services.AddOpenApiConfiguration(); + services.AddScalarConfiguration(); services.AddValidatorsFromAssemblyContaining(); return services; diff --git a/WebApi/OpenApi/OpenApiExtensions.cs b/WebApi/OpenApi/OpenApiExtensions.cs new file mode 100644 index 0000000..38c4372 --- /dev/null +++ b/WebApi/OpenApi/OpenApiExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.OpenApi; + +namespace WebApi.OpenApi; + +public static class OpenApiExtensions +{ + public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + { + services.AddOpenApi(); + services.ConfigureOptions(); + + return services; + } +} diff --git a/WebApi/OpenApi/OpenApiOptionsSetup.cs b/WebApi/OpenApi/OpenApiOptionsSetup.cs new file mode 100644 index 0000000..57af47d --- /dev/null +++ b/WebApi/OpenApi/OpenApiOptionsSetup.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; +using WebApi.Authentication; + +namespace WebApi.OpenApi; + +public sealed class OpenApiOptionsSetup(IOptions options) + : IConfigureNamedOptions +{ + private const string SecuritySchemeId = "OAuth2"; + + private readonly AuthenticationConfiguration _configuration = options.Value; + + public void Configure(string? name, OpenApiOptions options) => Configure(options); + + public void Configure(OpenApiOptions options) + { + options.AddDocumentTransformer((document, _, _) => + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri(_configuration.AuthorizationEndpoint), + TokenUrl = new Uri(_configuration.TokenEndpoint), + Scopes = _configuration.Scopes.ToDictionary(scope => scope, scope => scope), + }, + }, + }; + + return Task.CompletedTask; + }); + + options.AddOperationTransformer((operation, context, _) => + { + var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + if (!requiresAuthorization || allowsAnonymous) + { + return Task.CompletedTask; + } + + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = + _configuration.Scopes.ToList(), + }); + + return Task.CompletedTask; + }); + } +} diff --git a/WebApi/Program.cs b/WebApi/Program.cs index 96097c7..38f37e3 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -17,6 +17,9 @@ if (app.Environment.IsDevelopment()) app.MapScalarApiReference(); } +app.UseAuthentication(); +app.UseAuthorization(); + app .MapPlayListEndpoints() .MapTrackEndpoints() diff --git a/WebApi/Scalar/ScalarExtensions.cs b/WebApi/Scalar/ScalarExtensions.cs new file mode 100644 index 0000000..c499588 --- /dev/null +++ b/WebApi/Scalar/ScalarExtensions.cs @@ -0,0 +1,11 @@ +namespace WebApi.Scalar; + +public static class ScalarExtensions +{ + public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + { + services.ConfigureOptions(); + + return services; + } +} diff --git a/WebApi/Scalar/ScalarOptionsSetup.cs b/WebApi/Scalar/ScalarOptionsSetup.cs new file mode 100644 index 0000000..ef18898 --- /dev/null +++ b/WebApi/Scalar/ScalarOptionsSetup.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using Scalar.AspNetCore; +using WebApi.Authentication; + +namespace WebApi.Scalar; + +public sealed class ScalarOptionsSetup(IOptions options) + : IConfigureOptions +{ + private const string SecuritySchemeId = "OAuth2"; + + private readonly AuthenticationConfiguration _configuration = options.Value; + + public void Configure(ScalarOptions scalarOptions) + { + scalarOptions + .WithTitle("Musify API") + .AddPreferredSecuritySchemes(SecuritySchemeId) + .AddAuthorizationCodeFlow(SecuritySchemeId, flow => + { + flow.ClientId = _configuration.ClientId; + flow.ClientSecret = _configuration.ClientSecret; + flow.AuthorizationUrl = _configuration.AuthorizationEndpoint; + flow.TokenUrl = _configuration.TokenEndpoint; + flow.Pkce = Pkce.Sha256; + flow.SelectedScopes = _configuration.Scopes; + }) + .HideModels(); + } +} diff --git a/WebApi/WebApi.csproj b/WebApi/WebApi.csproj index 4d6b30b..52caafc 100644 --- a/WebApi/WebApi.csproj +++ b/WebApi/WebApi.csproj @@ -15,6 +15,7 @@ +