Add project files.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<configuration>
|
||||
</configuration>
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using System.Security.Authentication;
|
||||
using Core.Extensions;
|
||||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static IdentityServer4.Models.IdentityResources;
|
||||
|
||||
public class BaseApiController : ControllerBase
|
||||
{
|
||||
public Guid UserId
|
||||
{
|
||||
get
|
||||
{
|
||||
var subject = User.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Subject)?.Value;
|
||||
if (subject.IsPresent() && Guid.TryParse(subject, out var guid)) return guid;
|
||||
|
||||
throw new AuthenticationException("Get UserId failed");
|
||||
}
|
||||
}
|
||||
|
||||
public ObjectResult ProblemBadRequest(string? detail = null)
|
||||
{
|
||||
return Problem(detail, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using Models.Auth;
|
||||
using Identity.Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using MyOffice.Data.Models.Users;
|
||||
using Core.Extensions;
|
||||
using MyOffice.Core.Identity;
|
||||
using Core;
|
||||
using Services.Users;
|
||||
using Services.Users.Domain;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UserController : BaseApiController
|
||||
{
|
||||
private readonly UserManager<ApplicationUser<Guid>> _userManager;
|
||||
private readonly UserService _userService;
|
||||
private readonly IEnumerable<IExternalProviderValidator> _externalProviderValidators;
|
||||
|
||||
public UserController(
|
||||
UserManager<ApplicationUser<Guid>> userManager,
|
||||
UserService userService,
|
||||
IEnumerable<IExternalProviderValidator> externalProviderValidators
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userService = userService;
|
||||
_externalProviderValidators = externalProviderValidators;
|
||||
}
|
||||
|
||||
[HttpPost("~/api/user/register")]
|
||||
public async Task<object> Register([FromBody] RegisterModel request)
|
||||
{
|
||||
var user = new ApplicationUser<Guid>
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = request.UserName,
|
||||
Email = request.UserName
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, request.Password);
|
||||
|
||||
return new
|
||||
{
|
||||
result.Succeeded,
|
||||
Errors = result.Errors.Where(x => !x.Code.Equals("DuplicateUserName"))
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("~/api/user/profile")]
|
||||
public async Task<object> ProfileGet()
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
|
||||
return user.ToModel(await _userService.GetUserExternals(user.Id));
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("~/api/user/profile")]
|
||||
public async Task<object> ProfileUpdate([FromBody] ProfileModel model)
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
|
||||
user.FirstName = model.FirstName;
|
||||
user.LastName = model.LastName;
|
||||
user.FullName = model.FullName.NullIfEmpty() ?? $"{model.FirstName} {model.LastName}";
|
||||
|
||||
await _userManager.UpdateAsync(user);
|
||||
|
||||
return user.ToModel(await _userService.GetUserExternals(user.Id));
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("~/api/user/attach")]
|
||||
public async Task<object> AttachProvider([FromBody] AttachModel model)
|
||||
{
|
||||
var validator = _externalProviderValidators.FirstOrDefault(x => x.Provider.EqualsIgnoreCase(model.Provider));
|
||||
if (validator == null) return ProblemBadRequest("Provider not supported");
|
||||
|
||||
var result = await validator.ValidateAsync(model.Token);
|
||||
if (!result.IsSuccessed) return ProblemBadRequest("Token not valid");
|
||||
|
||||
var execResult = _userService.AddUserExternal(UserId, model.Provider, result.ExternalId!, result.Email!);
|
||||
switch (execResult.Status)
|
||||
{
|
||||
case AddUserExternalStatusEnum.success:
|
||||
return Ok(new
|
||||
{
|
||||
success = true
|
||||
});
|
||||
|
||||
case AddUserExternalStatusEnum.externalid_used:
|
||||
case AddUserExternalStatusEnum.user_not_valid:
|
||||
return ProblemBadRequest("Provider already connected");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(execResult.Status.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("~/api/user/deattach")]
|
||||
public async Task<object> DeattachProvider([FromBody] DeattachModel model)
|
||||
{
|
||||
var exec = await _userService.RemoveUserExternal(UserId, model.Provider);
|
||||
|
||||
if (exec.Status == GeneralExecStatus.not_found) return ProblemBadRequest("Provider not connected");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class DeattachModel
|
||||
{
|
||||
[Required] public string Provider { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class AttachModel
|
||||
{
|
||||
[Required] public string Provider { get; set; } = null!;
|
||||
[Required] public string Token { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class ProfileModel
|
||||
{
|
||||
public class ProviderModel
|
||||
{
|
||||
public string Provider { get; set; } = null!;
|
||||
|
||||
public DateTime CreatedOn { get; set; }
|
||||
}
|
||||
|
||||
public string? Email { get; set; }
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public bool? IsEmailConfirmed { get; set; }
|
||||
|
||||
public List<ProviderModel>? Providers { get; set; }
|
||||
}
|
||||
|
||||
public static class ProfileModelExtensions
|
||||
{
|
||||
public static ProfileModel ToModel(this ApplicationUser<Guid> user, List<UserExternal> userClaims)
|
||||
{
|
||||
return new ProfileModel
|
||||
{
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed,
|
||||
Providers = userClaims?.Select(x => new ProfileModel.ProviderModel
|
||||
{
|
||||
Provider = x.Provider,
|
||||
CreatedOn = x.CreatedOn
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace MyOffice.Web.Controllers;
|
||||
|
||||
using Models.WeatherForecast;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class WeatherForecastController : ControllerBase
|
||||
{
|
||||
private static readonly string[] Summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
private readonly ILogger<WeatherForecastController> _logger;
|
||||
|
||||
public WeatherForecastController(ILogger<WeatherForecastController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("~/api/weatherforecast")]
|
||||
public IEnumerable<WeatherForecast> Get()
|
||||
{
|
||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = DateTime.Now.AddDays(index),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
[HttpGet("~/api/weatherforecast/auth")]
|
||||
[Authorize]
|
||||
public IEnumerable<WeatherForecast> GetAuth()
|
||||
{
|
||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = DateTime.Now.AddDays(index),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace MyOffice.Web.Identity.Configure;
|
||||
|
||||
using MyOffice.Web.Infrastructure;
|
||||
using IdentityServer4.AccessTokenValidation;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class ConfigureIdentityServerOptions : IConfigureNamedOptions<IdentityServerAuthenticationOptions>
|
||||
{
|
||||
readonly GlobalSettings _globalSettings;
|
||||
readonly ILogger<ConfigureIdentityServerOptions> _logger;
|
||||
|
||||
public ConfigureIdentityServerOptions(
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<ConfigureIdentityServerOptions> logger
|
||||
)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void Configure(string? name, IdentityServerAuthenticationOptions options)
|
||||
{
|
||||
//_logger.LogError($"Configure: {_globalSettings.Host}");
|
||||
if (name == IdentityServerAuthenticationDefaults.AuthenticationScheme)
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.ApiName = IdentityServerConfig.ApiName;
|
||||
options.Authority = _globalSettings.Host;
|
||||
}
|
||||
}
|
||||
|
||||
// This won't be called, but is required for the IConfigureNamedOptions interface
|
||||
public void Configure(IdentityServerAuthenticationOptions options) => Configure(Options.DefaultName, options);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace MyOffice.Web.Identity.Configure;
|
||||
|
||||
using IdentityModel;
|
||||
using IdentityServer4;
|
||||
using IdentityServer4.Models;
|
||||
|
||||
public class IdentityServerConfig
|
||||
{
|
||||
public static class ClaimConstants
|
||||
{
|
||||
public const string Subject = "subject";
|
||||
public const string Permission = "permission";
|
||||
}
|
||||
|
||||
public static class ScopeConstants
|
||||
{
|
||||
public const string Roles = "roles";
|
||||
}
|
||||
|
||||
public const string ApiName = "api";
|
||||
public const string ApiFriendlyName = "MyOffice.Web API";
|
||||
public const string AppClientID = "angulartemplate_spa";
|
||||
|
||||
public static IEnumerable<IdentityResource> GetIdentityResources()
|
||||
{
|
||||
return new List<IdentityResource>
|
||||
{
|
||||
new IdentityResources.OpenId(),
|
||||
new IdentityResources.Profile(),
|
||||
new IdentityResources.Phone(),
|
||||
new IdentityResources.Email(),
|
||||
new(ScopeConstants.Roles, new List<string> { JwtClaimTypes.Role })
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<ApiScope> GetApiScopes()
|
||||
{
|
||||
return new List<ApiScope>
|
||||
{
|
||||
new(ApiName, ApiFriendlyName)
|
||||
{
|
||||
UserClaims =
|
||||
{
|
||||
JwtClaimTypes.Name,
|
||||
JwtClaimTypes.Email,
|
||||
JwtClaimTypes.PhoneNumber,
|
||||
JwtClaimTypes.Role,
|
||||
ClaimConstants.Permission
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<ApiResource> GetApiResources()
|
||||
{
|
||||
return new List<ApiResource>
|
||||
{
|
||||
new(ApiName)
|
||||
{
|
||||
Scopes = { ApiName }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<Client> GetClients()
|
||||
{
|
||||
return new List<Client>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ClientId = AppClientID,
|
||||
ClientSecrets = new List<Secret>
|
||||
{
|
||||
new Secret("secret".Sha256())
|
||||
},
|
||||
AllowedGrantTypes = new[]
|
||||
{
|
||||
GrantType.AuthorizationCode,
|
||||
GrantType.ClientCredentials,
|
||||
GrantType.DeviceFlow,
|
||||
GrantType.ResourceOwnerPassword,
|
||||
"external"
|
||||
},
|
||||
AllowAccessTokensViaBrowser = true,
|
||||
RequireClientSecret = false,
|
||||
|
||||
RedirectUris = new[]
|
||||
{
|
||||
"http://localhost:4200/silent-refresh.html",
|
||||
},
|
||||
|
||||
AllowedScopes =
|
||||
{
|
||||
IdentityServerConstants.StandardScopes.OpenId,
|
||||
IdentityServerConstants.StandardScopes.Profile,
|
||||
IdentityServerConstants.StandardScopes.Phone,
|
||||
IdentityServerConstants.StandardScopes.Email,
|
||||
ScopeConstants.Roles,
|
||||
ApiName
|
||||
},
|
||||
AllowOfflineAccess = true,
|
||||
RefreshTokenExpiration = TokenExpiration.Sliding,
|
||||
RefreshTokenUsage = TokenUsage.OneTimeOnly
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace MyOffice.Web.Identity;
|
||||
|
||||
using Core.Extensions;
|
||||
using IdentityServer4.Services;
|
||||
using Infrastructure;
|
||||
|
||||
public class CorsPolicyService : ICorsPolicyService
|
||||
{
|
||||
private readonly ILogger<CorsPolicyService> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public CorsPolicyService(
|
||||
ILogger<CorsPolicyService> logger,
|
||||
GlobalSettings globalSettings
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public Task<bool> IsOriginAllowedAsync(string origin)
|
||||
{
|
||||
|
||||
var result = origin.EqualsIgnoreCase(_globalSettings.Host);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MyOffice.Web.Identity.Domain;
|
||||
|
||||
public class ApplicationRole
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace MyOffice.Web.Identity.Domain;
|
||||
|
||||
public class ApplicationUser<TKey>
|
||||
{
|
||||
#pragma warning disable CS8618 // Non-nullable property 'Id' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
|
||||
public TKey Id { get; set; }
|
||||
#pragma warning restore CS8618 // Non-nullable property 'Id' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
|
||||
public string UserName { get; set; } = null!;
|
||||
public string Email { get; set; } = null!;
|
||||
public string PasswordHash { get; set; } = null!;
|
||||
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public bool IsEmailConfirmed { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MyOffice.Web.Identity.Domain;
|
||||
|
||||
public class ExternalProvidersConfig
|
||||
{
|
||||
public class Auth0Config
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
public string? Domain { get; set; }
|
||||
public string? SecretKey { get; set; }
|
||||
}
|
||||
|
||||
public class GoogleConfig
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
}
|
||||
|
||||
public Auth0Config? Auth0 { get; set; }
|
||||
public GoogleConfig? Google { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
namespace MyOffice.Web.Identity;
|
||||
|
||||
using MyOffice.Data.Models.Users;
|
||||
using Domain;
|
||||
using Data.Repositories.Users;
|
||||
using Repositories;
|
||||
using IdentityModel;
|
||||
using IdentityServer4.Models;
|
||||
using IdentityServer4.Validation;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using Core.Extensions;
|
||||
using Core.Identity;
|
||||
|
||||
public class ExtensionGrantValidator : IExtensionGrantValidator
|
||||
{
|
||||
private readonly IUserStore<ApplicationUser<Guid>> _userStore;
|
||||
private readonly ILogger<ExtensionGrantValidator> _logger;
|
||||
private readonly AppUserManager _appUserManager;
|
||||
private readonly IUserExternalRepository _userExternalRepository;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ExternalProvidersConfig _externalProvidersConfig;
|
||||
private readonly IEnumerable<IExternalProviderValidator> _externalProviderValidators;
|
||||
|
||||
public ExtensionGrantValidator(
|
||||
ILogger<ExtensionGrantValidator> logger,
|
||||
IUserStore<ApplicationUser<Guid>> userStore,
|
||||
AppUserManager appUserManager,
|
||||
IUserExternalRepository userExternalRepository,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<ExternalProvidersConfig> externalProvidersConfig,
|
||||
IEnumerable<IExternalProviderValidator> externalProviderValidators
|
||||
)
|
||||
{
|
||||
if (externalProvidersConfig == null)
|
||||
throw new ArgumentNullException(nameof(externalProvidersConfig));
|
||||
|
||||
_userStore = userStore ?? throw new ArgumentNullException(nameof(userStore));
|
||||
_appUserManager = appUserManager ?? throw new ArgumentNullException(nameof(appUserManager));
|
||||
_userExternalRepository =
|
||||
userExternalRepository ?? throw new ArgumentNullException(nameof(userExternalRepository));
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
_externalProvidersConfig = externalProvidersConfig.Value;
|
||||
_externalProviderValidators = externalProviderValidators;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string GrantType => "external";
|
||||
|
||||
public async Task ValidateAsync(ExtensionGrantValidationContext context)
|
||||
{
|
||||
var provider = context.Request.Raw["provider"];
|
||||
if (provider == null)
|
||||
throw new ArgumentNullException("provider not found");
|
||||
|
||||
var validator = _externalProviderValidators.FirstOrDefault(x => x.Provider.EqualsIgnoreCase(provider));
|
||||
if (validator == null || !validator.IsConfigured)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest,
|
||||
$"Provider not supported: {provider}");
|
||||
return;
|
||||
}
|
||||
|
||||
var token = context.Request.Raw["token"];
|
||||
if (token == null)
|
||||
throw new ArgumentNullException("token not found");
|
||||
|
||||
var result = await validator.ValidateAsync(token);
|
||||
if (!result.IsSuccessed)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, $"Token not valid");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Result = await ProcessAsync(result.Email!, result.ExternalId!, provider, result.EmailVerified,
|
||||
result.FullName);
|
||||
}
|
||||
|
||||
private readonly object _lock = new();
|
||||
|
||||
private async Task<GrantValidationResult> ProcessAsync(
|
||||
string email,
|
||||
string externlaId,
|
||||
string provider,
|
||||
bool isEmailConfirmed,
|
||||
string? fullName
|
||||
)
|
||||
{
|
||||
// connect to exists user
|
||||
if (_httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
return new GrantValidationResult(TokenRequestErrors.InvalidRequest, $"Authentication failed");
|
||||
}
|
||||
else
|
||||
{
|
||||
var user = await _appUserManager.FindByEmailAsync(email);
|
||||
if (user == null)
|
||||
{
|
||||
if (!isEmailConfirmed)
|
||||
return new GrantValidationResult(TokenRequestErrors.InvalidRequest, "Email not confirmed");
|
||||
|
||||
user = await CreateUser(email, isEmailConfirmed, fullName);
|
||||
if (user == null) return new GrantValidationResult(TokenRequestErrors.InvalidRequest);
|
||||
}
|
||||
|
||||
var claim = await _userExternalRepository.GetByUserIdAsync(user.Id, provider);
|
||||
if (claim == null) claim = AddClaim(user, email, externlaId, provider);
|
||||
|
||||
return ClaimToGrantValidationResult(claim.UserId, "", "");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser<Guid>?> CreateUser(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 _appUserManager.CreateAsync(user, password);
|
||||
if (!result.Succeeded) return null;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private UserExternal AddClaim(ApplicationUser<Guid> user, string email, string externalId, string provider)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var claim = _userExternalRepository.GetByUserId(user.Id, provider);
|
||||
|
||||
if (claim != null) return claim;
|
||||
|
||||
claim = new UserExternal
|
||||
{
|
||||
UserId = user.Id,
|
||||
CreatedOn = DateTime.UtcNow,
|
||||
Provider = provider.ToLower(),
|
||||
ExternalId = externalId,
|
||||
Email = email
|
||||
};
|
||||
_userExternalRepository.AddUserExternal(claim);
|
||||
|
||||
return claim;
|
||||
}
|
||||
}
|
||||
|
||||
private GrantValidationResult ClaimToGrantValidationResult(Guid userId, string provider, string email)
|
||||
{
|
||||
var userIdStr = userId.ToString();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtClaimTypes.Id, userIdStr),
|
||||
new(JwtClaimTypes.Subject, userIdStr),
|
||||
new(JwtClaimTypes.Email, email)
|
||||
};
|
||||
|
||||
var grantValidationResult = new GrantValidationResult(userIdStr, provider, claims);
|
||||
|
||||
return grantValidationResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace MyOffice.Web.Identity.ExternalProviders;
|
||||
|
||||
using Domain;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using Core.Extensions;
|
||||
using Core.Identity;
|
||||
|
||||
public class ExternalProviderValidatorAuth0 : IExternalProviderValidator
|
||||
{
|
||||
private readonly ExternalProvidersConfig _externalProvidersConfig;
|
||||
private readonly ILogger<ExternalProviderValidatorAuth0> _logger;
|
||||
|
||||
public ExternalProviderValidatorAuth0(
|
||||
ILogger<ExternalProviderValidatorAuth0> logger,
|
||||
IOptions<ExternalProvidersConfig> externalProvidersConfig
|
||||
)
|
||||
{
|
||||
_externalProvidersConfig = externalProvidersConfig.Value;
|
||||
_logger = logger;
|
||||
|
||||
Provider = ExternalProvidersConst.PROVIDER_AUTH0;
|
||||
IsConfigured = _externalProvidersConfig.IsAuth0Configured();
|
||||
}
|
||||
|
||||
public string Provider { get; }
|
||||
public bool IsConfigured { get; }
|
||||
|
||||
public async Task<ExternalProviderValidatorResult> ValidateAsync(string token)
|
||||
{
|
||||
var auth0Domain = _externalProvidersConfig.Auth0!.Domain!;
|
||||
var secretKey = _externalProvidersConfig.Auth0!.SecretKey!;
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(secretKey));
|
||||
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
$"{auth0Domain}.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever());
|
||||
var openIdConfig = await configurationManager.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
var validations = new TokenValidationParameters
|
||||
{
|
||||
ValidIssuer = auth0Domain,
|
||||
ValidAudiences = new[] { _externalProvidersConfig.Auth0!.ClientId },
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
TokenDecryptionKey = securityKey
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var user = tokenHandler.ValidateToken(token, validations, out var validatedToken);
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return ExternalProviderValidatorResult.Failed();
|
||||
}
|
||||
|
||||
var email = user.Claims.GetEmail();
|
||||
var emailVerified = user.Claims.GetValue("email_verified").AsBool();
|
||||
var fullName = user.Claims.GetValue("name")?.ToString();
|
||||
var externalId = user.Claims.GetSID();
|
||||
|
||||
if (email == null || externalId == null)
|
||||
{
|
||||
return ExternalProviderValidatorResult.Failed();
|
||||
}
|
||||
|
||||
return ExternalProviderValidatorResult.Success(email, emailVerified, externalId, fullName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace MyOffice.Web.Identity.ExternalProviders;
|
||||
|
||||
using MyOffice.Core.Identity;
|
||||
using Domain;
|
||||
using Google.Apis.Auth;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class ExternalProviderValidatorGoogle : IExternalProviderValidator
|
||||
{
|
||||
private readonly ExternalProvidersConfig _externalProvidersConfig;
|
||||
|
||||
public ExternalProviderValidatorGoogle(
|
||||
IOptions<ExternalProvidersConfig> externalProvidersConfig
|
||||
)
|
||||
{
|
||||
_externalProvidersConfig = externalProvidersConfig.Value;
|
||||
|
||||
Provider = ExternalProvidersConst.PROVIDER_GOOGLE;
|
||||
IsConfigured = _externalProvidersConfig.IsGoogleConfigured();
|
||||
}
|
||||
|
||||
public string Provider { get; }
|
||||
public bool IsConfigured { get; }
|
||||
|
||||
public async Task<ExternalProviderValidatorResult> ValidateAsync(string token)
|
||||
{
|
||||
var settings = new GoogleJsonWebSignature.ValidationSettings()
|
||||
{
|
||||
Audience = new List<string>() { _externalProvidersConfig.Google!.ClientId! }
|
||||
};
|
||||
var result = await GoogleJsonWebSignature.ValidateAsync(token, settings);
|
||||
if (!result.EmailVerified) return ExternalProviderValidatorResult.Failed();
|
||||
|
||||
return ExternalProviderValidatorResult.Success(result.Email, result.EmailVerified, result.Subject, result.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MyOffice.Web.Identity.ExternalProviders;
|
||||
|
||||
public class ExternalProvidersConst
|
||||
{
|
||||
public const string PROVIDER_GOOGLE = "google";
|
||||
public const string PROVIDER_AUTH0 = "auth0";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using MyOffice.Web.Identity.Domain;
|
||||
|
||||
namespace MyOffice.Web.Identity;
|
||||
|
||||
using Core.Extensions;
|
||||
|
||||
public static class ExternalProvidersConfigExtensions
|
||||
{
|
||||
public static bool IsGoogleConfigured(this ExternalProvidersConfig? config)
|
||||
{
|
||||
return config != null
|
||||
&& config.Google != null
|
||||
&& config.Google.ClientId.IsPresent();
|
||||
}
|
||||
|
||||
public static bool IsAuth0Configured(this ExternalProvidersConfig? config)
|
||||
{
|
||||
return config != null
|
||||
&& config.Auth0 != null
|
||||
&& config.Auth0.ClientId.IsPresent()
|
||||
&& config.Auth0.Domain.IsPresent()
|
||||
&& config.Auth0.SecretKey.IsPresent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace MyOffice.Web.Identity;
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Core.Extensions;
|
||||
using IdentityServer4.Models;
|
||||
using IdentityServer4.Stores;
|
||||
|
||||
public class PersistedGrantStore : IPersistedGrantStore
|
||||
{
|
||||
private static ConcurrentDictionary<string, PersistedGrant> _store = new ConcurrentDictionary<string, PersistedGrant>();
|
||||
|
||||
public Task StoreAsync(PersistedGrant grant)
|
||||
{
|
||||
_store.AddOrUpdate(grant.Key, grant, (key, oldValue) => grant);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PersistedGrant> GetAsync(string key)
|
||||
{
|
||||
if (!_store.TryGetValue(key, out var grant))
|
||||
throw new Exception($"Key '{key}' not found");
|
||||
|
||||
return Task.FromResult(grant);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
|
||||
{
|
||||
var list = _store.Values.Where(x =>
|
||||
(filter.ClientId.IsMissing() || filter.ClientId == x.ClientId)
|
||||
&& (filter.SessionId.IsMissing() || filter.SessionId == x.SessionId)
|
||||
&& (filter.SubjectId.IsMissing() || filter.SubjectId == x.SubjectId)
|
||||
&& (filter.Type.IsMissing() || filter.Type == x.Type)
|
||||
);
|
||||
|
||||
return Task.FromResult(list);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string key)
|
||||
{
|
||||
_store.TryRemove(key, out _);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task RemoveAllAsync(PersistedGrantFilter filter)
|
||||
{
|
||||
var list = await GetAllAsync(filter);
|
||||
foreach (var item in list)
|
||||
{
|
||||
await RemoveAsync(item.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace MyOffice.Web.Identity;
|
||||
|
||||
using Data.Repositories.Users;
|
||||
using IdentityModel;
|
||||
using IdentityServer4.Models;
|
||||
using IdentityServer4.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
public class ProfileService : IProfileService
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public ProfileService(
|
||||
IUserRepository userRepository
|
||||
)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
|
||||
{
|
||||
var sub = context.Subject.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Subject);
|
||||
if (sub != null && Guid.TryParse(sub.Value, out var id))
|
||||
{
|
||||
var user = await _userRepository.GetUserAsync(id);
|
||||
if (user != null)
|
||||
if (context.RequestedClaimTypes.Contains(JwtClaimTypes.Email))
|
||||
context.IssuedClaims.Add(new Claim(JwtClaimTypes.Email, user.Email));
|
||||
}
|
||||
}
|
||||
|
||||
public Task IsActiveAsync(IsActiveContext context)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace MyOffice.Web.Identity.Repositories;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class PasswordHasher : IPasswordHasher<ApplicationUser<Guid>>
|
||||
{
|
||||
public string HashPassword(ApplicationUser<Guid> user, string password)
|
||||
{
|
||||
byte[] salt;
|
||||
salt = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 100000);
|
||||
var hash = pbkdf2.GetBytes(20);
|
||||
|
||||
var hashBytes = new byte[36];
|
||||
Array.Copy(salt, 0, hashBytes, 0, 16);
|
||||
Array.Copy(hash, 0, hashBytes, 16, 20);
|
||||
|
||||
var passwordHash = Convert.ToBase64String(hashBytes);
|
||||
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
private bool IsValidHash(string password, string passwordHash)
|
||||
{
|
||||
/* Fetch the stored value */
|
||||
/* Extract the bytes */
|
||||
var hashBytes = Convert.FromBase64String(passwordHash);
|
||||
/* Get the salt */
|
||||
var salt = new byte[16];
|
||||
Array.Copy(hashBytes, 0, salt, 0, 16);
|
||||
/* Compute the hash on the password the user entered */
|
||||
var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 100000);
|
||||
var hash = pbkdf2.GetBytes(20);
|
||||
/* Compare the results */
|
||||
for (var i = 0; i < 20; i++)
|
||||
if (hashBytes[i + 16] != hash[i])
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public PasswordVerificationResult VerifyHashedPassword(
|
||||
ApplicationUser<Guid> user,
|
||||
string hashedPassword,
|
||||
string providedPassword
|
||||
)
|
||||
{
|
||||
if (IsValidHash(providedPassword, hashedPassword)) return PasswordVerificationResult.Success;
|
||||
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
public class AppUserManager : UserManager<ApplicationUser<Guid>>
|
||||
{
|
||||
public AppUserManager(
|
||||
IUserStore<ApplicationUser<Guid>> store,
|
||||
IOptions<IdentityOptions> optionsAccessor,
|
||||
IPasswordHasher<ApplicationUser<Guid>> passwordHasher,
|
||||
IEnumerable<IUserValidator<ApplicationUser<Guid>>> userValidators,
|
||||
IEnumerable<IPasswordValidator<ApplicationUser<Guid>>> passwordValidators,
|
||||
ILookupNormalizer keyNormalizer,
|
||||
IdentityErrorDescriber errors,
|
||||
IServiceProvider services,
|
||||
ILogger<UserManager<ApplicationUser<Guid>>> logger) :
|
||||
base(
|
||||
store,
|
||||
optionsAccessor,
|
||||
passwordHasher,
|
||||
userValidators,
|
||||
passwordValidators,
|
||||
keyNormalizer,
|
||||
errors,
|
||||
services,
|
||||
logger
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace MyOffice.Web.Identity.Repositories;
|
||||
|
||||
using Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
public class RoleStore : IRoleStore<ApplicationRole>
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<IdentityResult> CreateAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<IdentityResult> UpdateAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<IdentityResult> DeleteAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetRoleIdAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SetRoleNameAsync(ApplicationRole role, string roleName, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetNormalizedRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SetNormalizedRoleNameAsync(ApplicationRole role, string normalizedName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<ApplicationRole> FindByIdAsync(string roleId, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<ApplicationRole> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
namespace MyOffice.Web.Identity.Repositories;
|
||||
|
||||
using Data.Models.Users;
|
||||
using Data.Repositories.Users;
|
||||
using Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
public class UserStore :
|
||||
IUserStore<ApplicationUser<Guid>>,
|
||||
IUserPasswordStore<ApplicationUser<Guid>>,
|
||||
IUserEmailStore<ApplicationUser<Guid>>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public UserStore(
|
||||
IUserRepository userRepository
|
||||
)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<string> GetUserIdAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.Id.ToString());
|
||||
}
|
||||
|
||||
public Task<string> GetUserNameAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.UserName);
|
||||
}
|
||||
|
||||
public Task SetUserNameAsync(ApplicationUser<Guid> user, string userName, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetNormalizedUserNameAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SetNormalizedUserNameAsync(ApplicationUser<Guid> user, string normalizedName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
user.UserName = normalizedName;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> CreateAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userRepository.AddUserAsync(new User
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
PasswordHash = user.PasswordHash,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed
|
||||
});
|
||||
|
||||
return result == 1 ? IdentityResult.Success : IdentityResult.Failed();
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> UpdateAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
var userDb = new User
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
PasswordHash = user.PasswordHash,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed
|
||||
};
|
||||
|
||||
var result = await _userRepository.UpdateUserAsync(userDb);
|
||||
return result == 1 ? IdentityResult.Success : IdentityResult.Failed();
|
||||
}
|
||||
|
||||
public Task<IdentityResult> DeleteAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser<Guid>> FindByIdAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetUserAsync(Guid.Parse(userId));
|
||||
if (user == null)
|
||||
{
|
||||
#pragma warning disable CS8603 // Possible null reference return.
|
||||
return null;
|
||||
#pragma warning restore CS8603 // Possible null reference return.
|
||||
}
|
||||
|
||||
return new ApplicationUser<Guid>
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
PasswordHash = user.PasswordHash,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser<Guid>> FindByNameAsync(string normalizedUserName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByUserUserNameAsync(normalizedUserName);
|
||||
if (user == null)
|
||||
{
|
||||
#pragma warning disable CS8603 // Possible null reference return.
|
||||
return null;
|
||||
#pragma warning restore CS8603 // Possible null reference return.
|
||||
}
|
||||
|
||||
return new ApplicationUser<Guid>
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
PasswordHash = user.PasswordHash,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
IsEmailConfirmed = user.IsEmailConfirmed
|
||||
};
|
||||
}
|
||||
|
||||
public Task SetPasswordHashAsync(ApplicationUser<Guid> user, string passwordHash,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
user.PasswordHash = passwordHash;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<string> GetPasswordHashAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.PasswordHash);
|
||||
}
|
||||
|
||||
public Task<bool> HasPasswordAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SetEmailAsync(ApplicationUser<Guid> user, string email, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<string> GetEmailAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user.Email);
|
||||
}
|
||||
|
||||
public Task<bool> GetEmailConfirmedAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SetEmailConfirmedAsync(ApplicationUser<Guid> user, bool confirmed, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<ApplicationUser<Guid>> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
|
||||
{
|
||||
return FindByNameAsync(normalizedEmail, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetNormalizedEmailAsync(ApplicationUser<Guid> user, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(user?.Email);
|
||||
}
|
||||
|
||||
public Task SetNormalizedEmailAsync(ApplicationUser<Guid> user, string normalizedEmail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MyOffice.Web.Infrastructure;
|
||||
|
||||
public class GlobalSettings
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MyOffice.Web.Models.Auth;
|
||||
|
||||
public class LoginModel
|
||||
{
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MyOffice.Web.Models.Auth;
|
||||
|
||||
public class RegisterModel
|
||||
{
|
||||
public string UserName { get; set; } = null!;
|
||||
public string Password { get; set; } = null!;
|
||||
public string ConfirmPassword { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MyOffice.Web.Models.WeatherForecast;
|
||||
|
||||
public class WeatherForecast
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
public int TemperatureC { get; set; }
|
||||
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="logs\**" />
|
||||
<Content Remove="logs\**" />
|
||||
<EmbeddedResource Remove="logs\**" />
|
||||
<None Remove="logs\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings.Production.json" CopyToPublishDirectory="Never" />
|
||||
<Content Update="appsettings.Production.sample.json" CopyToPublishDirectory="Never" />
|
||||
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
|
||||
<Content Update="appsettings.Development.sample.json" CopyToPublishDirectory="Never" />
|
||||
<Content Update="web.Release.config" CopyToPublishDirectory="Never" />
|
||||
|
||||
<Content Update="appsettings.Production.json">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Remove="wwwroot\index.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="wwwroot\index.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Auth0.AuthenticationApi" Version="7.17.4" />
|
||||
<PackageReference Include="Auth0.Core" Version="7.17.4" />
|
||||
<PackageReference Include="Auth0.ManagementApi" Version="7.17.4" />
|
||||
<PackageReference Include="Google.Apis.Auth" Version="1.58.0-beta01" />
|
||||
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
|
||||
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyOffice.Core\MyOffice.Core.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Data.Models\MyOffice.Data.Models.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Data.Repositories\MyOffice.Data.Repositories.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.DbContext\MyOffice.DbContext.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Migration.Postgres\MyOffice.Migrations.Postgres.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Migration.Sqlite\MyOffice.Migrations.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Services\MyOffice.Services.csproj" />
|
||||
<ProjectReference Include="..\MyOffice.Shared\MyOffice.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,353 @@
|
||||
namespace MyOffice.Web;
|
||||
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Core.Extensions;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.FileProviders.Physical;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Identity;
|
||||
using Identity.Domain;
|
||||
using Identity.ExternalProviders;
|
||||
using Identity.Repositories;
|
||||
using IdentityServer4.AccessTokenValidation;
|
||||
using IdentityServer4.Configuration;
|
||||
using IdentityServer4.Extensions;
|
||||
using IdentityServer4.Models;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Security.Cryptography;
|
||||
using Core.Identity;
|
||||
using Data.Repositories.Users;
|
||||
using DbContext;
|
||||
using Services.Users;
|
||||
using Shared;
|
||||
using Identity.Configure;
|
||||
using Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
using IdentityServer4.Services;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
AddServices(builder);
|
||||
|
||||
var app = builder.Build();
|
||||
Configure(app);
|
||||
|
||||
app.Run();
|
||||
}
|
||||
|
||||
private static void AddServices(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddLogging(logging =>
|
||||
logging.AddSimpleConsole(options =>
|
||||
{
|
||||
//options.SingleLine = true;
|
||||
options.TimestampFormat = "[HH:mm:ss] ";
|
||||
options.ColorBehavior = LoggerColorBehavior.Enabled;
|
||||
})
|
||||
);
|
||||
//File Logger
|
||||
builder.Logging.AddFile(builder.Configuration.GetSection("Logging"));
|
||||
|
||||
// shared configuration
|
||||
builder.Configuration.AddConfiguration(SharedConfiguration.CreateConfigurationContainer());
|
||||
|
||||
// Database
|
||||
var databaseProvider = builder.Configuration["DatabaseProvider"];
|
||||
if (databaseProvider == null)
|
||||
throw new NullReferenceException($"Configuration DatabaseProvider {databaseProvider}");
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString(databaseProvider);
|
||||
if (connectionString == null)
|
||||
throw new NullReferenceException($"Configuration ConnectionString {databaseProvider}");
|
||||
|
||||
var connectionConfiguration = new ConnectionConfiguration(databaseProvider, connectionString);
|
||||
RepositoryInitializer.Initialize(builder.Services, connectionConfiguration);
|
||||
|
||||
// Configurations
|
||||
builder.Services.Configure<ExternalProvidersConfig>(
|
||||
builder.Configuration.GetSection("ExternalProviders")
|
||||
);
|
||||
|
||||
InitializeGlobalSettings(builder);
|
||||
|
||||
AddIdentityServices(builder);
|
||||
|
||||
AddRepositories(builder);
|
||||
|
||||
AddBusinessServices(builder);
|
||||
|
||||
builder.Services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.AllowInputFormatterExceptionMessages = builder.Environment.IsDevelopment();
|
||||
});
|
||||
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
//builder.Services.AddEndpointsApiExplorer();
|
||||
//builder.Services.AddSwaggerGen();
|
||||
|
||||
builder.Services.AddCors();
|
||||
}
|
||||
|
||||
private static void InitializeGlobalSettings(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<GlobalSettings>(_ => { });
|
||||
builder.Services.AddSingleton(resolver => resolver.GetRequiredService<IOptions<GlobalSettings>>().Value);
|
||||
}
|
||||
|
||||
private static void AddIdentityServices(WebApplicationBuilder builder)
|
||||
{
|
||||
// add identity
|
||||
builder.Services
|
||||
.AddIdentity<ApplicationUser<Guid>, ApplicationRole>()
|
||||
.AddUserStore<UserStore>()
|
||||
.AddRoleStore<RoleStore>()
|
||||
.AddUserManager<AppUserManager>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.AddScoped<ICorsPolicyService, CorsPolicyService>();
|
||||
builder.Services.AddScoped<IPasswordHasher<ApplicationUser<Guid>>, PasswordHasher>();
|
||||
|
||||
// Identity Services
|
||||
builder.Services.AddScoped<IUserStore<ApplicationUser<Guid>>, UserStore>();
|
||||
builder.Services.AddScoped<IRoleStore<ApplicationRole>, RoleStore>();
|
||||
|
||||
// External providers
|
||||
builder.Services.AddScoped<IExternalProviderValidator, ExternalProviderValidatorAuth0>();
|
||||
builder.Services.AddScoped<IExternalProviderValidator, ExternalProviderValidatorGoogle>();
|
||||
|
||||
// Configure Identity options and password complexity here
|
||||
builder.Services.Configure<IdentityOptions>(options =>
|
||||
{
|
||||
// User settings
|
||||
options.User.RequireUniqueEmail = true;
|
||||
|
||||
// Password settings
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequiredLength = 8;
|
||||
options.Password.RequireNonAlphanumeric = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
|
||||
// Lockout settings
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
|
||||
options.Lockout.MaxFailedAccessAttempts = 10;
|
||||
});
|
||||
|
||||
// Adds IdentityServer.
|
||||
builder.Services
|
||||
.AddIdentityServer(o =>
|
||||
{
|
||||
o.UserInteraction = new UserInteractionOptions()
|
||||
{
|
||||
LoginUrl = "/authentication/signin",
|
||||
LoginReturnUrlParameter = "returnUrl"
|
||||
};
|
||||
})
|
||||
.AddSigningCredential(CreateSigningCredential())
|
||||
.AddPersistedGrantStore<PersistedGrantStore>()
|
||||
.AddInMemoryIdentityResources(IdentityServerConfig.GetIdentityResources())
|
||||
.AddInMemoryApiScopes(IdentityServerConfig.GetApiScopes())
|
||||
.AddInMemoryApiResources(IdentityServerConfig.GetApiResources())
|
||||
.AddInMemoryClients(IdentityServerConfig.GetClients())
|
||||
.AddAspNetIdentity<ApplicationUser<Guid>>()
|
||||
.AddProfileService<ProfileService>()
|
||||
.AddExtensionGrantValidator<ExtensionGrantValidator>();
|
||||
|
||||
var authentication = builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
var frontEndHost = builder.Configuration.GetValue<string>("FrontEnd:Host");
|
||||
var isDynamicFrontEndHost = frontEndHost.IsMissing();
|
||||
if (isDynamicFrontEndHost)
|
||||
{
|
||||
builder.Services.ConfigureOptions<ConfigureIdentityServerOptions>();
|
||||
authentication.AddIdentityServerAuthentication();
|
||||
}
|
||||
else
|
||||
{
|
||||
authentication.AddIdentityServerAuthentication(options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.ApiName = IdentityServerConfig.ApiName;
|
||||
options.Authority = frontEndHost;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRepositories(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
builder.Services.AddScoped<IUserExternalRepository, UserExternalRepository>();
|
||||
}
|
||||
|
||||
private static void AddBusinessServices(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<UserService, UserService>();
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, PhysicalFileInfo> _staticFilesCache = new();
|
||||
|
||||
private static bool _firstRequest = true;
|
||||
private static readonly object LockFirstRequest = new();
|
||||
|
||||
private static void Configure(WebApplication app)
|
||||
{
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
app.UseExceptionHandler("/Error");
|
||||
}
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// configuration on first request
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
var path = ctx.Request.Path;
|
||||
|
||||
GlobalSettings? globalSettings = null;
|
||||
|
||||
if (_firstRequest)
|
||||
{
|
||||
// lock
|
||||
lock (LockFirstRequest)
|
||||
{
|
||||
// recheck
|
||||
if (_firstRequest)
|
||||
{
|
||||
_firstRequest = false;
|
||||
|
||||
// set real frontend host
|
||||
globalSettings = ctx.RequestServices.GetRequiredService<GlobalSettings>();
|
||||
globalSettings.Host = GetFrontendHost(ctx);
|
||||
|
||||
// update identity server
|
||||
var options = ctx.RequestServices.GetRequiredService<IdentityServerOptions>();
|
||||
options.IssuerUri = globalSettings.Host;
|
||||
|
||||
// update identity server client redirect uri
|
||||
var clients = ctx.RequestServices.GetRequiredService<IEnumerable<Client>>();
|
||||
var client = clients.FirstOrDefault()!;
|
||||
client.RedirectUris = new List<string>
|
||||
{
|
||||
$"{globalSettings.Host}/silent-refresh.html"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
if (path.Value.EqualsIgnoreCase("/.well-known/openid-configuration"))
|
||||
{
|
||||
globalSettings ??= ctx.RequestServices.GetRequiredService<GlobalSettings>();
|
||||
ctx.SetIdentityServerOrigin(globalSettings.Host);
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// send index.html to any routes except coreRoutes
|
||||
var coreRoutes = new[]
|
||||
{
|
||||
// API routes
|
||||
new PathString("/api"),
|
||||
// Identity server routes
|
||||
new PathString("/.well-known"),
|
||||
new PathString("/connect"),
|
||||
new PathString("/silent-refresh.html")
|
||||
};
|
||||
|
||||
var webRootPath = app.Configuration.GetValue<string>(WebHostDefaults.ContentRootKey);
|
||||
var wwwrootPath = Path.Combine(webRootPath, "wwwroot");
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
//app.Logger.LogWarning($"?path: {context.Request.Path}");
|
||||
var path = context.Request.Path;
|
||||
|
||||
if (path.Value == null) return;
|
||||
if (coreRoutes.Any(x => path.StartsWithSegments(x, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
//app.Logger.LogWarning($"*path: {path.Value}");
|
||||
await next();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!_staticFilesCache.TryGetValue(path.Value, out var physicalFileInfo))
|
||||
{
|
||||
var segments = path.Value.Split('/', '\\');
|
||||
var fileInfo = new FileInfo(Path.Combine(wwwrootPath, segments.LastOrDefault()!));
|
||||
if (!fileInfo.Exists)
|
||||
{
|
||||
//app.Logger.LogWarning($"-path: {fileInfo.FullName}");
|
||||
return;
|
||||
}
|
||||
|
||||
//app.Logger.LogWarning($"+path: {fileInfo.FullName}");
|
||||
physicalFileInfo = new PhysicalFileInfo(fileInfo);
|
||||
_staticFilesCache.TryAdd(path.Value, physicalFileInfo);
|
||||
}
|
||||
|
||||
await context.Response.SendFileAsync(physicalFileInfo);
|
||||
await context.Response.CompleteAsync();
|
||||
}
|
||||
});
|
||||
|
||||
app.UseCors(builder => builder
|
||||
.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod());
|
||||
|
||||
app.UseIdentityServer();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
}
|
||||
|
||||
private static string GetFrontendHost(HttpContext ctx)
|
||||
{
|
||||
// get X-Forwarded-Proto when reversed proxy used
|
||||
// internet <-> nginx (https) <-> site(http)
|
||||
var host = $"{ctx.Request.Headers["X-Forwarded-Proto"]}";
|
||||
|
||||
host = host.IsMissing()
|
||||
? ctx.Request.IsHttps ? "https" : "http"
|
||||
: host!;
|
||||
|
||||
host += $"://{ctx.Request.Host.Value}";
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
private static SigningCredentials CreateSigningCredential()
|
||||
{
|
||||
var rsaSecurityKey = new RsaSecurityKey(new RSACryptoServiceProvider(2048));
|
||||
var credentials = new SigningCredentials(rsaSecurityKey, SecurityAlgorithms.RsaSha256);
|
||||
|
||||
return credentials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"profiles": {
|
||||
"MyOffice.Web": {
|
||||
"commandName": "Project",
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:7097;http://localhost:5097",
|
||||
"dotnetRunMessages": true
|
||||
},
|
||||
"MyOffice.SPA": {
|
||||
"commandName": "Project"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS": {
|
||||
"commandName": "IIS",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iis": {
|
||||
"applicationUrl": "http://localhost:9100",
|
||||
"sslPort": 9101
|
||||
},
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:9100",
|
||||
"sslPort": 9101
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"DatabaseProvider": "sqlite",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"EndPoints": {
|
||||
"Http": {
|
||||
"Url": "http://*:9100"
|
||||
}
|
||||
/*,
|
||||
"Https": {
|
||||
"Url": "https://*:9101"
|
||||
}*/
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ExternalProviders": {
|
||||
"Auth0": {
|
||||
"ClientId": "",
|
||||
"Domain": "",
|
||||
"SecretKey": ""
|
||||
},
|
||||
"Google": {
|
||||
"ClientId": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
|
||||
<location path="." inheritInChildApplications="false">
|
||||
<system.webServer>
|
||||
<handlers>
|
||||
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
|
||||
</handlers>
|
||||
<aspNetCore processPath="bin\Debug\net6.0\MyOffice.Web.exe" arguments="" stdoutLogEnabled="true"
|
||||
stdoutLogFile=".\logs\stdout" hostingModel="InProcess">
|
||||
<environmentVariables xdt:Transform="InsertIfMissing">
|
||||
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="" xdt:Locator="Match(name)" xdt:Transform="Remove" />
|
||||
<environmentVariable name="ASPNETCORE_HTTPS_PORT" value="" xdt:Locator="Match(name)" xdt:Transform="Remove" />
|
||||
</environmentVariables>
|
||||
</aspNetCore>
|
||||
</system.webServer>
|
||||
</location>
|
||||
</configuration>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<location path="." inheritInChildApplications="false">
|
||||
<system.webServer>
|
||||
<handlers>
|
||||
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
|
||||
</handlers>
|
||||
<aspNetCore processPath="bin\Debug\net6.0\MyOffice.Web.exe" arguments="" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="InProcess">
|
||||
<environmentVariables>
|
||||
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Development" />
|
||||
<environmentVariable name="ASPNETCORE_HTTPS_PORT" value="9101" />
|
||||
</environmentVariables>
|
||||
</aspNetCore>
|
||||
</system.webServer>
|
||||
</location>
|
||||
</configuration>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MyOffice</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body { height: 100% }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Roboto, Helvetica Neue, sans-serif
|
||||
}
|
||||
</style><link rel="stylesheet" href="styles.08cfaa5c1b59bc73.css" media="print" onload="this.media = 'all'">
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="styles.08cfaa5c1b59bc73.css">
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<h1>DEVELOPER INDEX.HTML</h1>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user