Publish from private repository

This commit is contained in:
Gitea Actions
2026-08-01 11:58:54 +00:00
commit 39698fed40
695 changed files with 69563 additions and 0 deletions
@@ -0,0 +1,137 @@
namespace MyOffice.Web.Auth;
using Core.Extensions;
using Core.Identity;
using Data.Models.Users;
using Data.Repositories.Users;
using Identity.Domain;
using Identity.ExternalProviders;
using Identity.Repositories;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.OpenIddictServerEvents;
public sealed class ExternalGrantHandler : IOpenIddictServerHandler<HandleTokenRequestContext>
{
private readonly AppUserManager _userManager;
private readonly IUserExternalRepository _userExternalRepository;
private readonly IEnumerable<IExternalProviderValidator> _externalProviderValidators;
private readonly IHttpContextAccessor _httpContextAccessor;
public ExternalGrantHandler(
AppUserManager userManager,
IUserExternalRepository userExternalRepository,
IEnumerable<IExternalProviderValidator> externalProviderValidators,
IHttpContextAccessor httpContextAccessor
)
{
_userManager = userManager;
_userExternalRepository = userExternalRepository;
_externalProviderValidators = externalProviderValidators;
_httpContextAccessor = httpContextAccessor;
}
public async ValueTask HandleAsync(HandleTokenRequestContext context)
{
if (!string.Equals(context.Request.GrantType, OpenIddictAuthConstants.ExternalGrantType, StringComparison.Ordinal))
return;
var provider = context.Request.GetParameter("provider")?.ToString();
if (provider.IsMissing())
{
context.Reject(Errors.InvalidRequest, "The provider parameter is required.");
return;
}
var validator = _externalProviderValidators.FirstOrDefault(x => x.Provider.EqualsIgnoreCase(provider));
if (validator is null || !validator.IsConfigured)
{
context.Reject(Errors.InvalidRequest, $"Provider not supported: {provider}");
return;
}
var token = context.Request.GetParameter("token")?.ToString();
if (token.IsMissing())
{
context.Reject(Errors.InvalidRequest, "The token parameter is required.");
return;
}
var validationResult = await validator.ValidateAsync(token);
if (!validationResult.IsSuccessed)
{
context.Reject(Errors.InvalidGrant, "Token not valid.");
return;
}
if (_httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true)
{
context.Reject(Errors.InvalidGrant, "Authentication failed.");
return;
}
var user = await _userManager.FindByEmailAsync(validationResult.Email!);
if (user is null)
{
if (!validationResult.EmailVerified)
{
context.Reject(Errors.InvalidGrant, "Email not confirmed.");
return;
}
user = await CreateUserAsync(validationResult.Email!, validationResult.EmailVerified, validationResult.FullName);
if (user is null)
{
context.Reject(Errors.InvalidGrant, "Unable to create user.");
return;
}
}
var externalLogin = await _userExternalRepository.GetByUserIdAsync(user.Id, provider)
?? AddExternalLogin(user, validationResult.Email!, validationResult.ExternalId!, provider);
context.SignIn(OpenIddictClaimsHelper.CreatePrincipal(user, context.Request.GetScopes()));
}
private async Task<ApplicationUser<Guid>?> CreateUserAsync(string email, bool isEmailConfirmed, string? fullName)
{
var user = new ApplicationUser<Guid>
{
Id = Guid.NewGuid(),
UserName = email,
Email = email,
IsEmailConfirmed = isEmailConfirmed,
FullName = fullName
};
var password = "Qq1!_" + Guid.NewGuid();
var result = await _userManager.CreateAsync(user, password);
return result.Succeeded ? user : null;
}
private UserExternal AddExternalLogin(
ApplicationUser<Guid> user,
string email,
string externalId,
string provider
)
{
var existing = _userExternalRepository.GetByUserId(user.Id, provider);
if (existing is not null)
return existing;
var claim = new UserExternal
{
UserId = user.Id,
CreatedOn = DateTime.UtcNow,
Provider = provider.ToLower(),
ExternalId = externalId,
Email = email
};
_userExternalRepository.AddUserExternal(claim);
return claim;
}
}
@@ -0,0 +1,41 @@
namespace MyOffice.Web.Auth;
using System.Security.Claims;
using Identity.Domain;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
public static class OpenIddictClaimsHelper
{
public static ClaimsPrincipal CreatePrincipal(
ApplicationUser<Guid> user,
IEnumerable<string> scopes
)
{
var identity = new ClaimsIdentity(
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: Claims.Name,
roleType: Claims.Role);
var userId = user.Id.ToString();
identity.SetClaim(Claims.Subject, userId);
identity.SetClaim(Claims.Name, user.UserName);
identity.SetClaim(Claims.PreferredUsername, user.UserName);
identity.SetClaim(Claims.Email, user.Email);
identity.SetClaim(Claims.EmailVerified, user.IsEmailConfirmed);
if (!string.IsNullOrWhiteSpace(user.FullName))
identity.SetClaim(Claims.GivenName, user.FullName);
identity.SetScopes(scopes);
identity.SetDestinations(static claim => claim.Type switch
{
Claims.Name or Claims.PreferredUsername or Claims.Email or Claims.EmailVerified or Claims.GivenName
=> [Destinations.AccessToken, Destinations.IdentityToken],
_ => [Destinations.AccessToken]
});
return new ClaimsPrincipal(identity);
}
}
@@ -0,0 +1,10 @@
namespace MyOffice.Web.Auth;
public static class OpenIddictAuthConstants
{
public const string ApiScope = "api";
public const string ApiFriendlyName = "MyOffice.Web API";
public const string SpaClientId = "angulartemplate_spa";
public const string ExternalGrantType = "external";
public const string RolesScope = "roles";
}
@@ -0,0 +1,130 @@
namespace MyOffice.Web.Auth;
using DbContext;
using Infrastructure;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
public sealed class OpenIddictSeeder : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly IConfiguration _configuration;
private readonly ILogger<OpenIddictSeeder> _logger;
public OpenIddictSeeder(
IServiceProvider serviceProvider,
IConfiguration configuration,
ILogger<OpenIddictSeeder> logger
)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await using var scope = _serviceProvider.CreateAsyncScope();
await RegisterScopesAsync(scope.ServiceProvider, cancellationToken);
await RegisterClientAsync(scope.ServiceProvider, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task RegisterScopesAsync(IServiceProvider provider, CancellationToken cancellationToken)
{
var manager = provider.GetRequiredService<IOpenIddictScopeManager>();
if (await manager.FindByNameAsync(OpenIddictAuthConstants.ApiScope, cancellationToken) is null)
{
await manager.CreateAsync(new OpenIddictScopeDescriptor
{
Name = OpenIddictAuthConstants.ApiScope,
DisplayName = OpenIddictAuthConstants.ApiFriendlyName,
Resources = { OpenIddictAuthConstants.ApiScope }
}, cancellationToken);
_logger.LogInformation("Created OpenIddict scope {Scope}.", OpenIddictAuthConstants.ApiScope);
}
if (await manager.FindByNameAsync(OpenIddictAuthConstants.RolesScope, cancellationToken) is null)
{
await manager.CreateAsync(new OpenIddictScopeDescriptor
{
Name = OpenIddictAuthConstants.RolesScope,
DisplayName = "User roles"
}, cancellationToken);
_logger.LogInformation("Created OpenIddict scope {Scope}.", OpenIddictAuthConstants.RolesScope);
}
}
private async Task RegisterClientAsync(IServiceProvider provider, CancellationToken cancellationToken)
{
var manager = provider.GetRequiredService<IOpenIddictApplicationManager>();
var globalSettings = provider.GetRequiredService<GlobalSettings>();
var redirectUri = BuildRedirectUri(globalSettings.Host);
var existing = await manager.FindByClientIdAsync(OpenIddictAuthConstants.SpaClientId, cancellationToken);
var descriptor = CreateSpaClientDescriptor(redirectUri);
if (existing is null)
{
await manager.CreateAsync(descriptor, cancellationToken);
_logger.LogInformation("Created OpenIddict client {ClientId}.", OpenIddictAuthConstants.SpaClientId);
return;
}
var currentRedirectUris = await manager.GetRedirectUrisAsync(existing, cancellationToken);
foreach (var uri in currentRedirectUris)
{
if (!string.Equals(uri, redirectUri.ToString(), StringComparison.Ordinal))
descriptor.RedirectUris.Add(new Uri(uri, UriKind.Absolute));
}
await manager.UpdateAsync(existing, descriptor, cancellationToken);
_logger.LogInformation("Updated OpenIddict client {ClientId}.", OpenIddictAuthConstants.SpaClientId);
}
internal static OpenIddictApplicationDescriptor CreateSpaClientDescriptor(Uri redirectUri)
{
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = OpenIddictAuthConstants.SpaClientId,
DisplayName = "MyOffice SPA",
ClientType = ClientTypes.Public,
ConsentType = ConsentTypes.Implicit,
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.Endpoints.EndSession,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.Password,
Permissions.GrantTypes.RefreshToken,
Permissions.Prefixes.GrantType + OpenIddictAuthConstants.ExternalGrantType,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles,
Permissions.Prefixes.Scope + Scopes.OpenId,
Permissions.Prefixes.Scope + Scopes.OfflineAccess,
Permissions.Prefixes.Scope + OpenIddictAuthConstants.ApiScope,
Permissions.Prefixes.Scope + OpenIddictAuthConstants.RolesScope
}
};
descriptor.RedirectUris.Add(redirectUri);
return descriptor;
}
internal static Uri BuildRedirectUri(string? host)
{
if (string.IsNullOrWhiteSpace(host))
return new Uri("http://localhost:4300/silent-refresh.html");
return new Uri($"{host.TrimEnd('/')}/silent-refresh.html");
}
}
@@ -0,0 +1,192 @@
namespace MyOffice.Web.Auth;
using System.Security.Cryptography;
using DbContext;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Server;
using OpenIddict.Validation.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.OpenIddictServerEvents;
public static class OpenIddictServiceCollectionExtensions
{
public static IServiceCollection AddMyOfficeOpenIddict(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment
)
{
services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<AppDbContext>();
})
.AddServer(options =>
{
options.SetAuthorizationEndpointUris("/connect/authorize")
.SetTokenEndpointUris("/connect/token")
.SetUserInfoEndpointUris("/connect/userinfo")
.SetEndSessionEndpointUris("/connect/logout");
options.AllowAuthorizationCodeFlow()
.AllowPasswordFlow()
.AllowRefreshTokenFlow()
.AllowCustomFlow(OpenIddictAuthConstants.ExternalGrantType);
options.RegisterScopes(
Scopes.OpenId,
Scopes.Email,
Scopes.Profile,
Scopes.Roles,
Scopes.OfflineAccess,
OpenIddictAuthConstants.ApiScope,
OpenIddictAuthConstants.RolesScope);
ConfigureCryptography(options, configuration, environment);
var aspNetCoreBuilder = options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableUserInfoEndpointPassthrough()
.EnableEndSessionEndpointPassthrough();
// Local HTTP only. In production nginx terminates TLS and
// ForwardedHeaders restores https:// for OpenIddict.
// Docker demo also serves plain HTTP (OpenIddict:AllowHttp / Environment=Docker).
if (environment.IsDevelopment()
|| IsDockerEnvironment(environment)
|| configuration.GetValue("OpenIddict:AllowHttp", false))
{
aspNetCoreBuilder.DisableTransportSecurityRequirement();
}
// Password and external grants need custom handlers (separate registrations —
// a second UseScopedHandler() on the same builder replaces the first).
options.AddEventHandler<HandleTokenRequestContext>(builder =>
builder.UseScopedHandler<PasswordGrantHandler>());
options.AddEventHandler<HandleTokenRequestContext>(builder =>
builder.UseScopedHandler<ExternalGrantHandler>());
})
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});
services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
services.AddHostedService<OpenIddictSeeder>();
return services;
}
private static void ConfigureCryptography(
OpenIddictServerBuilder options,
IConfiguration configuration,
IHostEnvironment environment
)
{
// Dev: certs in the user profile. Docker/demo: ephemeral keys (no cert store in containers).
if (string.IsNullOrWhiteSpace(configuration["OpenIddict:SigningKeyPath"])
&& (environment.IsDevelopment()
|| IsDockerEnvironment(environment)
|| configuration.GetValue("OpenIddict:UseEphemeralKeys", false)))
{
if (IsDockerEnvironment(environment)
|| configuration.GetValue("OpenIddict:UseEphemeralKeys", false))
{
options.AddEphemeralEncryptionKey()
.AddEphemeralSigningKey();
}
else
{
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
}
return;
}
var contentRoot = configuration.GetValue<string>(WebHostDefaults.ContentRootKey)
?? throw new InvalidOperationException("Content root path is not configured.");
var signingPath = ResolveKeyPath(
configuration["OpenIddict:SigningKeyPath"],
contentRoot,
"keys/signing.pem");
var encryptionPath = ResolveKeyPath(
configuration["OpenIddict:EncryptionKeyPath"],
contentRoot,
"keys/encryption.pem");
if (!File.Exists(signingPath))
{
throw new InvalidOperationException(
$"OpenIddict signing key not found at '{signingPath}'. " +
"Set OpenIddict:SigningKeyPath or place keys/signing.pem under the content root.");
}
options.AddSigningKey(LoadRsaSecurityKey(signingPath));
if (File.Exists(encryptionPath) &&
!string.Equals(encryptionPath, signingPath, StringComparison.OrdinalIgnoreCase))
{
options.AddEncryptionKey(LoadRsaSecurityKey(encryptionPath));
}
else
{
// Prefer a dedicated encryption key. Fallback keeps older single-key setups working.
options.AddEncryptionKey(LoadRsaSecurityKey(signingPath));
}
}
private static bool IsDockerEnvironment(IHostEnvironment environment) =>
string.Equals(environment.EnvironmentName, "Docker", StringComparison.OrdinalIgnoreCase);
private static string ResolveKeyPath(string? configuredPath, string contentRoot, string defaultRelative)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.IsPathRooted(configuredPath)
? configuredPath
: Path.GetFullPath(Path.Combine(contentRoot, configuredPath));
}
return Path.GetFullPath(Path.Combine(contentRoot, defaultRelative));
}
private static RsaSecurityKey LoadRsaSecurityKey(string path)
{
var privateKey = File.ReadAllText(path)
.Replace("-----BEGIN RSA PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
.Replace("-----END RSA PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
.Replace("-----BEGIN PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
.Replace("-----END PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal);
var rsa = RSA.Create();
var keyBytes = Convert.FromBase64String(privateKey);
try
{
rsa.ImportRSAPrivateKey(keyBytes, out _);
}
catch (CryptographicException)
{
rsa.ImportPkcs8PrivateKey(keyBytes, out _);
}
return new RsaSecurityKey(rsa);
}
}
@@ -0,0 +1,70 @@
namespace MyOffice.Web.Auth;
using Identity.Domain;
using Identity.Repositories;
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.OpenIddictServerEvents;
public sealed class PasswordGrantHandler : IOpenIddictServerHandler<HandleTokenRequestContext>
{
private readonly AppUserManager _userManager;
private readonly SignInManager<ApplicationUser<Guid>> _signInManager;
public PasswordGrantHandler(
AppUserManager userManager,
SignInManager<ApplicationUser<Guid>> signInManager
)
{
_userManager = userManager;
_signInManager = signInManager;
}
public async ValueTask HandleAsync(HandleTokenRequestContext context)
{
if (!string.Equals(context.Request.GrantType, GrantTypes.Password, StringComparison.Ordinal))
return;
var username = context.Request.Username?.Trim();
if (string.IsNullOrWhiteSpace(username))
{
context.Reject(Errors.InvalidGrant, "Invalid username or password.");
return;
}
var user = await _userManager.FindByNameAsync(username)
?? await _userManager.FindByEmailAsync(username);
if (user is null)
{
context.Reject(Errors.InvalidGrant, "Invalid username or password.");
return;
}
if (!await _signInManager.CanSignInAsync(user))
{
context.Reject(Errors.InvalidGrant, "The specified user cannot sign in.");
return;
}
if (_userManager.SupportsUserLockout && await _userManager.IsLockedOutAsync(user))
{
context.Reject(Errors.InvalidGrant, "The specified user is locked out.");
return;
}
var result = await _signInManager.CheckPasswordSignInAsync(user, context.Request.Password!, lockoutOnFailure: true);
if (!result.Succeeded)
{
context.Reject(Errors.InvalidGrant, "Invalid username or password.");
return;
}
if (_userManager.SupportsUserLockout)
await _userManager.ResetAccessFailedCountAsync(user);
context.SignIn(OpenIddictClaimsHelper.CreatePrincipal(user, context.Request.GetScopes()));
}
}