diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Context/IdentityContext.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Context/IdentityContext.cs index 2455d74b9..41568d692 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Context/IdentityContext.cs +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Context/IdentityContext.cs @@ -44,8 +44,13 @@ public IdentityContext(HttpContext httpContext) public IdentityContext(ClaimsPrincipal principal) { - _IdentityUserId = principal.FindFirst(ClaimTypes.NameIdentifier) is var idClaim && idClaim is not null - ? new Guid(idClaim.Value) : null; + if(principal.Identity!.AuthenticationType != "GitHub" + && principal.FindFirst(ClaimTypes.NameIdentifier) is var idClaim + && idClaim is not null) + { + _IdentityUserId = Guid.Parse(idClaim.Value); + } + Name = principal.FindFirst(ClaimTypes.Name)?.Value; IsAdmin = principal.FindAll(ClaimTypes.Role).Any(x => x.Value == Role.Admin); IsContentEditor = principal.FindAll(ClaimTypes.Role).Any(x => x.Value == Role.ContentEditor) && !IsAdmin; diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Controllers/GithubAuthenticationController.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Controllers/GithubAuthenticationController.cs new file mode 100644 index 000000000..d4fcf9c6a --- /dev/null +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Controllers/GithubAuthenticationController.cs @@ -0,0 +1,49 @@ +using GreenOnSoftware.Application.GithubAuthentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace GreenOnSoftware.Api.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class GithubAuthenticationController : ControllerBase +{ + private readonly IGithubAuthenticationService _githubAuthenticationService; + + public GithubAuthenticationController(IGithubAuthenticationService githubAuthenticationService) + { + _githubAuthenticationService = githubAuthenticationService; + } + + [HttpGet("[action]")] + public IActionResult SignIn(string? redirectUrl) + { + if (!string.IsNullOrEmpty(redirectUrl)) + { + HttpContext.Session.SetString("GithubAuthorization:RedirectUrl", redirectUrl); + } + var properties = _githubAuthenticationService.CreateAuthenticationProperties("/api/GithubAuthentication/Authenticate"); + + return Challenge(properties, "GitHub"); + } + + [Authorize(AuthenticationSchemes = "Identity.Application, Identity.External")] + [HttpGet("[Action]")] + public async Task Authenticate() + { + var result = await _githubAuthenticationService.AuthenticateAsync(); + if (result.HasErrors) + { + return BadRequest(result); + } + string? redirectUrl = HttpContext.Session.GetString("GithubAuthorization:RedirectUrl"); + if (string.IsNullOrEmpty(redirectUrl)) + { + return Ok(result); + } + + HttpContext.Session.Remove("GithubAuthorization:RedirectUrl"); + + return Redirect(redirectUrl); + } +} \ No newline at end of file diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/GreenOnSoftware.Api.csproj b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/GreenOnSoftware.Api.csproj index a383f0f6b..a53450acc 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/GreenOnSoftware.Api.csproj +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/GreenOnSoftware.Api.csproj @@ -19,6 +19,7 @@ + diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Program.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Program.cs index ec42ea718..ad69e8750 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Program.cs +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Program.cs @@ -41,7 +41,7 @@ builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("GreenOnSoftware"))); -builder.Services.AddIdentity(); +builder.Services.AddAuthentication(builder.Configuration); builder.Services.AddCors(); diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Startup/IdentityConfig.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Startup/AuthenticationConfig.cs similarity index 74% rename from dotnet/GreenOnSoftware/GreenOnSoftware.Api/Startup/IdentityConfig.cs rename to dotnet/GreenOnSoftware/GreenOnSoftware.Api/Startup/AuthenticationConfig.cs index 3fe009ba6..4f0697e4c 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Startup/IdentityConfig.cs +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/Startup/AuthenticationConfig.cs @@ -5,9 +5,9 @@ namespace GreenOnSoftware.Api.Startup; -public static class IdentityConfig +public static class AuthenticationConfig { - public static IServiceCollection AddIdentity(this IServiceCollection services) + public static IServiceCollection AddAuthentication(this IServiceCollection services, IConfiguration configuration) { services.AddIdentity>(options => options.SignIn.RequireConfirmedAccount = false) .AddEntityFrameworkStores() @@ -53,6 +53,22 @@ public static IServiceCollection AddIdentity(this IServiceCollection services) }; }); + var githubConfig = configuration.GetSection("Github"); + + var siema = githubConfig["ClientSecret"]; + + services + .AddAuthentication(options => + { + options.DefaultChallengeScheme = IdentityConstants.ExternalScheme; + }) + .AddGitHub(options => { + options.ClientId = githubConfig["ClientId"]; + options.ClientSecret = githubConfig["ClientSecret"]; + options.Scope.Add("user:email"); + options.SaveTokens = true; + }); + return services; } } diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.Development.json b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.Development.json index e3eb29545..63ed782c5 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.Development.json +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.Development.json @@ -21,5 +21,10 @@ "BlobStorage": { "ConnectionString": "--secret--", "Container": "dev" + }, + + "Github": { + "ClientId": "In secrets.json", + "ClientSecret": "In secrets.json" } } diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.dev.json b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.dev.json index bd4ff71a3..e312326d9 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.dev.json +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.dev.json @@ -22,5 +22,10 @@ "BlobStorage": { "ConnectionString": "--secret--", "Container": "dev" + }, + + "Github": { + "ClientId": "In secrets.json", + "ClientSecret": "In secrets.json" } } diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.prod.json b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.prod.json index 147b01251..e0439e808 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.prod.json +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Api/appsettings.prod.json @@ -26,5 +26,10 @@ "CorsOriginsUrls": [ "https://greenonsoftware.com" - ] + ], + + "Github": { + "ClientId": "In secrets.json", + "ClientSecret": "In secrets.json" + } } diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Application/DependencyInjection.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/DependencyInjection.cs index bf2d75031..49a17be8a 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Application/DependencyInjection.cs +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/DependencyInjection.cs @@ -1,4 +1,5 @@ -using GreenOnSoftware.Application.Services; +using GreenOnSoftware.Application.GithubAuthentication; +using GreenOnSoftware.Application.Services; using GreenOnSoftware.Application.Services.Interfaces; using Microsoft.Extensions.DependencyInjection; @@ -13,6 +14,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services; } diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/GithubAuthenticationService.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/GithubAuthenticationService.cs new file mode 100644 index 000000000..a652b2140 --- /dev/null +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/GithubAuthenticationService.cs @@ -0,0 +1,148 @@ +using GreenOnSoftware.Commons.Dtos; +using GreenOnSoftware.Commons.Resources; +using GreenOnSoftware.Core.Identity; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Serilog; +using System.Security.Claims; +using GreenOnSoftware.Commons.Extensions; +using GreenOnSoftware.Commons.Consts; + +namespace GreenOnSoftware.Application.GithubAuthentication; + +public class GithubAuthenticationService : IGithubAuthenticationService +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public GithubAuthenticationService( + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + } + + public AuthenticationProperties CreateAuthenticationProperties(string redirectUrl) + { + + return _signInManager.ConfigureExternalAuthenticationProperties("GitHub", new PathString(redirectUrl)); + } + + public async Task> AuthenticateAsync() + { + var response = new Result(); + + var externalLoginInfo = await _signInManager.GetExternalLoginInfoAsync(); + if (externalLoginInfo is null) + { + response.AddError(ErrorMessages.ActionFailed, ErrorActionNames.ExternalSignIn); + return response; + } + string? applicationUserId = GetAppicationUserId(); + + var user = await _userManager.FindByLoginAsync(externalLoginInfo.LoginProvider, externalLoginInfo.ProviderKey); + if (user is null) + { + var userResult = await GetApplicationUser(applicationUserId, externalLoginInfo.Principal.Claims); + if (userResult.HasErrors) + { + response.AddErrors(userResult); + response.AddError(ErrorMessages.ActionFailed, ErrorActionNames.ExternalSignIn); + return response; + } + user = userResult.Data; + + var addLoginResult = await _userManager.AddLoginAsync(user, externalLoginInfo); + if (!addLoginResult.Succeeded) + { + response.AddError(ErrorMessages.ActionFailed, ErrorActionNames.AddExternalLogin); + return response; + } + } + else + { + if (!string.IsNullOrEmpty(applicationUserId) && applicationUserId != user.Id.ToString()) + { + response.AddError(ErrorMessages.AlreadyConnectedWithAnotherAccount); + return response; + } + } + + SignInResult signInResult = await _signInManager.ExternalLoginSignInAsync(externalLoginInfo.LoginProvider, externalLoginInfo.ProviderKey, true); + + if (!signInResult.Succeeded) + { + response.AddError(ErrorMessages.ActionFailed, ErrorActionNames.ExternalSignIn); + return response; + } + + IdentityResult saveTokensResult = await _signInManager.UpdateExternalAuthenticationTokensAsync(externalLoginInfo); + + if (!saveTokensResult.Succeeded) + { + response.AddErrors(saveTokensResult.GetErrors()); + return response; + } + + return response; + } + + public async Task GetGithubTokenAsync(string username = null) + { + if (string.IsNullOrEmpty(username)) + { + username = _signInManager.Context.User.Claims.Single(x => x.Type == ClaimTypes.Name).Value; + } + + User currentUser = await _userManager.FindByNameAsync(username); + string githubToken = await _userManager.GetAuthenticationTokenAsync(currentUser, "Github", "access_token"); + + return githubToken; + } + + private string? GetAppicationUserId() + { + return _signInManager.Context.User + .Identities + .SingleOrDefault(x => x.AuthenticationType == IdentityConstants.ApplicationScheme) + ?.Claims + .Single(x => x.Type == ClaimTypes.NameIdentifier) + .Value; + } + + private async Task> GetApplicationUser(string? applicationUserId, IEnumerable externalClaims) + { + var result = new Result(); + if (applicationUserId != null) + { + result.SetData(await _userManager.FindByIdAsync(applicationUserId)); + if (result.Data != null) + { + return result; + } + } + + var newUser = new User { + Email = externalClaims.Single(x => x.Type == ClaimTypes.Email).Value, + UserName = externalClaims.Single(x => x.Type == ClaimTypes.Name).Value, + }; + var addUserResult = await _userManager.CreateAsync(newUser); + if (!addUserResult.Succeeded) + { + result.AddErrors(addUserResult.GetErrors()); + return result; + } + var addToRoleResult = await _userManager.AddToRoleAsync(newUser, Role.GeneralUser); + if (!addToRoleResult.Succeeded) + { + result.AddErrors(addToRoleResult.GetErrors()); + Log.Error($"Failed to assign role to new user {newUser.UserName}."); + } + + result.SetData(newUser); + + return result; + } +} \ No newline at end of file diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/GithubTokenDto.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/GithubTokenDto.cs new file mode 100644 index 000000000..664d72c67 --- /dev/null +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/GithubTokenDto.cs @@ -0,0 +1,7 @@ +namespace GreenOnSoftware.Application.GithubAuthentication; + +public class GithubTokenDto +{ + public string AccessToken { get; set; } + public IEnumerable Scope { get; set; } +} \ No newline at end of file diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/IGithubAuthenticationService.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/IGithubAuthenticationService.cs new file mode 100644 index 000000000..a197b62dc --- /dev/null +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/GithubAuthentication/IGithubAuthenticationService.cs @@ -0,0 +1,11 @@ +using GreenOnSoftware.Commons.Dtos; +using Microsoft.AspNetCore.Authentication; + +namespace GreenOnSoftware.Application.GithubAuthentication; + +public interface IGithubAuthenticationService +{ + Task> AuthenticateAsync(); + AuthenticationProperties CreateAuthenticationProperties(string redirectUrl); + Task GetGithubTokenAsync(string? username = null); +} \ No newline at end of file diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Application/_Services/ThumbnailService.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/_Services/ThumbnailService.cs index add2e548b..6d2fe5bf1 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Application/_Services/ThumbnailService.cs +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Application/_Services/ThumbnailService.cs @@ -1,4 +1,5 @@ -using GreenOnSoftware.Application.Services.Interfaces; +using GreenOnSoftware.Application.Account.SignInCommand; +using GreenOnSoftware.Application.Services.Interfaces; using GreenOnSoftware.Commons.Dtos; using GreenOnSoftware.Commons.Resources; using Microsoft.AspNetCore.Http; diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Consts/ErrorActionNames.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Consts/ErrorActionNames.cs new file mode 100644 index 000000000..f36a7f796 --- /dev/null +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Consts/ErrorActionNames.cs @@ -0,0 +1,9 @@ +namespace GreenOnSoftware.Commons.Consts; + +public static class ErrorActionNames +{ + public const string AddExternalLogin = "Add external login"; + public const string EmailConfirmation = "Email confirmation"; + public const string EmailSending = "Email sending"; + public const string ExternalSignIn = "External sign in"; +} \ No newline at end of file diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Resources/ErrorMessages.Designer.cs b/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Resources/ErrorMessages.Designer.cs index 63ef22f2f..8b962db0f 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Resources/ErrorMessages.Designer.cs +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Resources/ErrorMessages.Designer.cs @@ -69,6 +69,15 @@ public static string ActionFailed { } } + /// + /// Looks up a localized string similar to This github account is already connected with another user.. + /// + public static string AlreadyConnectedWithAnotherAccount { + get { + return ResourceManager.GetString("AlreadyConnectedWithAnotherAccount", resourceCulture); + } + } + /// /// Looks up a localized string similar to Article already exists.. /// diff --git a/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Resources/ErrorMessages.resx b/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Resources/ErrorMessages.resx index c5f29209f..8d1ca4a20 100644 --- a/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Resources/ErrorMessages.resx +++ b/dotnet/GreenOnSoftware/GreenOnSoftware.Commons/Resources/ErrorMessages.resx @@ -120,6 +120,9 @@ {0} failed. + + This github account is already connected with another user. + Article already exists.