diff --git a/Streetcode/DbUpdate/Program.cs b/Streetcode/DbUpdate/Program.cs index de935da6..ae4643c4 100644 --- a/Streetcode/DbUpdate/Program.cs +++ b/Streetcode/DbUpdate/Program.cs @@ -1,54 +1,56 @@ using DbUp; using Microsoft.Extensions.Configuration; -public class Program +namespace Streetcode.DbUpdate { - static int Main(string[] args) + public class Program { - string migrationPath = Path.Combine(Directory.GetCurrentDirectory(), - "Streetcode.DAL", "Persistence", "ScriptsMigration"); + private static int Main(string[] args) + { + string migrationPath = Path.Combine(Directory.GetCurrentDirectory(), "Streetcode.DAL", "Persistence", "ScriptsMigration"); - var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Local"; + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Local"; - var configuration = new ConfigurationBuilder() - .SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "Streetcode.WebApi")) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables("STREETCODE_") - .Build(); + var configuration = new ConfigurationBuilder() + .SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "Streetcode.WebApi")) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables("STREETCODE_") + .Build(); - var connectionString = configuration.GetConnectionString("DefaultConnection"); + var connectionString = configuration.GetConnectionString("DefaultConnection"); - string pathToScript = ""; + string pathToScript = ""; - Console.WriteLine("Enter '-m' to MIGRATE or '-s' to SEED db:"); - pathToScript = Console.ReadLine(); + Console.WriteLine("Enter '-m' to MIGRATE or '-s' to SEED db:"); + var command = Console.ReadLine(); - pathToScript = migrationPath; - - var upgrader = - DeployChanges.To - .SqlDatabase(connectionString) - .WithScriptsFromFileSystem(pathToScript) - .LogToConsole() - .Build(); + pathToScript = migrationPath; - var result = upgrader.PerformUpgrade(); + var upgrader = + DeployChanges.To + .SqlDatabase(connectionString) + .WithScriptsFromFileSystem(pathToScript) + .LogToConsole() + .Build(); - if (!result.Successful) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(result.Error); - Console.ResetColor(); + var result = upgrader.PerformUpgrade(); + + if (!result.Successful) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(result.Error); + Console.ResetColor(); #if DEBUG - Console.ReadLine(); + Console.ReadLine(); #endif - return -1; - } + return -1; + } - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("Success!"); - Console.ResetColor(); - return 0; + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("Success!"); + Console.ResetColor(); + return 0; + } } } \ No newline at end of file diff --git a/Streetcode/README.md b/Streetcode/README.md new file mode 100644 index 00000000..1a80ec88 --- /dev/null +++ b/Streetcode/README.md @@ -0,0 +1,38 @@ +# Streetcode.Auth Service + +- GoogleAuth: +--- +## 1. Configure GoogleAuth__ClientId + +```bash +dotnet user-secrets set "GoogleAuth:ClientId" "123721973387-7i3rs06c8iui5lrb805f22o0k2s6gk1o.apps.googleusercontent.com" +``` + +## 2. Create .env file in repository root example + +```env +DB_PASSWORD=Admin@1234 +RABBITMQ_USERNAME=guest +RABBITMQ_PASSWORD=guest + +JWT_KEY=StreetcodeSuperSecretJwtKeyForDevelopmentOnly1234567890 +JWT_ISSUER=Streetcode +JWT_AUDIENCE=StreetcodeUsers +JWT_ACCESS_TOKEN_LIFETIME_IN_MINUTES=120 +JWT_REFRESH_TOKEN_LIFETIME_IN_DAYS=7 + + +EmailConfiguration:From = your@email.com +EmailConfiguration:SmtpServer = smtp.gmail.com +EmailConfiguration:Port = 465 +EmailConfiguration:UserName = your@email.com +EmailConfiguration:Password = your-app-password + +ADMIN_EMAIL=admin@gmail.com +ADMIN_PASSWORD=admin@1234 + +GoogleAuth__ClientId=123721973387-7i3rs06c8iui5lrb805f22o0k2s6gk1o.apps.googleusercontent.com +``` + + +--- \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth/Controllers/Users/AuthController.cs b/Streetcode/Streetcode.Auth/Controllers/Users/AuthController.cs index a46e6aa7..532b64b5 100644 --- a/Streetcode/Streetcode.Auth/Controllers/Users/AuthController.cs +++ b/Streetcode/Streetcode.Auth/Controllers/Users/AuthController.cs @@ -1,12 +1,15 @@ +using Google.Apis.Auth; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Streetcode.Auth.Extensions; using Streetcode.Auth.MediatR.Users.Login; +using Streetcode.Auth.MediatR.Users.LoginGoogle; using Streetcode.Auth.MediatR.Users.Logout; using Streetcode.Auth.MediatR.Users.RefreshToken; using Streetcode.Auth.MediatR.Users.Register; using Streetcode.Auth.Models.DTO; +using Streetcode.Auth.Services.Interfaces.Users; namespace Streetcode.Auth.Controllers.Users; @@ -15,10 +18,12 @@ namespace Streetcode.Auth.Controllers.Users; public class AuthController : ControllerBase { private readonly IMediator _mediator; + private readonly IGoogleAuthService _googleAuthService; - public AuthController(IMediator mediator) + public AuthController(IMediator mediator, IGoogleAuthService googleAuthService) { _mediator = mediator; + _googleAuthService = googleAuthService; } [AllowAnonymous] @@ -29,6 +34,32 @@ public async Task Login(UserLoginDto request) return this.ToActionResult(result); } + [HttpPost("google-login")] + public async Task GoogleLogin([FromBody] GoogleLoginRequest request) + { + try + { + var payload = await _googleAuthService.ValidateTokenAsync(request.IdToken); + + if (payload == null) + return Unauthorized("Invalid Google Token"); + + var loginDto = new GoogleLoginRequestDto + { + Email = payload.Email, + Name = payload.GivenName, + Surname = payload.FamilyName + }; + + var result = await _mediator.Send(new GoogleLoginCommand(loginDto)); + return this.ToActionResult(result); + } + catch (Exception) + { + return Unauthorized("Invalid Google Token"); + } + } + [AllowAnonymous] [HttpPost("register")] public async Task Register(UserRegisterDto request) diff --git a/Streetcode/Streetcode.Auth/MediatR/Users/LoginGoogle/GoogleLoginCommand.cs b/Streetcode/Streetcode.Auth/MediatR/Users/LoginGoogle/GoogleLoginCommand.cs new file mode 100644 index 00000000..373b5e34 --- /dev/null +++ b/Streetcode/Streetcode.Auth/MediatR/Users/LoginGoogle/GoogleLoginCommand.cs @@ -0,0 +1,9 @@ +using FluentResults; +using MediatR; +using Streetcode.Auth.Models.DTO; + +namespace Streetcode.Auth.MediatR.Users.LoginGoogle +{ + public record GoogleLoginCommand(GoogleLoginRequestDto googleLoginRequest) + : IRequest>; +} diff --git a/Streetcode/Streetcode.Auth/MediatR/Users/LoginGoogle/GoogleLoginHandler.cs b/Streetcode/Streetcode.Auth/MediatR/Users/LoginGoogle/GoogleLoginHandler.cs new file mode 100644 index 00000000..b22566b9 --- /dev/null +++ b/Streetcode/Streetcode.Auth/MediatR/Users/LoginGoogle/GoogleLoginHandler.cs @@ -0,0 +1,64 @@ +using FluentResults; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Streetcode.Auth.Models.DTO; +using Streetcode.Auth.Models.Entities; +using Streetcode.Auth.Services.Interfaces.Logging; +using Streetcode.Auth.Services.Interfaces.Users; +using Streetcode.Common.Enums; + +namespace Streetcode.Auth.MediatR.Users.LoginGoogle +{ + public class GoogleLoginHandler : IRequestHandler> + { + private readonly UserManager _userManager; + private readonly IAuthService _authService; + + private readonly ILoggerService _logger; + + public GoogleLoginHandler( + UserManager userManager, + IAuthService authService, + ILoggerService logger) + { + _userManager = userManager; + _authService = authService; + + _logger = logger; + } + + public async Task> Handle(GoogleLoginCommand request, CancellationToken cancellationToken) + { + var loginDto = request.googleLoginRequest; + _logger.LogInformation($"Google login attempt for {loginDto.Email}"); + + var user = await _userManager.FindByEmailAsync(loginDto.Email); + + if (user == null) + { + user = new User { Email = loginDto.Email, UserName = loginDto.Email, Name = loginDto.Name, Surname = loginDto.Surname }; + + var createResult = await _userManager.CreateAsync(user); + var errorResult = CheckIdentityResult(createResult, request, $"Failed to create user {loginDto.Email}"); + if (errorResult != null) return errorResult; + + var roleResult = await _userManager.AddToRoleAsync(user, UserRole.MainAdministrator.ToString()); + errorResult = CheckIdentityResult(roleResult, request, $"Failed to add role for user {user.Id}"); + if (errorResult != null) return errorResult; + } + + var authResult = await _authService.CreateLoginResultAsync(user); + _logger.LogInformation($"User {user.Id} successfully logged in"); + return Result.Ok(authResult); + } + + private Result CheckIdentityResult(IdentityResult result, object request, string errorMessage) + { + if (result.Succeeded) return null!; + + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogError(request, $"{errorMessage}: {errors}"); + return Result.Fail(errorMessage); + } + } +} diff --git a/Streetcode/Streetcode.Auth/Models/DTO/GoogleLoginRequest.cs b/Streetcode/Streetcode.Auth/Models/DTO/GoogleLoginRequest.cs new file mode 100644 index 00000000..7a72e426 --- /dev/null +++ b/Streetcode/Streetcode.Auth/Models/DTO/GoogleLoginRequest.cs @@ -0,0 +1,7 @@ +namespace Streetcode.Auth.Models.DTO +{ + public class GoogleLoginRequest + { + public string IdToken { get; set; } = string.Empty; + } +} diff --git a/Streetcode/Streetcode.Auth/Models/DTO/GoogleLoginRequestDto.cs b/Streetcode/Streetcode.Auth/Models/DTO/GoogleLoginRequestDto.cs new file mode 100644 index 00000000..125bff41 --- /dev/null +++ b/Streetcode/Streetcode.Auth/Models/DTO/GoogleLoginRequestDto.cs @@ -0,0 +1,9 @@ +namespace Streetcode.Auth.Models.DTO +{ + public class GoogleLoginRequestDto + { + public string Email { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Surname { get; set; } = string.Empty; + } +} diff --git a/Streetcode/Streetcode.Auth/Program.cs b/Streetcode/Streetcode.Auth/Program.cs index 73df4af8..71132378 100644 --- a/Streetcode/Streetcode.Auth/Program.cs +++ b/Streetcode/Streetcode.Auth/Program.cs @@ -5,6 +5,8 @@ using Streetcode.Auth.MediatR.Behaviors; using Streetcode.Auth.Services; using Streetcode.Auth.Services.Interfaces; +using Streetcode.Auth.Services.Interfaces.Users; +using Streetcode.Auth.Services.Users; using Streetcode.Common.Models; using Streetcode.Shared.Web.Middleware; using System.Reflection; @@ -23,6 +25,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); + builder.Services.AddControllers() .AddJsonOptions(options => { diff --git a/Streetcode/Streetcode.Auth/Resources/ErrorMessages.Designer.cs b/Streetcode/Streetcode.Auth/Resources/ErrorMessages.Designer.cs index 665b1120..64bd21e3 100644 --- a/Streetcode/Streetcode.Auth/Resources/ErrorMessages.Designer.cs +++ b/Streetcode/Streetcode.Auth/Resources/ErrorMessages.Designer.cs @@ -78,6 +78,15 @@ public static string EmailMustNotExceedCharacters { } } + /// + /// Looks up a localized string similar to Google ID Token is required.. + /// + public static string GoogleIDTokenIsRequired { + get { + return ResourceManager.GetString("GoogleIDTokenIsRequired", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid email format.. /// @@ -87,6 +96,15 @@ public static string InvalidEmailFormat { } } + /// + /// Looks up a localized string similar to Invalid Google Token Format.. + /// + public static string InvalidTokenFormat { + get { + return ResourceManager.GetString("InvalidTokenFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid user role.. /// diff --git a/Streetcode/Streetcode.Auth/Resources/ErrorMessages.resx b/Streetcode/Streetcode.Auth/Resources/ErrorMessages.resx index 77b66f2c..c946ddb8 100644 --- a/Streetcode/Streetcode.Auth/Resources/ErrorMessages.resx +++ b/Streetcode/Streetcode.Auth/Resources/ErrorMessages.resx @@ -162,4 +162,10 @@ Passwords did not match. + + Google ID Token is required. + + + Invalid Google Token Format. + \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth/Services/Interfaces/Users/IGoogleAuthService.cs b/Streetcode/Streetcode.Auth/Services/Interfaces/Users/IGoogleAuthService.cs new file mode 100644 index 00000000..3f43ede2 --- /dev/null +++ b/Streetcode/Streetcode.Auth/Services/Interfaces/Users/IGoogleAuthService.cs @@ -0,0 +1,9 @@ +using Google.Apis.Auth; + +namespace Streetcode.Auth.Services.Interfaces.Users +{ + public interface IGoogleAuthService + { + Task ValidateTokenAsync(string idToken); + } +} diff --git a/Streetcode/Streetcode.Auth/Services/Users/GoogleAuthService.cs b/Streetcode/Streetcode.Auth/Services/Users/GoogleAuthService.cs new file mode 100644 index 00000000..9e691ee2 --- /dev/null +++ b/Streetcode/Streetcode.Auth/Services/Users/GoogleAuthService.cs @@ -0,0 +1,33 @@ +using Google.Apis.Auth; +using Streetcode.Auth.Services.Interfaces.Users; + +namespace Streetcode.Auth.Services.Users +{ + public class GoogleAuthService : IGoogleAuthService + { + private readonly IConfiguration _configuration; + + public GoogleAuthService(IConfiguration configuration) + { + _configuration = configuration; + } + + public async Task ValidateTokenAsync(string idToken) + { + try + { + var clientId = _configuration["GoogleAuth:ClientId"]; + var settings = new GoogleJsonWebSignature.ValidationSettings() + { + Audience = new List { clientId! } + }; + + return await GoogleJsonWebSignature.ValidateAsync(idToken, settings); + } + catch + { + return null; + } + } + } +} diff --git a/Streetcode/Streetcode.Auth/Streetcode.Auth.csproj b/Streetcode/Streetcode.Auth/Streetcode.Auth.csproj index 36cf4b86..216817ef 100644 --- a/Streetcode/Streetcode.Auth/Streetcode.Auth.csproj +++ b/Streetcode/Streetcode.Auth/Streetcode.Auth.csproj @@ -12,6 +12,7 @@ + diff --git a/Streetcode/Streetcode.Auth/Validators/Users/GoogleLoginRequestDtoValidator.cs b/Streetcode/Streetcode.Auth/Validators/Users/GoogleLoginRequestDtoValidator.cs new file mode 100644 index 00000000..9539dc7c --- /dev/null +++ b/Streetcode/Streetcode.Auth/Validators/Users/GoogleLoginRequestDtoValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using Streetcode.Auth.Models.DTO; +using Streetcode.Auth.Resources; + +namespace Streetcode.Auth.Validators.Users +{ + public class GoogleLoginRequestDtoValidator : AbstractValidator + { + public GoogleLoginRequestDtoValidator() + { + RuleFor(x => x.Email) + .ValidEmail( + maxLength: 255, + requiredMessage: ErrorMessages.EmailIsRequired, + formatMessage: ErrorMessages.InvalidEmailFormat, + lengthMessage: ErrorMessages.EmailMustNotExceedCharacters); + + RuleFor(x => x.Name) + .RequiredWithMaxLength( + maxLength: 50, + requiredMessage: ErrorMessages.NameIsRequired, + lengthMessage: ErrorMessages.NameMustNotExceedCharacters); + + RuleFor(x => x.Surname) + .RequiredWithMaxLength( + maxLength: 50, + requiredMessage: ErrorMessages.NameIsRequired, + lengthMessage: ErrorMessages.NameMustNotExceedCharacters); + } + } +} diff --git a/Streetcode/Streetcode.Auth/Validators/Users/GoogleLoginRequestValidator.cs b/Streetcode/Streetcode.Auth/Validators/Users/GoogleLoginRequestValidator.cs new file mode 100644 index 00000000..310af6fc --- /dev/null +++ b/Streetcode/Streetcode.Auth/Validators/Users/GoogleLoginRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Streetcode.Auth.Models.DTO; +using Streetcode.Auth.Resources; + +namespace Streetcode.Auth.Validators.Users +{ + public class GoogleLoginRequestValidator : AbstractValidator + { + public GoogleLoginRequestValidator() + { + RuleFor(x => x.IdToken) + .NotEmpty().WithMessage(ErrorMessages.GoogleIDTokenIsRequired) + .MinimumLength(100).WithMessage(ErrorMessages.InvalidTokenFormat); + } + } +} diff --git a/Streetcode/Streetcode.BLL/DTO/Users/GoogleLoginRequest.cs b/Streetcode/Streetcode.BLL/DTO/Users/GoogleLoginRequest.cs new file mode 100644 index 00000000..b541e733 --- /dev/null +++ b/Streetcode/Streetcode.BLL/DTO/Users/GoogleLoginRequest.cs @@ -0,0 +1,7 @@ +namespace Streetcode.BLL.DTO.Users +{ + public class GoogleLoginRequest + { + public string IdToken { get; set; } = string.Empty; + } +} diff --git a/Streetcode/Streetcode.BLL/DTO/Users/GoogleLoginRequestDto.cs b/Streetcode/Streetcode.BLL/DTO/Users/GoogleLoginRequestDto.cs new file mode 100644 index 00000000..f4f6b802 --- /dev/null +++ b/Streetcode/Streetcode.BLL/DTO/Users/GoogleLoginRequestDto.cs @@ -0,0 +1,9 @@ +namespace Streetcode.BLL.DTO.Users +{ + public class GoogleLoginRequestDto + { + public string Email { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Surname { get; set; } = string.Empty; + } +} diff --git a/Streetcode/Streetcode.BLL/MediatR/Users/LoginGoogle/GoogleLoginCommand.cs b/Streetcode/Streetcode.BLL/MediatR/Users/LoginGoogle/GoogleLoginCommand.cs new file mode 100644 index 00000000..1928171e --- /dev/null +++ b/Streetcode/Streetcode.BLL/MediatR/Users/LoginGoogle/GoogleLoginCommand.cs @@ -0,0 +1,9 @@ +using FluentResults; +using MediatR; +using Streetcode.BLL.DTO.Users; + +namespace Streetcode.BLL.MediatR.Users.LoginGoogle +{ + public record GoogleLoginCommand(GoogleLoginRequestDto googleLoginRequest) + : IRequest>; +} diff --git a/Streetcode/Streetcode.BLL/MediatR/Users/LoginGoogle/GoogleLoginHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Users/LoginGoogle/GoogleLoginHandler.cs new file mode 100644 index 00000000..41355dd9 --- /dev/null +++ b/Streetcode/Streetcode.BLL/MediatR/Users/LoginGoogle/GoogleLoginHandler.cs @@ -0,0 +1,89 @@ +using System.IdentityModel.Tokens.Jwt; +using AutoMapper; +using FluentResults; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Streetcode.BLL.DTO.Users; +using Streetcode.BLL.Interfaces.Logging; +using Streetcode.BLL.Interfaces.Users; +using Streetcode.DAL.Entities.Users; +using Streetcode.DAL.Enums; + +namespace Streetcode.BLL.MediatR.Users.LoginGoogle +{ + public class GoogleLoginHandler : IRequestHandler> + { + private readonly UserManager _userManager; + private readonly ITokenService _tokenService; + private readonly IMapper _mapper; + private readonly ILoggerService _logger; + + public GoogleLoginHandler(UserManager userManager, ITokenService tokenService, ILoggerService logger, IMapper mapper) + { + _userManager = userManager; + _tokenService = tokenService; + _logger = logger; + _mapper = mapper; + } + + public async Task> Handle(GoogleLoginCommand request, CancellationToken cancellationToken) + { + var req = request.googleLoginRequest; + _logger.LogInformation($"Google login attempt for {req.Email}"); + + var user = await _userManager.FindByEmailAsync(req.Email); + + if (user == null) + { + user = new User { Email = req.Email, UserName = req.Email, Name = req.Name, Surname = req.Surname }; + + var createResult = await _userManager.CreateAsync(user); + if (IsFailure(createResult, request, $"Failed to create user {req.Email}", out var error)) + { + return error; + } + + var roleResult = await _userManager.AddToRoleAsync(user, UserRole.MainAdministrator.ToString()); + if (IsFailure(roleResult, request, $"Failed to add role for user {user.Id}", out error)) + { + return error; + } + } + + return Result.Ok(await GenerateLoginResult(user)); + } + + private bool IsFailure(IdentityResult result, object request, string message, out Result failure) + { + failure = null!; + if (result.Succeeded) + { + return false; + } + + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogError(request, $"{message}: {errors}"); + failure = Result.Fail(message); + return true; + } + private async Task GenerateLoginResult(User user) + { + var jwtToken = _tokenService.GenerateJWTToken(user); + var refreshToken = _tokenService.GenerateRefreshToken(); + + user.RefreshToken = refreshToken; + user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7); + await _userManager.UpdateAsync(user); + + _logger.LogInformation($"User {user.Id} successfully logged in"); + + return new LoginResultDto + { + User = _mapper.Map(user), + Token = new JwtSecurityTokenHandler().WriteToken(jwtToken), + RefreshToken = refreshToken, + ExpireAt = jwtToken.ValidTo + }; + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/Resources/ErrorMessages.Designer.cs b/Streetcode/Streetcode.BLL/Resources/ErrorMessages.Designer.cs index a1f421d3..1917f506 100644 --- a/Streetcode/Streetcode.BLL/Resources/ErrorMessages.Designer.cs +++ b/Streetcode/Streetcode.BLL/Resources/ErrorMessages.Designer.cs @@ -19,7 +19,7 @@ namespace Streetcode.BLL.Resources { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class ErrorMessages { @@ -618,6 +618,15 @@ public static string EmailFromMustNotExceedCharacters { } } + /// + /// Looks up a localized string similar to Invalid email format.. + /// + public static string EmailIsInvalid { + get { + return ResourceManager.GetString("EmailIsInvalid", resourceCulture); + } + } + /// /// Looks up a localized string similar to Email is required.. /// @@ -906,6 +915,15 @@ public static string FoundResultMatchingNull { } } + /// + /// Looks up a localized string similar to Google ID Token is required.. + /// + public static string GoogleIDTokenIsRequired { + get { + return ResourceManager.GetString("GoogleIDTokenIsRequired", resourceCulture); + } + } + /// /// Looks up a localized string similar to Historical contexts collection is required.. /// @@ -1041,6 +1059,15 @@ public static string InvalidToken { } } + /// + /// Looks up a localized string similar to Invalid Google Token Format.. + /// + public static string InvalidTokenFormat { + get { + return ResourceManager.GetString("InvalidTokenFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid user role.. /// diff --git a/Streetcode/Streetcode.BLL/Resources/ErrorMessages.resx b/Streetcode/Streetcode.BLL/Resources/ErrorMessages.resx index 569de4b3..4e5520d8 100644 --- a/Streetcode/Streetcode.BLL/Resources/ErrorMessages.resx +++ b/Streetcode/Streetcode.BLL/Resources/ErrorMessages.resx @@ -711,4 +711,13 @@ News with id {0} was not found + + Invalid email format. + + + Google ID Token is required. + + + Invalid Google Token Format. + \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/Validators/Users/GoogleLoginRequestDtoValidator.cs b/Streetcode/Streetcode.BLL/Validators/Users/GoogleLoginRequestDtoValidator.cs new file mode 100644 index 00000000..1179f585 --- /dev/null +++ b/Streetcode/Streetcode.BLL/Validators/Users/GoogleLoginRequestDtoValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using Streetcode.BLL.DTO.Users; +using Streetcode.BLL.Resources; + +namespace Streetcode.BLL.Validators.Users +{ + public class GoogleLoginRequestDtoValidator : AbstractValidator + { + public GoogleLoginRequestDtoValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage(ErrorMessages.EmailIsRequired) + .EmailAddress().WithMessage(ErrorMessages.InvalidEmailFormat); + + RuleFor(x => x.Name).MustBeValidName(); + RuleFor(x => x.Surname).MustBeValidName(); + } + } +} diff --git a/Streetcode/Streetcode.BLL/Validators/Users/GoogleLoginRequestValidator.cs b/Streetcode/Streetcode.BLL/Validators/Users/GoogleLoginRequestValidator.cs new file mode 100644 index 00000000..1b8a25eb --- /dev/null +++ b/Streetcode/Streetcode.BLL/Validators/Users/GoogleLoginRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Streetcode.BLL.DTO.Users; +using Streetcode.BLL.Resources; + +namespace Streetcode.BLL.Validators.Users +{ + public class GoogleLoginRequestValidator : AbstractValidator + { + public GoogleLoginRequestValidator() + { + RuleFor(x => x.IdToken) + .NotEmpty().WithMessage(ErrorMessages.GoogleIDTokenIsRequired) + .MinimumLength(100).WithMessage(ErrorMessages.InvalidTokenFormat); + } + } +} diff --git a/Streetcode/Streetcode.BLL/Validators/Users/ValidationExtensions.cs b/Streetcode/Streetcode.BLL/Validators/Users/ValidationExtensions.cs new file mode 100644 index 00000000..c386d76d --- /dev/null +++ b/Streetcode/Streetcode.BLL/Validators/Users/ValidationExtensions.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Streetcode.BLL.Resources; + +namespace Streetcode.BLL.Validators.Users +{ + public static class ValidationExtensions + { + public static IRuleBuilderOptions MustBeValidName(this IRuleBuilder ruleBuilder) + { + return ruleBuilder + .NotEmpty().WithMessage(ErrorMessages.NameIsRequired) + .MaximumLength(50).WithMessage(string.Format(ErrorMessages.NameMustNotExceedCharacters, 50)); + } + } +} diff --git a/Streetcode/Streetcode.WebApi/Controllers/Users/AuthController.cs b/Streetcode/Streetcode.WebApi/Controllers/Users/AuthController.cs index 3be0382e..fbba432d 100644 --- a/Streetcode/Streetcode.WebApi/Controllers/Users/AuthController.cs +++ b/Streetcode/Streetcode.WebApi/Controllers/Users/AuthController.cs @@ -1,12 +1,14 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Streetcode.BLL.DTO.Users; -using Microsoft.AspNetCore.Authorization; using Streetcode.BLL.MediatR.Users.Login; +using Streetcode.BLL.MediatR.Users.LoginGoogle; using Streetcode.BLL.MediatR.Users.Logout; -using Streetcode.BLL.MediatR.Users.Register; using Streetcode.BLL.MediatR.Users.RefreshToken; +using Streetcode.BLL.MediatR.Users.Register; +using Streetcode.WebApi.Service.Interfaces; namespace Streetcode.WebApi.Controllers.Users; @@ -14,6 +16,13 @@ namespace Streetcode.WebApi.Controllers.Users; [ExcludeFromCodeCoverage] public sealed class AuthController : BaseApiController { + private readonly IGoogleAuthService _googleAuthService; + + public AuthController(IGoogleAuthService googleAuthService) + { + _googleAuthService = googleAuthService; + } + [HttpPost("login")] public async Task Login([FromBody] UserLoginDto loginRequest, CancellationToken cancellationToken = default) { @@ -22,6 +31,35 @@ await base.Mediator.Send(new LoginUserCommand(loginRequest), cancellationToken) ); } + [HttpPost("google-login")] + public async Task GoogleLogin([FromBody] GoogleLoginRequest request) + { + try + { + var payload = await _googleAuthService.ValidateTokenAsync(request.IdToken); + + if (payload == null) + { + return Unauthorized("Invalid Google Token"); + } + + var loginDto = new GoogleLoginRequestDto + { + Email = payload.Email, + Name = payload.GivenName, + Surname = payload.FamilyName + }; + + return base.HandleResult( + await base.Mediator.Send(new GoogleLoginCommand(loginDto)) + ); + } + catch (Exception) + { + return Unauthorized("Invalid Google Token"); + } + } + [HttpPost("register")] public async Task Register([FromBody] UserRegisterDto registerRequest, CancellationToken cancellationToken = default) { @@ -43,7 +81,7 @@ await base.Mediator.Send(new RefreshTokenCommand(refreshTokenRequest), cancellat public async Task Logout(CancellationToken cancellationToken = default) { string? user_id_claim = User.FindFirstValue(ClaimTypes.NameIdentifier); - if(string.IsNullOrEmpty(user_id_claim) || !int.TryParse(user_id_claim, out int user_id)) + if (string.IsNullOrEmpty(user_id_claim) || !int.TryParse(user_id_claim, out int user_id)) { return base.Unauthorized(); } diff --git a/Streetcode/Streetcode.WebApi/Program.cs b/Streetcode/Streetcode.WebApi/Program.cs index a547f00e..be4616ef 100644 --- a/Streetcode/Streetcode.WebApi/Program.cs +++ b/Streetcode/Streetcode.WebApi/Program.cs @@ -1,10 +1,12 @@ using FluentValidation; using Hangfire; +using Streetcode.BLL.Interfaces.WebParsingUtils; using Streetcode.BLL.Services.BlobStorageService; +using Streetcode.BLL.Services.WebParsingUtils; using Streetcode.BLL.Validators; using Streetcode.WebApi.Extensions; -using Streetcode.BLL.Interfaces.WebParsingUtils; -using Streetcode.BLL.Services.WebParsingUtils; +using Streetcode.WebApi.Service; +using Streetcode.WebApi.Service.Interfaces; var builder = WebApplication.CreateBuilder(args); @@ -36,6 +38,8 @@ builder.Services.Configure(builder.Configuration.GetSection("UkrPoshtaParser")); builder.Services.Configure(builder.Configuration.GetSection("Geocoding")); +builder.Services.AddScoped(); + var app = builder.Build(); if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Local") diff --git a/Streetcode/Streetcode.WebApi/Service/GoogleAuthService.cs b/Streetcode/Streetcode.WebApi/Service/GoogleAuthService.cs new file mode 100644 index 00000000..53fef0e6 --- /dev/null +++ b/Streetcode/Streetcode.WebApi/Service/GoogleAuthService.cs @@ -0,0 +1,33 @@ +using Google.Apis.Auth; +using Streetcode.WebApi.Service.Interfaces; + +namespace Streetcode.WebApi.Service +{ + public class GoogleAuthService : IGoogleAuthService + { + private readonly IConfiguration _configuration; + + public GoogleAuthService(IConfiguration configuration) + { + _configuration = configuration; + } + + public async Task ValidateTokenAsync(string idToken) + { + try + { + var clientId = _configuration["GoogleAuth:ClientId"]; + var settings = new GoogleJsonWebSignature.ValidationSettings() + { + Audience = new List { clientId! } + }; + + return await GoogleJsonWebSignature.ValidateAsync(idToken, settings); + } + catch + { + return null!; + } + } + } +} diff --git a/Streetcode/Streetcode.WebApi/Service/Interfaces/IGoogleAuthService.cs b/Streetcode/Streetcode.WebApi/Service/Interfaces/IGoogleAuthService.cs new file mode 100644 index 00000000..dfaeafaa --- /dev/null +++ b/Streetcode/Streetcode.WebApi/Service/Interfaces/IGoogleAuthService.cs @@ -0,0 +1,9 @@ +using Google.Apis.Auth; + +namespace Streetcode.WebApi.Service.Interfaces +{ + public interface IGoogleAuthService + { + Task ValidateTokenAsync(string idToken); + } +} diff --git a/Streetcode/Streetcode.WebApi/Streetcode.WebApi.csproj b/Streetcode/Streetcode.WebApi/Streetcode.WebApi.csproj index 71ba06b4..6c1a0aca 100644 --- a/Streetcode/Streetcode.WebApi/Streetcode.WebApi.csproj +++ b/Streetcode/Streetcode.WebApi/Streetcode.WebApi.csproj @@ -21,6 +21,7 @@ + diff --git a/Streetcode/Streetcode.XUnitTest/AuthService/Controllers/AuthControllerTests.cs b/Streetcode/Streetcode.XUnitTest/AuthService/Controllers/AuthControllerTests.cs index 6741a776..51048da6 100644 --- a/Streetcode/Streetcode.XUnitTest/AuthService/Controllers/AuthControllerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/AuthService/Controllers/AuthControllerTests.cs @@ -1,16 +1,18 @@ using FluentAssertions; using FluentResults; +using Google.Apis.Auth; using MediatR; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Moq; using Streetcode.Auth.Controllers.Users; using Streetcode.Auth.MediatR.Users.Login; +using Streetcode.Auth.MediatR.Users.LoginGoogle; using Streetcode.Auth.MediatR.Users.Logout; using Streetcode.Auth.MediatR.Users.RefreshToken; using Streetcode.Auth.MediatR.Users.Register; using Streetcode.Auth.Models.DTO; +using Streetcode.Auth.Services.Interfaces.Users; +using Streetcode.Common.Enums; using Xunit; namespace Streetcode.XUnitTest.AuthService.Controllers; @@ -18,19 +20,21 @@ namespace Streetcode.XUnitTest.AuthService.Controllers; public class AuthControllerTests { private readonly Mock _mediatorMock; + private readonly Mock _googleAuthServiceMock; private readonly AuthController _controller; public AuthControllerTests() { _mediatorMock = new Mock(); - _controller = new AuthController(_mediatorMock.Object); + _googleAuthServiceMock = new Mock(); + _controller = new AuthController(_mediatorMock.Object, _googleAuthServiceMock.Object); } [Fact] public async Task Login_ShouldReturnOk() { // Arrange - var request = new UserLoginDto + var request = new Auth.Models.DTO.UserLoginDto { Login = "test", Password = "123" @@ -38,7 +42,7 @@ public async Task Login_ShouldReturnOk() var authResult = new AuthResponseDto { - User = new UserDto + User = new Auth.Models.DTO.UserDto { Id = 1, Email = "test@mail.com", @@ -181,4 +185,46 @@ public async Task Logout_ShouldReturnOk_WhenTokenIsValid() It.IsAny()), Times.Once); } + + [Fact] + public async Task GoogleLogin_ShouldReturnOk_WhenTokenIsValid() + { + // Arrange + var request = new GoogleLoginRequest { IdToken = "valid-token" }; + var payload = new GoogleJsonWebSignature.Payload { Email = "test@test.com", GivenName = "John", FamilyName = "Doe" }; + + var authResponse = new AuthResponseDto + { + User = new UserDto + { + Id = 1, + Name = "John", + Surname = "Doe", + Email = "john@example.com", + Login = "johndoe", + Role = UserRole.MainAdministrator + }, + Token = "jwt-token", + RefreshToken = "refresh-token", + ExpireAt = DateTime.UtcNow.AddHours(1) + }; + + _googleAuthServiceMock.Setup(x => x.ValidateTokenAsync("valid-token")) + .ReturnsAsync(payload); + + _mediatorMock.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok(authResponse)); + + var result = await _controller.GoogleLogin(request); + + // Assert + result.Should().BeOfType(); + + var okResult = result as OkObjectResult; + okResult?.Value.Should().BeEquivalentTo(authResponse); + + _mediatorMock.Verify( + x => x.Send(It.IsAny(), It.IsAny()), + Times.Once); + } } \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/AuthService/MediatR/Users/LoginGoogle/GoogleLoginHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/AuthService/MediatR/Users/LoginGoogle/GoogleLoginHandlerTests.cs new file mode 100644 index 00000000..82a4307b --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/AuthService/MediatR/Users/LoginGoogle/GoogleLoginHandlerTests.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Identity; +using Moq; +using Streetcode.Auth.MediatR.Users.LoginGoogle; +using Streetcode.Auth.Models.DTO; +using Streetcode.Auth.Models.Entities; +using Streetcode.Auth.Services.Interfaces.Logging; +using Streetcode.Auth.Services.Interfaces.Users; +using Xunit; + +namespace Streetcode.XUnitTest.AuthService.MediatR.Users.LoginGoogle +{ + public class GoogleLoginHandlerTests + { + private readonly Mock> _userManagerMock; + private readonly Mock _authServiceMock; + private readonly Mock _loggerMock; + private readonly GoogleLoginHandler _handler; + + public GoogleLoginHandlerTests() + { + var store = new Mock>(); + _userManagerMock = new Mock>(store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + + _authServiceMock = new Mock(); + _loggerMock = new Mock(); + + _handler = new GoogleLoginHandler( + _userManagerMock.Object, + _authServiceMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task Handle_ExistingUser_ReturnsSuccess() + { + // Arrange + var user = new User + { + Email = "test@test.com", + Id = 1, + Name = "Test", + Surname = "User" + }; + var request = new GoogleLoginCommand(new GoogleLoginRequestDto { Email = "test@test.com" }); + + _userManagerMock.Setup(x => x.FindByEmailAsync(request.googleLoginRequest.Email)) + .ReturnsAsync(user); + + _authServiceMock.Setup(x => x.CreateLoginResultAsync(user)) + .ReturnsAsync(new AuthResponseDto + { + Token = "jwt", + RefreshToken = "refresh", + User = new UserDto { Id = 1 } + }); + + // Act + var result = await _handler.Handle(request, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal("refresh", result.Value.RefreshToken); + _authServiceMock.Verify(x => x.CreateLoginResultAsync(user), Times.Once); + } + + [Fact] + public async Task Handle_NewUser_CreatesAndAssignsRole() + { + // Arrange + var request = new GoogleLoginCommand(new GoogleLoginRequestDto { Email = "new@test.com" }); + + _userManagerMock.Setup(x => x.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((User?)null); + + _userManagerMock.Setup(x => x.CreateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + _userManagerMock.Setup(x => x.AddToRoleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + _authServiceMock.Setup(x => x.CreateLoginResultAsync(It.IsAny())) + .ReturnsAsync(new AuthResponseDto + { + Token = "jwt", + RefreshToken = "refresh", + User = new UserDto { Id = 1 } + }); + + // Act + var result = await _handler.Handle(request, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + _userManagerMock.Verify(x => x.CreateAsync(It.IsAny()), Times.Once); + _userManagerMock.Verify(x => x.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Once); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/AuthService/Services/GoogleAuthServiceTests.cs b/Streetcode/Streetcode.XUnitTest/AuthService/Services/GoogleAuthServiceTests.cs new file mode 100644 index 00000000..1fec52ac --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/AuthService/Services/GoogleAuthServiceTests.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Configuration; +using Streetcode.Auth.Services.Users; +using Xunit; + +namespace Streetcode.XUnitTest.AuthService.Services +{ + public class GoogleAuthServiceTests + { + [Fact] + public async Task ValidateTokenAsync_ShouldReturnNull_WhenTokenIsInvalid() + { + // Arrange + var myConfiguration = new Dictionary + { + {"GoogleAuth:ClientId", "fake-client-id"} + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(myConfiguration!) + .Build(); + + var service = new GoogleAuthService(configuration); + + // Act + var result = await service.ValidateTokenAsync("invalid-token-string"); + + // Assert + Assert.Null(result); + } + } +} diff --git a/Streetcode/Streetcode.XUnitTest/AuthService/Validators/Users/GoogleLoginRequestDtoValidatorTests.cs b/Streetcode/Streetcode.XUnitTest/AuthService/Validators/Users/GoogleLoginRequestDtoValidatorTests.cs new file mode 100644 index 00000000..443f9717 --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/AuthService/Validators/Users/GoogleLoginRequestDtoValidatorTests.cs @@ -0,0 +1,70 @@ +using FluentValidation.TestHelper; +using Streetcode.Auth.Models.DTO; +using Streetcode.Auth.Validators.Users; +using Xunit; + +namespace Streetcode.XUnitTest.AuthService.Validators.Users +{ + public class GoogleLoginRequestDtoValidatorTests + { + private readonly GoogleLoginRequestDtoValidator _validator; + + public GoogleLoginRequestDtoValidatorTests() + { + _validator = new GoogleLoginRequestDtoValidator(); + } + + [Fact] + public void Should_Have_Error_When_Email_Is_Empty() + { + var model = new GoogleLoginRequestDto { Email = "", Name = "Valid", Surname = "Valid" }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Fact] + public void Should_Have_Error_When_Email_Is_Invalid() + { + var model = new GoogleLoginRequestDto { Email = "invalid-email", Name = "Valid", Surname = "Valid" }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Should_Have_Error_When_Name_Is_Empty(string? name) + { + var model = new GoogleLoginRequestDto { Email = "test@test.com", Name = name!, Surname = "Valid" }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Should_Have_Error_When_Name_Is_Too_Long() + { + var model = new GoogleLoginRequestDto + { + Email = "test@test.com", + Name = new string('a', 51), + Surname = "Valid" + }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Should_Not_Have_Error_When_Data_Is_Valid() + { + var model = new GoogleLoginRequestDto + { + Email = "valid@test.com", + Name = "John", + Surname = "Doe" + }; + var result = _validator.TestValidate(model); + result.ShouldNotHaveAnyValidationErrors(); + } + } +} diff --git a/Streetcode/Streetcode.XUnitTest/AuthService/Validators/Users/GoogleLoginRequestValidatorTests.cs b/Streetcode/Streetcode.XUnitTest/AuthService/Validators/Users/GoogleLoginRequestValidatorTests.cs new file mode 100644 index 00000000..8ba9a9ee --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/AuthService/Validators/Users/GoogleLoginRequestValidatorTests.cs @@ -0,0 +1,43 @@ +using FluentValidation.TestHelper; +using Streetcode.Auth.Models.DTO; +using Streetcode.Auth.Validators.Users; +using Xunit; + +namespace Streetcode.XUnitTest.AuthService.Validators.Users +{ + public class GoogleLoginRequestValidatorTests + { + private readonly GoogleLoginRequestValidator _validator; + + public GoogleLoginRequestValidatorTests() + { + _validator = new GoogleLoginRequestValidator(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Should_Have_Error_When_IdToken_Is_Empty(string? token) + { + var model = new GoogleLoginRequest { IdToken = token! }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.IdToken); + } + + [Fact] + public void Should_Have_Error_When_IdToken_Is_Too_Short() + { + var model = new GoogleLoginRequest { IdToken = "short-token" }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.IdToken); + } + + [Fact] + public void Should_Not_Have_Error_When_IdToken_Is_Valid_Length() + { + var model = new GoogleLoginRequest { IdToken = new string('a', 101) }; + var result = _validator.TestValidate(model); + result.ShouldNotHaveAnyValidationErrors(); + } + } +} diff --git a/Streetcode/Streetcode.XUnitTest/BLL/MediatR/Users/LoginGoogle/GoogleLoginHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/BLL/MediatR/Users/LoginGoogle/GoogleLoginHandlerTests.cs new file mode 100644 index 00000000..51f7369c --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/BLL/MediatR/Users/LoginGoogle/GoogleLoginHandlerTests.cs @@ -0,0 +1,97 @@ +using System.IdentityModel.Tokens.Jwt; +using AutoMapper; +using Microsoft.AspNetCore.Identity; +using Moq; +using Streetcode.BLL.DTO.Users; +using Streetcode.BLL.Interfaces.Logging; +using Streetcode.BLL.Interfaces.Users; +using Streetcode.BLL.MediatR.Users.LoginGoogle; +using Streetcode.DAL.Entities.Users; + +using Xunit; + +namespace Streetcode.XUnitTest.BLL.MediatR.Users.LoginGoogle +{ + public class GoogleLoginHandlerTests + { + private readonly Mock> _userManagerMock; + private readonly Mock _tokenServiceMock; + private readonly Mock _mapperMock; + private readonly Mock _loggerMock; + private readonly GoogleLoginHandler _handler; + + public GoogleLoginHandlerTests() + { + var store = new Mock>(); + _userManagerMock = new Mock>(store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + + _tokenServiceMock = new Mock(); + _mapperMock = new Mock(); + _loggerMock = new Mock(); + + _handler = new GoogleLoginHandler( + _userManagerMock.Object, + _tokenServiceMock.Object, + _loggerMock.Object, + _mapperMock.Object); + } + + [Fact] + public async Task Handle_ExistingUser_ReturnsSuccess() + { + // Arrange + var user = new User + { + Email = "test@test.com", + Id = 1, + Name = "Test", + Surname = "User" + }; + var request = new GoogleLoginCommand(new GoogleLoginRequestDto { Email = "test@test.com" }); + + _userManagerMock.Setup(x => x.FindByEmailAsync(request.googleLoginRequest.Email)) + .ReturnsAsync(user); + + _tokenServiceMock.Setup(x => x.GenerateJWTToken(user)) + .Returns(new JwtSecurityToken()); + + _tokenServiceMock.Setup(x => x.GenerateRefreshToken()) + .Returns("refresh_token"); + + // Act + var result = await _handler.Handle(request, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal("refresh_token", result.Value.RefreshToken); + _userManagerMock.Verify(x => x.UpdateAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_NewUser_CreatesAndAssignsRole() + { + // Arrange + var request = new GoogleLoginCommand(new GoogleLoginRequestDto { Email = "new@test.com" }); + + _userManagerMock.Setup(x => x.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((User?)null); + + _userManagerMock.Setup(x => x.CreateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + _userManagerMock.Setup(x => x.AddToRoleAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + _tokenServiceMock.Setup(x => x.GenerateJWTToken(It.IsAny())) + .Returns(new JwtSecurityToken()); + + // Act + var result = await _handler.Handle(request, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + _userManagerMock.Verify(x => x.CreateAsync(It.IsAny()), Times.Once); + _userManagerMock.Verify(x => x.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/Streetcode/Streetcode.XUnitTest/BLL/Validators/Users/GoogleLoginRequestDtoValidatorTests.cs b/Streetcode/Streetcode.XUnitTest/BLL/Validators/Users/GoogleLoginRequestDtoValidatorTests.cs new file mode 100644 index 00000000..07716de6 --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/BLL/Validators/Users/GoogleLoginRequestDtoValidatorTests.cs @@ -0,0 +1,70 @@ +using FluentValidation.TestHelper; +using Streetcode.BLL.DTO.Users; +using Streetcode.BLL.Validators.Users; +using Xunit; + +namespace Streetcode.XUnitTest.BLL.Validators.Users +{ + public class GoogleLoginRequestDtoValidatorTests + { + private readonly GoogleLoginRequestDtoValidator _validator; + + public GoogleLoginRequestDtoValidatorTests() + { + _validator = new GoogleLoginRequestDtoValidator(); + } + + [Fact] + public void Should_Have_Error_When_Email_Is_Empty() + { + var model = new GoogleLoginRequestDto { Email = "", Name = "Valid", Surname = "Valid" }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Fact] + public void Should_Have_Error_When_Email_Is_Invalid() + { + var model = new GoogleLoginRequestDto { Email = "invalid-email", Name = "Valid", Surname = "Valid" }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Should_Have_Error_When_Name_Is_Empty(string? name) + { + var model = new GoogleLoginRequestDto { Email = "test@test.com", Name = name!, Surname = "Valid" }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Should_Have_Error_When_Name_Is_Too_Long() + { + var model = new GoogleLoginRequestDto + { + Email = "test@test.com", + Name = new string('a', 51), + Surname = "Valid" + }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Should_Not_Have_Error_When_Data_Is_Valid() + { + var model = new GoogleLoginRequestDto + { + Email = "valid@test.com", + Name = "John", + Surname = "Doe" + }; + var result = _validator.TestValidate(model); + result.ShouldNotHaveAnyValidationErrors(); + } + } +} diff --git a/Streetcode/Streetcode.XUnitTest/BLL/Validators/Users/GoogleLoginRequestValidatorTests.cs b/Streetcode/Streetcode.XUnitTest/BLL/Validators/Users/GoogleLoginRequestValidatorTests.cs new file mode 100644 index 00000000..2df9d368 --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/BLL/Validators/Users/GoogleLoginRequestValidatorTests.cs @@ -0,0 +1,43 @@ +using FluentValidation.TestHelper; +using Streetcode.BLL.DTO.Users; +using Streetcode.BLL.Validators.Users; +using Xunit; + +namespace Streetcode.XUnitTest.BLL.Validators.Users +{ + public class GoogleLoginRequestValidatorTests + { + private readonly GoogleLoginRequestValidator _validator; + + public GoogleLoginRequestValidatorTests() + { + _validator = new GoogleLoginRequestValidator(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Should_Have_Error_When_IdToken_Is_Empty(string? token) + { + var model = new GoogleLoginRequest { IdToken = token! }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.IdToken); + } + + [Fact] + public void Should_Have_Error_When_IdToken_Is_Too_Short() + { + var model = new GoogleLoginRequest { IdToken = "short-token" }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.IdToken); + } + + [Fact] + public void Should_Not_Have_Error_When_IdToken_Is_Valid_Length() + { + var model = new GoogleLoginRequest { IdToken = new string('a', 101) }; + var result = _validator.TestValidate(model); + result.ShouldNotHaveAnyValidationErrors(); + } + } +} diff --git a/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj b/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj index 70d4582e..00a67463 100644 --- a/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj +++ b/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj @@ -45,7 +45,6 @@ - \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/WebApi/Controllers/AuthControllerTests.cs b/Streetcode/Streetcode.XUnitTest/WebApi/Controllers/AuthControllerTests.cs new file mode 100644 index 00000000..53cb8ed2 --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/WebApi/Controllers/AuthControllerTests.cs @@ -0,0 +1,145 @@ +using System.Security.Claims; +using FluentAssertions; +using FluentResults; +using Google.Apis.Auth; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Streetcode.BLL.DTO.Users; +using Streetcode.BLL.MediatR.Users.Login; +using Streetcode.BLL.MediatR.Users.LoginGoogle; +using Streetcode.BLL.MediatR.Users.RefreshToken; +using Streetcode.BLL.MediatR.Users.Register; +using Streetcode.DAL.Enums; +using Streetcode.WebApi.Controllers; +using Streetcode.WebApi.Controllers.Users; +using Streetcode.WebApi.Service.Interfaces; +using Xunit; + +namespace Streetcode.XUnitTest.WebApi.Controllers; + +public class AuthControllerTests +{ + private readonly Mock _mediatorMock; + private readonly Mock _googleAuthServiceMock; + private readonly AuthController _controller; + + public AuthControllerTests() + { + _mediatorMock = new Mock(); + _googleAuthServiceMock = new Mock(); + + _controller = new AuthController(_googleAuthServiceMock.Object); + + var field = typeof(BaseApiController).GetField( + "k__BackingField", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + + if (field != null) + { + field.SetValue(_controller, _mediatorMock.Object); + } + else + { + throw new Exception("Не удалось найти поле k__BackingField в BaseApiController."); + } + } + + private static LoginResultDto CreateLoginResult() => new LoginResultDto + { + User = new UserDto { Id = 1, Email = "test@mail.com", Name = "John", Surname = "Doe", Login = "jd", Role = UserRole.MainAdministrator }, + Token = "jwt-token", + RefreshToken = "refresh-token", + ExpireAt = DateTime.UtcNow.AddHours(1) + }; + + [Fact] + public async Task Login_ShouldReturnOk() + { + var request = new UserLoginDto { Login = "test", Password = "123" }; + var loginResult = CreateLoginResult(); + + _mediatorMock.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok(loginResult)); + + var result = await _controller.Login(request); + + result.Should().BeOfType(); + ((OkObjectResult)result).Value.Should().BeEquivalentTo(loginResult); + } + + [Fact] + public async Task Register_ShouldReturnOk() + { + // Arrange + var request = new UserRegisterDto + { + Email = "test@mail.com", + Password = "123", + PasswordConfirmation = "123", + Name = "John", + Surname = "Doe" + }; + + _mediatorMock + .Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok()); + + // Act + var result = await _controller.Register(request); + + // Assert + result.Should().BeOfType(); + + _mediatorMock.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task RefreshToken_ShouldReturnOk() + { + var request = new RefreshTokenRequestDto { Token = "tiken", RefreshToken = "token" }; + var loginResult = CreateLoginResult(); + + _mediatorMock.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok(loginResult)); + + var result = await _controller.RefreshToken(request); + + result.Should().BeOfType(); + ((OkObjectResult)result).Value.Should().BeEquivalentTo(loginResult); + } + + [Fact] + public async Task Logout_ShouldReturnUnauthorized_WhenUserClaimsAreInvalid() + { + // Arrange + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity()) } + }; + + // Act + var result = await _controller.Logout(CancellationToken.None); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task GoogleLogin_ShouldReturnOk_WhenTokenIsValid() + { + var request = new GoogleLoginRequest { IdToken = "valid-token" }; + var payload = new GoogleJsonWebSignature.Payload { Email = "test@test.com", GivenName = "John", FamilyName = "Doe" }; + var loginResult = CreateLoginResult(); + + _googleAuthServiceMock.Setup(x => x.ValidateTokenAsync("valid-token")).ReturnsAsync(payload); + _mediatorMock.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok(loginResult)); + + var result = await _controller.GoogleLogin(request); + + result.Should().BeOfType(); + ((OkObjectResult)result).Value.Should().BeEquivalentTo(loginResult); + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.slnx b/Streetcode/Streetcode.slnx index db05cc8b..b4cfa13a 100644 --- a/Streetcode/Streetcode.slnx +++ b/Streetcode/Streetcode.slnx @@ -13,12 +13,18 @@ - + + + - + + + - + + +