diff --git a/OutOfSchool/OutOfSchool.DataAccess/Extensions/DbSetExtensions.cs b/OutOfSchool/OutOfSchool.DataAccess/Extensions/DbSetExtensions.cs new file mode 100644 index 0000000000..dcad3527f5 --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Extensions/DbSetExtensions.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +#nullable enable + +namespace OutOfSchool.Services.Extensions +{ + public static class DbSetExtensions + { + /// + /// Add entry if it doesn't exist based on a predicate. + /// Not thread safe! There's a window between Any and Add where another thread can add an entry. + /// Performance issues is need to add multiple entities. + /// + /// Extension target. + /// Entry to add. + /// Optional predicate. + /// Entity type. + /// Return an entity that was just added or null. + public static EntityEntry? AddIfNotExists(this DbSet dbSet, T entity, Expression>? predicate = null) + where T : class, new() + { + var exists = predicate != null ? dbSet.Any(predicate) : dbSet.Any(); + return !exists ? dbSet.Add(entity) : null; + } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.IdentityServer/Config.cs b/OutOfSchool/OutOfSchool.IdentityServer/Config.cs deleted file mode 100644 index 5316ad6222..0000000000 --- a/OutOfSchool/OutOfSchool.IdentityServer/Config.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Collections.Generic; -using IdentityServer4; -using IdentityServer4.Models; - -namespace OutOfSchool.IdentityServer -{ - public static class Config - { - public static IEnumerable IdentityResources => - new[] - { - new IdentityResources.OpenId(), - new IdentityResources.Profile(), - new IdentityResource - { - Name = "role", - UserClaims = new List { "role" }, - }, - }; - - public static IEnumerable ApiScopes => - new[] - { - new ApiScope("outofschoolapi.read"), - new ApiScope("outofschoolapi.write"), - }; - - public static IEnumerable ApiResources(string apiSecret) => new[] - { - new ApiResource("outofschoolapi") - { - Scopes = new List { "outofschoolapi.read", "outofschoolapi.write" }, - ApiSecrets = new List { new Secret(apiSecret.Sha256()) }, - UserClaims = new List { "role" }, - }, - }; - - public static IEnumerable Clients(string clientSecret) => - new[] - { - // m2m client credentials flow client - new Client - { - ClientId = "m2m.client", - ClientName = "Client Credentials Client", - - AllowedGrantTypes = GrantTypes.ClientCredentials, - ClientSecrets = { new Secret(clientSecret.Sha256()) }, - - AllowedScopes = { "outofschoolapi.read", "outofschoolapi.write" }, - }, - - new Client - { - ClientId = "angular", - - AllowedGrantTypes = GrantTypes.Code, - RequirePkce = true, - RequireClientSecret = false, - AllowOfflineAccess = true, - - RedirectUris = { "http://localhost:4200", "http://oos.dmytrominochkin.cloud" }, - PostLogoutRedirectUris = { "http://localhost:4200", "http://oos.dmytrominochkin.cloud" }, - AllowedCorsOrigins = { "http://localhost:4200", "http://oos.dmytrominochkin.cloud" }, - - AllowedScopes = - { - IdentityServerConstants.StandardScopes.OpenId, - "outofschoolapi.read", "outofschoolapi.write", - }, - - AllowAccessTokensViaBrowser = true, - RequireConsent = false, - }, - }; - } -} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.IdentityServer/Config/AdditionalIdentityClients.cs b/OutOfSchool/OutOfSchool.IdentityServer/Config/AdditionalIdentityClients.cs new file mode 100644 index 0000000000..fb7f456b0f --- /dev/null +++ b/OutOfSchool/OutOfSchool.IdentityServer/Config/AdditionalIdentityClients.cs @@ -0,0 +1,13 @@ +namespace OutOfSchool.IdentityServer.Config +{ + public class AdditionalIdentityClients + { + public string ClientId { get; set; } + + public string[] RedirectUris { get; set; } + + public string[] PostLogoutRedirectUris { get; set; } + + public string[] AllowedCorsOrigins { get; set; } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.IdentityServer/Config/IdentityAccessOptions.cs b/OutOfSchool/OutOfSchool.IdentityServer/Config/IdentityAccessOptions.cs new file mode 100644 index 0000000000..8bce2a290c --- /dev/null +++ b/OutOfSchool/OutOfSchool.IdentityServer/Config/IdentityAccessOptions.cs @@ -0,0 +1,9 @@ +namespace OutOfSchool.IdentityServer.Config +{ + public class IdentityAccessOptions + { + public readonly string Name = "IdentityAccessConfig"; + + public AdditionalIdentityClients[] AdditionalIdentityClients { get; set; } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.IdentityServer/Config/StaticConfig.cs b/OutOfSchool/OutOfSchool.IdentityServer/Config/StaticConfig.cs new file mode 100644 index 0000000000..9c038308c3 --- /dev/null +++ b/OutOfSchool/OutOfSchool.IdentityServer/Config/StaticConfig.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using IdentityServer4; +using IdentityServer4.Models; + +namespace OutOfSchool.IdentityServer.Config +{ + public static class StaticConfig + { + public static IEnumerable IdentityResources => + new[] + { + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResource + { + Name = "role", + UserClaims = new List {"role"}, + }, + }; + + public static IEnumerable ApiScopes => + new[] + { + new ApiScope("outofschoolapi.read"), + new ApiScope("outofschoolapi.write"), + }; + + public static IEnumerable ApiResources(string apiSecret) => new[] + { + new ApiResource("outofschoolapi") + { + Scopes = new List {"outofschoolapi.read", "outofschoolapi.write"}, + ApiSecrets = new List { new Secret(apiSecret.Sha256()) }, + UserClaims = new List {"role"}, + }, + }; + + public static IEnumerable Clients(string clientSecret, IEnumerable additionalClients) + { + // m2m client credentials flow client + var clients = new List + { + new Client + { + ClientId = "m2m.client", + ClientName = "Client Credentials Client", + AllowedGrantTypes = GrantTypes.ClientCredentials, + ClientSecrets = {new Secret(clientSecret.Sha256()) }, + AllowedScopes = {"outofschoolapi.read", "outofschoolapi.write"}, + }, + }; + + clients.AddRange(additionalClients.Select(c => new Client + { + ClientId = c.ClientId, + AllowedGrantTypes = GrantTypes.Code, + RequirePkce = true, + RequireClientSecret = false, + AllowOfflineAccess = true, + + RedirectUris = c.RedirectUris, + PostLogoutRedirectUris = c.PostLogoutRedirectUris, + AllowedCorsOrigins = c.AllowedCorsOrigins, + + AllowedScopes = + { + IdentityServerConstants.StandardScopes.OpenId, + "outofschoolapi.read", "outofschoolapi.write", + }, + + AllowAccessTokensViaBrowser = true, + RequireConsent = false, + })); + + return clients; + } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.IdentityServer/Program.cs b/OutOfSchool/OutOfSchool.IdentityServer/Program.cs index 98b2c9ce15..b642268a44 100644 --- a/OutOfSchool/OutOfSchool.IdentityServer/Program.cs +++ b/OutOfSchool/OutOfSchool.IdentityServer/Program.cs @@ -9,10 +9,11 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using OutOfSchool.IdentityServer; +using OutOfSchool.IdentityServer.Config; using OutOfSchool.Services; +using OutOfSchool.Services.Extensions; -namespace IdentityServer +namespace OutOfSchool.IdentityServer { public class Program { @@ -34,8 +35,12 @@ public static void Main(string[] args) .Database.Migrate(); var identityContext = scope.ServiceProvider.GetRequiredService(); var configService = scope.ServiceProvider.GetRequiredService(); + + // TODO: Move to identity options var apiSecret = configService["outofschoolapi:ApiSecret"]; var clientSecret = configService["m2m.client:ClientSecret"]; + var identityOptions = new IdentityAccessOptions(); + configService.GetSection(identityOptions.Name).Bind(identityOptions); context.Database.Migrate(); identityContext.Database.Migrate(); @@ -46,45 +51,34 @@ public static void Main(string[] args) RolesInit(manager); } - if (!context.Clients.Any()) + foreach (var client in StaticConfig.Clients(clientSecret, identityOptions.AdditionalIdentityClients)) { - foreach (var client in Config.Clients(clientSecret)) - { - context.Clients.Add(client.ToEntity()); - } - - context.SaveChanges(); + context.Clients.AddIfNotExists(client.ToEntity(), c => c.ClientId == client.ClientId); } - if (!context.IdentityResources.Any()) - { - foreach (var resource in Config.IdentityResources) - { - context.IdentityResources.Add(resource.ToEntity()); - } + context.SaveChanges(); - context.SaveChanges(); - } - if (!context.ApiResources.Any()) + foreach (var resource in StaticConfig.IdentityResources) { - foreach (var resource in Config.ApiResources(apiSecret)) - { - context.ApiResources.Add(resource.ToEntity()); - } - - context.SaveChanges(); + context.IdentityResources.AddIfNotExists(resource.ToEntity(), ir => resource.Name == ir.Name); } - if (!context.ApiScopes.Any()) + context.SaveChanges(); + + foreach (var resource in StaticConfig.ApiResources(apiSecret)) { - foreach (var resource in Config.ApiScopes) - { - context.ApiScopes.Add(resource.ToEntity()); - } + context.ApiResources.AddIfNotExists(resource.ToEntity(), ar => resource.Name == ar.Name); + } + + context.SaveChanges(); - context.SaveChanges(); + foreach (var resource in StaticConfig.ApiScopes) + { + context.ApiScopes.AddIfNotExists(resource.ToEntity(), apiScope => apiScope.Name == resource.Name); } + + context.SaveChanges(); } host.Run(); diff --git a/OutOfSchool/OutOfSchool.IdentityServer/Startup.cs b/OutOfSchool/OutOfSchool.IdentityServer/Startup.cs index 386ab609e4..a14ee4dee1 100644 --- a/OutOfSchool/OutOfSchool.IdentityServer/Startup.cs +++ b/OutOfSchool/OutOfSchool.IdentityServer/Startup.cs @@ -65,6 +65,7 @@ public void ConfigureServices(IServiceCollection services) { if (env.IsEnvironment("Release")) { + // TODO: Change this to something decent and configurable options.IssuerUri = "http://hostname:5443"; } }) diff --git a/OutOfSchool/OutOfSchool.IdentityServer/appsettings.Azure.json b/OutOfSchool/OutOfSchool.IdentityServer/appsettings.Azure.json index 4179e4c0f5..ee422f1116 100644 --- a/OutOfSchool/OutOfSchool.IdentityServer/appsettings.Azure.json +++ b/OutOfSchool/OutOfSchool.IdentityServer/appsettings.Azure.json @@ -5,5 +5,36 @@ "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Warning" } + }, + "IdentityAccessConfig": { + "AdditionalIdentityClients": [ + { + "ClientId": "angular", + "RedirectUris": [ + "http://localhost:4200", + "http://oos.dmytrominochkin.cloud" + ], + "PostLogoutRedirectUris": [ + "http://localhost:4200", + "http://oos.dmytrominochkin.cloud" + ], + "AllowedCorsOrigins": [ + "http://localhost:4200", + "http://oos.dmytrominochkin.cloud" + ] + }, + { + "ClientId": "Swagger", + "RedirectUris": [ + "https://api.oos.dmytrominochkin.cloud/swagger/oauth2-redirect.html" + ], + "PostLogoutRedirectUris": [ + "https://api.oos.dmytrominochkin.cloud/swagger/oauth2-redirect.html" + ], + "AllowedCorsOrigins": [ + "https://api.oos.dmytrominochkin.cloud" + ] + } + ] } } \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.IdentityServer/appsettings.json b/OutOfSchool/OutOfSchool.IdentityServer/appsettings.json index 7c1edd3be1..3e00cc578f 100644 --- a/OutOfSchool/OutOfSchool.IdentityServer/appsettings.json +++ b/OutOfSchool/OutOfSchool.IdentityServer/appsettings.json @@ -17,5 +17,36 @@ "Username": "", "Password": "" } + }, + "IdentityAccessConfig": { + "AdditionalIdentityClients": [ + { + "ClientId": "angular", + "RedirectUris": [ + "http://localhost:4200", + "http://oos.dmytrominochkin.cloud" + ], + "PostLogoutRedirectUris": [ + "http://localhost:4200", + "http://oos.dmytrominochkin.cloud" + ], + "AllowedCorsOrigins": [ + "http://localhost:4200", + "http://oos.dmytrominochkin.cloud" + ] + }, + { + "ClientId": "Swagger", + "RedirectUris": [ + "http://localhost:5000/swagger/oauth2-redirect.html" + ], + "PostLogoutRedirectUris": [ + "http://localhost:5000/swagger/oauth2-redirect.html" + ], + "AllowedCorsOrigins": [ + "http://localhost:5000" + ] + } + ] } } diff --git a/OutOfSchool/OutOfSchool.WebApi/Properties/launchSettings.json b/OutOfSchool/OutOfSchool.WebApi/Properties/launchSettings.json index c6684c196e..ce4f7b7a14 100644 --- a/OutOfSchool/OutOfSchool.WebApi/Properties/launchSettings.json +++ b/OutOfSchool/OutOfSchool.WebApi/Properties/launchSettings.json @@ -20,7 +20,7 @@ "OutOfSchool": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "Organization/TestOk", + "launchUrl": "swagger", "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/OutOfSchool/OutOfSchool.WebApi/Startup.cs b/OutOfSchool/OutOfSchool.WebApi/Startup.cs index 88feb153ab..8740dca06a 100644 --- a/OutOfSchool/OutOfSchool.WebApi/Startup.cs +++ b/OutOfSchool/OutOfSchool.WebApi/Startup.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Reflection; @@ -11,11 +12,14 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; using OutOfSchool.Services; using OutOfSchool.Services.Models; using OutOfSchool.Services.Repository; using OutOfSchool.WebApi.Extensions; using OutOfSchool.WebApi.Services; +using OutOfSchool.WebApi.Util; using Serilog; namespace OutOfSchool.WebApi @@ -61,7 +65,12 @@ public static void Configure(IApplicationBuilder app, IWebHostEnvironment env) // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), // specifying the Swagger JSON endpoint. - app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "Out Of School API"); }); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Out Of School API"); + c.OAuthClientId("Swagger"); + c.OAuthUsePkce(); + }); app.UseHttpsRedirection(); @@ -161,7 +170,25 @@ public void ConfigureServices(IServiceCollection services) throw new InvalidOperationException("Unable to determine tag for endpoint."); }); - + c.OperationFilter(); + var baseUrl = Configuration["SwaggerIdentityAccess:BaseUrl"]; + c.AddSecurityDefinition("Identity server", new OpenApiSecurityScheme + { + Description = "Identity server", + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri($"{baseUrl}/connect/authorize", UriKind.Absolute), + TokenUrl = new Uri($"{baseUrl}/connect/token", UriKind.Absolute), + Scopes = new Dictionary + { + { "openid outofschoolapi.read offline_access", "Scopes" }, + }, + }, + }, + }); c.DocInclusionPredicate((name, api) => true); }); diff --git a/OutOfSchool/OutOfSchool.WebApi/Util/AuthorizeCheckOperationFilter.cs b/OutOfSchool/OutOfSchool.WebApi/Util/AuthorizeCheckOperationFilter.cs new file mode 100644 index 0000000000..68dd8581a7 --- /dev/null +++ b/OutOfSchool/OutOfSchool.WebApi/Util/AuthorizeCheckOperationFilter.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace OutOfSchool.WebApi.Util +{ + public class AuthorizeCheckOperationFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var hasAuthorize = + context.MethodInfo.DeclaringType!.GetCustomAttributes(true).OfType().Any() + || context.MethodInfo.GetCustomAttributes(true).OfType().Any(); + + if (hasAuthorize) + { + operation.Security = new List + { + new OpenApiSecurityRequirement + { + [ + new OpenApiSecurityScheme { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Identity server", + }, + } + + ] = new List(), + }, + }; + + } + } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.WebApi/appsettings.Azure.json b/OutOfSchool/OutOfSchool.WebApi/appsettings.Azure.json index 4179e4c0f5..16d9b06005 100644 --- a/OutOfSchool/OutOfSchool.WebApi/appsettings.Azure.json +++ b/OutOfSchool/OutOfSchool.WebApi/appsettings.Azure.json @@ -5,5 +5,8 @@ "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Warning" } + }, + "SwaggerIdentityAccess": { + "BaseUrl": "http://auth.oos.dmytrominochkin.cloud" } } \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.WebApi/appsettings.json b/OutOfSchool/OutOfSchool.WebApi/appsettings.json index 4a80e668f3..6a6f5302e8 100644 --- a/OutOfSchool/OutOfSchool.WebApi/appsettings.json +++ b/OutOfSchool/OutOfSchool.WebApi/appsettings.json @@ -9,6 +9,9 @@ "Identity": { "Authority": "http://localhost:5443" }, + "SwaggerIdentityAccess": { + "BaseUrl": "http://localhost:5443" + }, "AllowedHosts": "*" } \ No newline at end of file