diff --git a/Streetcode/Streetcode.Auth.BLL/DTO/Auth/LoginWithGoogleDTO.cs b/Streetcode/Streetcode.Auth.BLL/DTO/Auth/LoginWithGoogleDTO.cs new file mode 100644 index 0000000..38521e6 --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/DTO/Auth/LoginWithGoogleDTO.cs @@ -0,0 +1,9 @@ +namespace Streetcode.Auth.BLL.DTO.Auth +{ + public class LoginWithGoogleDTO + { + public string Email { get; set; } + public string Name { get; set; } + public string Surname { get; set; } + } +} diff --git a/Streetcode/Streetcode.Auth.BLL/Mapping/AuthProfile.cs b/Streetcode/Streetcode.Auth.BLL/Mapping/AuthProfile.cs index 9d15b06..92c8d43 100644 --- a/Streetcode/Streetcode.Auth.BLL/Mapping/AuthProfile.cs +++ b/Streetcode/Streetcode.Auth.BLL/Mapping/AuthProfile.cs @@ -13,6 +13,13 @@ public AuthProfile() .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.Email)); CreateMap(); + + CreateMap() + .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.Email)) + .ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email)) + .ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name)) + .ForMember(dest => dest.Surname, opt => opt.MapFrom(src => src.Surname)) + .ForMember(dest => dest.EmailConfirmed, opt => opt.MapFrom(_ => true)); } } } diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommand.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommand.cs new file mode 100644 index 0000000..e6b9008 --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommand.cs @@ -0,0 +1,8 @@ +using FluentResults; +using MediatR; +using Streetcode.Auth.BLL.DTO.Auth; + +namespace Streetcode.Auth.BLL.MediatR.LoginWithGoogle +{ + public record LoginWithGoogleCommand(LoginWithGoogleDTO LoginGoogle) : IRequest>; +} diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommandValidation.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommandValidation.cs new file mode 100644 index 0000000..41d0eed --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommandValidation.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Streetcode.Auth.BLL.MediatR.Login; + +namespace Streetcode.Auth.BLL.MediatR.LoginWithGoogle +{ + public class LoginWithGoogleCommandValidation : AbstractValidator + { + public LoginWithGoogleCommandValidation() + { + RuleFor(x => x.LoginGoogle) + .SetValidator(new LoginWithGoogleDTOValidator()); + } + } +} diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleDTOValidator.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleDTOValidator.cs new file mode 100644 index 0000000..4b52702 --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleDTOValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Streetcode.Auth.BLL.DTO.Auth; + +namespace Streetcode.Auth.BLL.MediatR.LoginWithGoogle +{ + public class LoginWithGoogleDTOValidator : AbstractValidator + { + public LoginWithGoogleDTOValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required.") + .EmailAddress().WithMessage("Invalid email format."); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required."); + + RuleFor(x => x.Surname) + .NotEmpty().WithMessage("Surname is required."); + } + } +} diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleHandler.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleHandler.cs new file mode 100644 index 0000000..c23b1aa --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleHandler.cs @@ -0,0 +1,78 @@ +using AutoMapper; +using FluentResults; +using MassTransit; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Streetcode.Auth.BLL.DTO.Auth; +using Streetcode.Auth.BLL.DTO.Users; +using Streetcode.Auth.BLL.Interfaces; +using Streetcode.Auth.BLL.MediatR.Login; +using Streetcode.Auth.DAL.Entities; +using Streetcode.Shared.DTO.Events; +using Streetcode.Shared.Enums; + +namespace Streetcode.Auth.BLL.MediatR.LoginWithGoogle +{ + public class LoginWithGoogleHandler : IRequestHandler> + { + private readonly ILogger _logger; + private readonly ITokenService _tokenService; + private readonly UserManager _userManager; + private readonly IMapper _mapper; + private readonly IConfiguration _configuration; + private readonly IPublishEndpoint _publishEndpoint; + + public LoginWithGoogleHandler(ILogger logger, ITokenService tokenService, UserManager userManager, IMapper mapper, IConfiguration configuration, IPublishEndpoint publishEndpoint) + { + _logger = logger; + _tokenService = tokenService; + _userManager = userManager; + _mapper = mapper; + _configuration = configuration; + _publishEndpoint = publishEndpoint; + } + + public async Task> Handle(LoginWithGoogleCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.LoginGoogle.Email); + + if (user == null) + { + user = _mapper.Map(request.LoginGoogle); + + var createResult = await _userManager.CreateAsync(user); + + if (!createResult.Succeeded) + { + return Result.Fail(createResult.Errors.Select(e => e.Description)); + } + + await _userManager.AddToRoleAsync(user, nameof(UserRole.User)); + + await _publishEndpoint.Publish( + new UserRegisteredEvent + { + UserId = user.Id, + Email = user.Email, + Name = user.Name, + Surname = user.Surname, + Role = UserRole.User + }, + cancellationToken); + } + + var (accessToken, refreshToken) = await _tokenService.GenerateTokensAsync(user); + + var responseDto = new TokenResponseDTO + { + AccessToken = accessToken, + AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(double.Parse(_configuration["Jwt:ExpireMinutes"] !)), + User = _mapper.Map(user) + }; + + return Result.Ok((responseDto, refreshToken.Token)); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs index 5798ccd..c87c36d 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs +++ b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs @@ -1,7 +1,12 @@ -using MediatR; +using System.Security.Claims; +using MediatR; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Mvc; using Streetcode.Auth.BLL.DTO.Auth; using Streetcode.Auth.BLL.MediatR.Login; +using Streetcode.Auth.BLL.MediatR.LoginWithGoogle; using Streetcode.Auth.BLL.MediatR.Logout; using Streetcode.Auth.BLL.MediatR.RefreshToken; using Streetcode.Auth.BLL.MediatR.Register; @@ -76,6 +81,50 @@ public async Task RefreshToken() return Ok(responseDto); } + [HttpGet("login-google")] + public IActionResult LoginGoogle() + { + var redirectUrl = Url.Action("GoogleCallback"); + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + + return Challenge(properties, GoogleDefaults.AuthenticationScheme); + } + + [HttpGet("google-callback")] + public async Task GoogleCallback() + { + var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + if (!result.Succeeded) + { + return BadRequest(new { error = "Google authentication failed" }); + } + + var email = result.Principal.FindFirstValue(ClaimTypes.Email); + var name = result.Principal.FindFirstValue(ClaimTypes.Name); + var surname = result.Principal.FindFirstValue(ClaimTypes.Surname); + + var command = new LoginWithGoogleCommand(new LoginWithGoogleDTO() + { + Email = email, + Name = name, + Surname = surname + }); + + var loginResult = await _mediator.Send(command); + + if (loginResult.IsFailed) + { + return Unauthorized(loginResult.Errors.Select(e => e.Message)); + } + + var (responseDto, refreshToken) = loginResult.Value; + + _cookieService.SetRefreshTokenCookie(Response, refreshToken); + + return Ok(responseDto); + } + [HttpPost("logout")] public async Task Logout() { diff --git a/Streetcode/Streetcode.Auth.WebApi/Extensions/ServiceCollectionExtensions.cs b/Streetcode/Streetcode.Auth.WebApi/Extensions/ServiceCollectionExtensions.cs index 65e4dec..0cd641f 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/Streetcode/Streetcode.Auth.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,11 @@ using System.Reflection; using System.Text; using FluentValidation; +using Hangfire; +using Hangfire.SqlServer; using MassTransit; using MediatR; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -18,8 +21,6 @@ using Streetcode.Auth.DAL.Repositories.Realizations; using Streetcode.Auth.WebApi.Services.Interfaces; using Streetcode.Auth.WebApi.Services.Realizations; -using Hangfire; -using Hangfire.SqlServer; namespace Streetcode.Auth.WebApi.Extensions; @@ -66,6 +67,7 @@ public static void AddIdentityServices(this IServiceCollection services, IConfig options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters @@ -78,6 +80,13 @@ public static void AddIdentityServices(this IServiceCollection services, IConfig ValidAudience = jwtOptions.Audience, IssuerSigningKey = new SymmetricSecurityKey(key) }; + }) + .AddGoogle(options => + { + options.ClientId = configuration["Authentication:Google:ClientId"]; + options.ClientSecret = configuration["Authentication:Google:ClientSecret"]; + + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; }); } @@ -181,7 +190,10 @@ public static void AddHangfireServices(this IServiceCollection services, IConfig services.AddHangfire(config => { - config.UseSqlServerStorage(connectionString); + config.UseSqlServerStorage(connectionString, new SqlServerStorageOptions + { + PrepareSchemaIfNecessary = true + }); }); services.AddHangfireServer(); diff --git a/Streetcode/Streetcode.Auth.WebApi/Streetcode.Auth.WebApi.csproj b/Streetcode/Streetcode.Auth.WebApi/Streetcode.Auth.WebApi.csproj index abf3452..dc40224 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Streetcode.Auth.WebApi.csproj +++ b/Streetcode/Streetcode.Auth.WebApi/Streetcode.Auth.WebApi.csproj @@ -11,6 +11,8 @@ + + diff --git a/Streetcode/Streetcode.Auth.WebApi/appsettings.json b/Streetcode/Streetcode.Auth.WebApi/appsettings.json index 1ad7e6d..aed5298 100644 --- a/Streetcode/Streetcode.Auth.WebApi/appsettings.json +++ b/Streetcode/Streetcode.Auth.WebApi/appsettings.json @@ -29,5 +29,11 @@ "Admin": { "Email": "admin@streetcode.com", "Password": "SomePassword" + }, + "Authentication": { + "Google": { + "ClientId": "clientID", + "ClientSecret": "clientSecret" + } } -} \ No newline at end of file + } \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleDTOValidatorTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleDTOValidatorTests.cs new file mode 100644 index 0000000..b304461 --- /dev/null +++ b/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleDTOValidatorTests.cs @@ -0,0 +1,94 @@ +namespace Streetcode.Auth.XUnitTest.LoginWithGoogle +{ + using FluentValidation.TestHelper; + using Streetcode.Auth.BLL.DTO.Auth; + using Streetcode.Auth.BLL.MediatR.LoginWithGoogle; + using Xunit; + + public class LoginWithGoogleDTOValidatorTests + { + private readonly LoginWithGoogleDTOValidator validator; + + public LoginWithGoogleDTOValidatorTests() + { + this.validator = new LoginWithGoogleDTOValidator(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ShouldHaveError_WhenEmailIsEmpty(string email) + { + var model = new LoginWithGoogleDTO { Email = email }; + var result = this.validator.TestValidate(model); + + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Email is required."); + } + + [Theory] + [InlineData("plainaddress")] + [InlineData("#@%^%#$@#$@#.com")] + [InlineData("@example.com")] + [InlineData("email.example.com")] + public void ShouldHaveError_WhenEmailIsInvalidFormat(string email) + { + var model = new LoginWithGoogleDTO { Email = email }; + var result = this.validator.TestValidate(model); + + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Invalid email format."); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ShouldHaveError_WhenNameIsEmpty(string name) + { + var model = new LoginWithGoogleDTO { Name = name, Email = "test@gmail.com", Surname = "Test" }; + var result = this.validator.TestValidate(model); + + result.ShouldHaveValidationErrorFor(x => x.Name) + .WithErrorMessage("Name is required."); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ShouldHaveError_WhenSurnameIsEmpty(string surname) + { + // Arrange + var model = new LoginWithGoogleDTO + { + Surname = surname, + Email = "test@gmail.com", + Name = "John" + }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Surname) + .WithErrorMessage("Surname is required."); + } + + [Fact] + public void ShouldNotHaveError_WhenDTOIsValid() + { + // Arrange + var model = new LoginWithGoogleDTO + { + Email = "google.user@gmail.com", + Name = "John", + Surname = "Smith" + }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleHandlerTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleHandlerTests.cs new file mode 100644 index 0000000..6975902 --- /dev/null +++ b/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleHandlerTests.cs @@ -0,0 +1,152 @@ +namespace Streetcode.Auth.XUnitTest.LoginWithGoogle +{ + using AutoMapper; + using FluentAssertions; + using MassTransit; + using Microsoft.AspNetCore.Identity; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Logging; + using Moq; + using Streetcode.Auth.BLL.DTO.Auth; + using Streetcode.Auth.BLL.DTO.Users; + using Streetcode.Auth.BLL.Interfaces; + using Streetcode.Auth.BLL.Mapping; + using Streetcode.Auth.BLL.MediatR.LoginWithGoogle; + using Streetcode.Auth.DAL.Entities; + using Streetcode.Shared.DTO.Events; + using Xunit; + + public class LoginWithGoogleHandlerTests + { + private const string Email = "google-user@gmail.com"; + private const string Name = "John"; + private const string Surname = "Smith"; + private const string AccessToken = "google_access_token"; + private const string RefreshTokenStr = "google_refresh_token"; + + private readonly Mock> userManagerMock; + private readonly Mock tokenServiceMock; + private readonly Mock configMock; + private readonly Mock> loggerMock; + private readonly Mock publishEndpointMock; + private readonly IMapper mapper; + private readonly LoginWithGoogleHandler handler; + + public LoginWithGoogleHandlerTests() + { + this.userManagerMock = this.CreateUserManagerMock(); + this.tokenServiceMock = new Mock(); + this.configMock = new Mock(); + this.loggerMock = new Mock>(); + this.publishEndpointMock = new Mock(); + + var configuration = new MapperConfiguration(cfg => + { + cfg.AddProfile(new AuthProfile()); + }); + this.mapper = configuration.CreateMapper(); + + this.handler = new LoginWithGoogleHandler( + this.loggerMock.Object, + this.tokenServiceMock.Object, + this.userManagerMock.Object, + this.mapper, + this.configMock.Object, + this.publishEndpointMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnSuccess_WhenUserExists() + { + // Arrange + var user = new ApplicationUser + { + Id = "google-id-123", + Email = Email, + UserName = Email, + Name = Name, + Surname = Surname + }; + var refreshTokenEntity = new RefreshToken { Token = RefreshTokenStr, UserId = user.Id }; + + this.userManagerMock + .Setup(m => m.FindByEmailAsync(Email)) + .ReturnsAsync(user); + + this.tokenServiceMock + .Setup(s => s.GenerateTokensAsync(user)) + .ReturnsAsync((AccessToken, refreshTokenEntity)); + + this.configMock.Setup(c => c["Jwt:ExpireMinutes"]).Returns("60"); + + var command = new LoginWithGoogleCommand(new LoginWithGoogleDTO + { + Email = Email, + Name = Name, + Surname = Surname + }); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + var (responseDto, refreshToken) = result.Value; + + refreshToken.Should().Be(RefreshTokenStr); + responseDto.AccessToken.Should().Be(AccessToken); + responseDto.User.Email.Should().Be(Email); + responseDto.User.Surname.Should().Be(Surname); + + this.tokenServiceMock.Verify(s => s.GenerateTokensAsync(user), Times.Once); + } + + [Fact] + public async Task Handle_ShouldCreateUserAndReturnSuccess_WhenUserDoesNotExist() + { + // Arrange + this.userManagerMock + .Setup(m => m.FindByEmailAsync(Email)) + .ReturnsAsync((ApplicationUser?)null); + + this.userManagerMock + .Setup(m => m.CreateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + this.userManagerMock + .Setup(m => m.AddToRoleAsync(It.IsAny(), "User")) + .ReturnsAsync(IdentityResult.Success); + + var user = new ApplicationUser { Id = "new-id", Email = Email }; + var refreshTokenEntity = new RefreshToken { Token = RefreshTokenStr, UserId = user.Id }; + + this.tokenServiceMock + .Setup(s => s.GenerateTokensAsync(It.IsAny())) + .ReturnsAsync((AccessToken, refreshTokenEntity)); + + this.configMock.Setup(c => c["Jwt:ExpireMinutes"]).Returns("60"); + + var command = new LoginWithGoogleCommand(new LoginWithGoogleDTO + { + Email = Email, + Name = "New", + Surname = "User" + }); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + this.userManagerMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Once); + this.publishEndpointMock.Verify(p => p.Publish(It.IsAny(), It.IsAny()), Times.Once); + } + + private Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null, null, null, null, null, null, null, null); + } + } +} \ No newline at end of file diff --git a/Streetcode/docker-compose.yml b/Streetcode/docker-compose.yml new file mode 100644 index 0000000..bf2c125 --- /dev/null +++ b/Streetcode/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + rabbitmq: + image: rabbitmq:3-management + container_name: streetcode-rabbitmq + restart: always + ports: + - "5672:5672" + - "15672:15672" + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} + + redis: + image: redis:latest + container_name: streetcode-redis + restart: always + ports: + - "6379:6379" + + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: streetcode-main-db + ports: + - "1433:1433" + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=${DB_PASSWORD} + healthcheck: + test: ["CMD", "/opt/mssql-tools/bin/sqlcmd", "-U", "sa", "-P", "${DB_PASSWORD}", "-Q", "SELECT 1"] + interval: 10s + timeout: 3s + retries: 10 \ No newline at end of file