diff --git a/Bimix.API/Bimix.API.csproj b/Bimix.API/Bimix.API.csproj index b9f2005..e4972ba 100644 --- a/Bimix.API/Bimix.API.csproj +++ b/Bimix.API/Bimix.API.csproj @@ -7,16 +7,18 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all + + - diff --git a/Bimix.API/Controllers/AuthController.cs b/Bimix.API/Controllers/AuthController.cs new file mode 100644 index 0000000..7ed1397 --- /dev/null +++ b/Bimix.API/Controllers/AuthController.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using Bimix.API.Services; +using Bimix.Application.DTOModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bimix.API.Controllers; + +public class AuthController( + GoogleAuthService googleAuthService, + JwtTokenService jwtTokenService, + ILogger logger) + : ControllerBase +{ + [HttpPost("google")] + public async Task GoogleAuth([FromBody] GoogleAuthRequest request) + { + try + { + if (string.IsNullOrEmpty(request.IdToken)) + { + return BadRequest(new GoogleAuthResponse + { + Success = false, + Error = "IdToken is required" + }); + } + + var (isValid, user, error) = await googleAuthService.ValidateGoogleTokenAsync(request.IdToken); + + if (!isValid || user == null) + { + var statusCode = error switch + { + "User not authorized to access this application" => 403, + "User account is not active" => 403, + "Invalid Google token" => 401, + _ => 401 + }; + + return StatusCode(statusCode, new GoogleAuthResponse + { + Success = false, + Error = error ?? "Authentication failed" + }); + } + + var jwt = jwtTokenService.GenerateToken(user); + + return Ok(new GoogleAuthResponse + { + Success = true, + Token = jwt, + User = new UserDto + { + Id = user.Id, + Email = user.Email, + FullName = user.FullName, + IsActive = user.IsActive, + LastLoginAt = user.LastLoginAt + } + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during Google authentication"); + return StatusCode(500, new GoogleAuthResponse + { + Success = false, + Error = "Internal server error" + }); + } + } + + [HttpGet("me")] + [Authorize] + public IActionResult GetCurrentUser() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier); + var emailClaim = User.FindFirst(ClaimTypes.Email); + var nameClaim = User.FindFirst(ClaimTypes.Name); + + if (userIdClaim == null || emailClaim == null || nameClaim == null) + { + return Unauthorized(); + } + + return Ok(new UserDto + { + Id = Guid.Parse(userIdClaim.Value), + Email = emailClaim.Value, + FullName = nameClaim.Value, + IsActive = true, + }); + } +} \ No newline at end of file diff --git a/Bimix.API/Program.cs b/Bimix.API/Program.cs index 72aa51f..c614b8e 100644 --- a/Bimix.API/Program.cs +++ b/Bimix.API/Program.cs @@ -1,6 +1,11 @@ +using System.Text; +using Bimix.API.Services; using Bimix.Infrastructure.Data; using Bimix.Infrastructure.Sync; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + var builder = WebApplication.CreateBuilder(args); @@ -13,6 +18,43 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +// Start auth section +var jwtSettings = builder.Configuration.GetSection("JwtSettings"); +var secretKey = jwtSettings["SecretKey"]; +var issuer = jwtSettings["Issuer"]; +var audience = jwtSettings["Audience"]; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = issuer, + ValidAudience = audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + ClockSkew = TimeSpan.Zero, + }; + }); +builder.Services.AddAuthentication(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); +// End auth section + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -22,6 +64,8 @@ if (app.Environment.IsDevelopment()) } app.UseHttpsRedirection(); +app.UseCors("AllowAll"); +app.UseAuthorization(); app.UseAuthorization(); app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/Bimix.API/Services/GoogleAuthService.cs b/Bimix.API/Services/GoogleAuthService.cs new file mode 100644 index 0000000..8ca694d --- /dev/null +++ b/Bimix.API/Services/GoogleAuthService.cs @@ -0,0 +1,72 @@ +using Bimix.Domain.Entities; +using Bimix.Infrastructure.Data; +using Google.Apis.Auth; +using Microsoft.EntityFrameworkCore; + +namespace Bimix.API.Services; + +public class GoogleAuthService(BimixDbContext context, IConfiguration configuration, ILogger logger) +{ + private readonly BimixDbContext _context = context; + private readonly IConfiguration _configuration = configuration; + private readonly ILogger _logger = logger; + + public async Task<(bool IsValid, User? user, string? error)> ValidateGoogleTokenAsync(string idToken) + { + try + { + var clientId = _configuration["GoogleAuth:ClientId"]; + if (string.IsNullOrEmpty(clientId)) + { + _logger.LogError("Google Auth Client Id is not configured"); + return (false, null, "Google Auth Client Id is not configured"); + } + + var payload = await GoogleJsonWebSignature.ValidateAsync(idToken, + new GoogleJsonWebSignature.ValidationSettings + { + Audience = new[] { clientId } + }); + + _logger.LogInformation("Google token validated for user: {Email}", payload.Email); + + var user = await _context.Users + .FirstOrDefaultAsync(x => x.GoogleId == payload.Subject || x.Email == payload.Email); + + if (user == null) + { + _logger.LogError("User not found in Bimix database: {Email}", payload.Email); + return (false, null, "User not found in Bimix database"); + } + + if (!user.IsActive) + { + _logger.LogError("User is not active: {Email}", payload.Email); + return (false, null, "User is not active"); + } + + user.LastLoginAt = DateTime.UtcNow; + user.FullName = payload.Name; + + if (user.GoogleId != payload.Subject) + { + user.GoogleId = payload.Subject; + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation("User logged in: {Email}", payload.Email); + + return (true, user, null); + } + catch (InvalidJwtException ex) + { + _logger.LogError(ex, "Invalid JWT token"); + return (false, null, "Invalid JWT token"); + } catch (Exception ex) + { + _logger.LogError(ex, "Error validating Google token"); + return (false, null, "Error validating Google token"); + } + } +} \ No newline at end of file diff --git a/Bimix.API/Services/JwtTokenService.cs b/Bimix.API/Services/JwtTokenService.cs new file mode 100644 index 0000000..f37cfda --- /dev/null +++ b/Bimix.API/Services/JwtTokenService.cs @@ -0,0 +1,87 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Bimix.Domain.Entities; + + +namespace Bimix.API.Services; + +public class JwtTokenService(IConfiguration configuration, ILogger logger) +{ + private readonly IConfiguration _configuration = configuration; + private readonly ILogger _logger = logger; + + public string GenerateToken(User user) + { + var jwtSettings = _configuration.GetSection("JwtSettings"); + var securityKey = jwtSettings["SecurityKey"]; + var issuer = jwtSettings["Issuer"]; + var audience = jwtSettings["Audience"]; + var expiryDays = int.Parse(jwtSettings["ExpiryDays"] ?? "7"); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.FullName), + new Claim("google_id", user.GoogleId), + new Claim("is_active", user.IsActive.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds().ToString(), + ClaimValueTypes.Integer64) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddDays(expiryDays), + signingCredentials: creds + ); + + var tokenString = new JwtSecurityTokenHandler().WriteToken(token); + + _logger.LogInformation("Generated JWT token for user: {Email}", user.Email); + + return tokenString; + } + + public ClaimsPrincipal? ValidateToken(string token) + { + try + { + var jwtSettings = _configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"]; + var issuer = jwtSettings["Issuer"]; + var audience = jwtSettings["Audience"]; + + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(secretKey); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = issuer, + ValidAudience = audience, + IssuerSigningKey = new SymmetricSecurityKey(key), + ClockSkew = TimeSpan.Zero + }; + + var principal = tokenHandler.ValidateToken(token, validationParameters, out _); + return principal; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating JWT token"); + return null; + } + } +} + diff --git a/Bimix.API/appsettings.Development.json b/Bimix.API/appsettings.Development.json index 9165130..a13a784 100644 Binary files a/Bimix.API/appsettings.Development.json and b/Bimix.API/appsettings.Development.json differ diff --git a/Bimix.Application/DTOModels/AuthDto.cs b/Bimix.Application/DTOModels/AuthDto.cs new file mode 100644 index 0000000..a150a9c --- /dev/null +++ b/Bimix.Application/DTOModels/AuthDto.cs @@ -0,0 +1,22 @@ +namespace Bimix.Application.DTOModels; + +public class GoogleAuthRequest +{ + public string? IdToken { get; set; } +} + +public class GoogleAuthResponse +{ + public bool Success { get; set; } + public string? Token { get; set; } + public UserDto? User { get; set; } + public string? Error { get; set; } +} + +public class UserDto { + public Guid Id { get; set; } + public string Email { get; set; } = default!; + public string FullName { get; set; } = default!; + public bool IsActive { get; set; } + public DateTime? LastLoginAt { get; set; } +} \ No newline at end of file diff --git a/Bimix.Domain/Entities/User.cs b/Bimix.Domain/Entities/User.cs new file mode 100644 index 0000000..490a4a0 --- /dev/null +++ b/Bimix.Domain/Entities/User.cs @@ -0,0 +1,10 @@ +namespace Bimix.Domain.Entities; + +public class User : BaseEntity +{ + public string GoogleId { get; set; } = default!; + public string Email { get; set; } = default!; + public string FullName { get; set; } = default!; + public bool IsActive { get; set; } = false; + public DateTime? LastLoginAt { get; set; } +} \ No newline at end of file diff --git a/Bimix.Infrastructure/Data/BimixDbContext.cs b/Bimix.Infrastructure/Data/BimixDbContext.cs index 3388c1e..96875cb 100644 --- a/Bimix.Infrastructure/Data/BimixDbContext.cs +++ b/Bimix.Infrastructure/Data/BimixDbContext.cs @@ -7,17 +7,84 @@ public class BimixDbContext(DbContextOptions options) : DbContex { public DbSet Products { get; set; } public DbSet SyncStates { get; set; } + public DbSet Users { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + // Product properties modelBuilder.Entity().HasKey(x => x.Id); modelBuilder.Entity().Property(x => x.Name).IsRequired().HasMaxLength(512); modelBuilder.Entity().Property(x => x.Code).IsRequired().HasMaxLength(40); modelBuilder.Entity().Property(x => x.Ean).IsRequired().HasMaxLength(50); modelBuilder.Entity().Property(x => x.StockAddresses).IsRequired().HasMaxLength(512); + // SyncState properties modelBuilder.Entity().HasKey((x => x.Entity)); + + // User properties + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().Property(x => x.GoogleId).IsRequired().HasMaxLength(100); + modelBuilder.Entity().Property(x => x.Email).IsRequired().HasMaxLength(255); + modelBuilder.Entity().Property(x => x.FullName).IsRequired().HasMaxLength(255); + modelBuilder.Entity().Property(x => x.IsActive).IsRequired().HasDefaultValue(false); + modelBuilder.Entity().Property(x => x.LastLoginAt).IsRequired(false); + + // User indexes + modelBuilder.Entity().HasIndex(x => x.GoogleId).IsUnique().HasDatabaseName("IX_Users_GoogleId"); + modelBuilder.Entity().HasIndex(x => x.Email).IsUnique().HasDatabaseName("IX_Users_Email"); + + // Configure defaults for all CreatedAt and UpdatedAt in entities + ConfigureBaseEntity(modelBuilder); + } + + private void ConfigureBaseEntity(ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (typeof(BaseEntity).IsAssignableFrom(entityType.ClrType)) + { + modelBuilder.Entity(entityType.ClrType) + .Property(nameof(BaseEntity.CreatedAt)) + .HasDefaultValueSql("GETUTCDATE()"); + + modelBuilder.Entity(entityType.ClrType) + .Property(nameof(BaseEntity.UpdatedAt)) + .HasDefaultValueSql("GETUTCDATE()"); + } + } + } + + public override int SaveChanges() + { + UpdateTimestamps(); + return base.SaveChanges(); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + UpdateTimestamps(); + return base.SaveChangesAsync(cancellationToken); + } + + private void UpdateTimestamps() + { + var entities = ChangeTracker.Entries(); + + foreach (var entity in entities) + { + if (entity.State == EntityState.Added) + { + entity.Entity.CreatedAt = DateTime.UtcNow; + entity.Entity.UpdatedAt = DateTime.UtcNow; + break; + } + else if (entity.State == EntityState.Modified) + { + entity.Entity.UpdatedAt = DateTime.UtcNow; + break; + } + } } } \ No newline at end of file diff --git a/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.Designer.cs b/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.Designer.cs new file mode 100644 index 0000000..56377d1 --- /dev/null +++ b/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using Bimix.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bimix.Infrastructure.Migrations +{ + [DbContext(typeof(BimixDbContext))] + [Migration("20250718162313_AddUsersTable")] + partial class AddUsersTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Bimix.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Ean") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("StockAddresses") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.HasKey("Id"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Bimix.Domain.Entities.SyncState", b => + { + b.Property("Entity") + .HasColumnType("nvarchar(450)"); + + b.Property("LastSynced") + .HasColumnType("bigint"); + + b.HasKey("Entity"); + + b.ToTable("SyncStates"); + }); + + modelBuilder.Entity("Bimix.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("GoogleId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("IX_Users_Email"); + + b.HasIndex("GoogleId") + .IsUnique() + .HasDatabaseName("IX_Users_GoogleId"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.cs b/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.cs new file mode 100644 index 0000000..95a9473 --- /dev/null +++ b/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bimix.Infrastructure.Migrations +{ + /// + public partial class AddUsersTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "Products", + type: "datetime2", + nullable: false, + defaultValueSql: "GETUTCDATE()", + oldClrType: typeof(DateTime), + oldType: "datetime2"); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Products", + type: "datetime2", + nullable: false, + defaultValueSql: "GETUTCDATE()", + oldClrType: typeof(DateTime), + oldType: "datetime2"); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + GoogleId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Email = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + FullName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + IsActive = table.Column(type: "bit", nullable: false, defaultValue: false), + LastLoginAt = table.Column(type: "datetime2", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()") + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_GoogleId", + table: "Users", + column: "GoogleId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "Products", + type: "datetime2", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "datetime2", + oldDefaultValueSql: "GETUTCDATE()"); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Products", + type: "datetime2", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "datetime2", + oldDefaultValueSql: "GETUTCDATE()"); + } + } +} diff --git a/Bimix.Infrastructure/Migrations/BimixDbContextModelSnapshot.cs b/Bimix.Infrastructure/Migrations/BimixDbContextModelSnapshot.cs index d4a21e3..02130e5 100644 --- a/Bimix.Infrastructure/Migrations/BimixDbContextModelSnapshot.cs +++ b/Bimix.Infrastructure/Migrations/BimixDbContextModelSnapshot.cs @@ -34,7 +34,9 @@ namespace Bimix.Infrastructure.Migrations .HasColumnType("nvarchar(40)"); b.Property("CreatedAt") - .HasColumnType("datetime2"); + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); b.Property("Ean") .IsRequired() @@ -52,7 +54,9 @@ namespace Bimix.Infrastructure.Migrations .HasColumnType("nvarchar(512)"); b.Property("UpdatedAt") - .HasColumnType("datetime2"); + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); b.HasKey("Id"); @@ -71,6 +75,58 @@ namespace Bimix.Infrastructure.Migrations b.ToTable("SyncStates"); }); + + modelBuilder.Entity("Bimix.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("GoogleId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("IX_Users_Email"); + + b.HasIndex("GoogleId") + .IsUnique() + .HasDatabaseName("IX_Users_GoogleId"); + + b.ToTable("Users"); + }); #pragma warning restore 612, 618 } } diff --git a/Bimix.UI.Shared/Bimix.UI.Shared.csproj b/Bimix.UI.Shared/Bimix.UI.Shared.csproj index 672ca7e..c7a5aec 100644 --- a/Bimix.UI.Shared/Bimix.UI.Shared.csproj +++ b/Bimix.UI.Shared/Bimix.UI.Shared.csproj @@ -24,11 +24,7 @@ - - - - - + diff --git a/Bimix.UI.Shared/Components/AuthGuard.razor b/Bimix.UI.Shared/Components/AuthGuard.razor new file mode 100644 index 0000000..225a102 --- /dev/null +++ b/Bimix.UI.Shared/Components/AuthGuard.razor @@ -0,0 +1,43 @@ +@using Bimix.UI.Shared.Services +@inject AuthService AuthService +@inject NavigationManager Navigation + +@if (_isLoading) +{ +
+ +
+} +else if (_isAuthenticated) +{ + @ChildContent +} + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + + private bool _isLoading = true; + private bool _isAuthenticated = false; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + Console.WriteLine("AuthGuard: Checking authentication..."); + + // ZAWSZE sprawdź localStorage przy inicjalizacji + _isAuthenticated = await AuthService.CheckAuthenticationAsync(); + _isLoading = false; + + Console.WriteLine($"AuthGuard: isAuthenticated={_isAuthenticated}"); + + if (!_isAuthenticated) + { + Console.WriteLine("AuthGuard: Redirecting to /login"); + Navigation.NavigateTo("/login", replace: true); + } + + StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/Bimix.UI.Shared/Components/LoginCard.razor b/Bimix.UI.Shared/Components/LoginCard.razor new file mode 100644 index 0000000..db146c1 --- /dev/null +++ b/Bimix.UI.Shared/Components/LoginCard.razor @@ -0,0 +1,121 @@ +@using Microsoft.Extensions.Configuration +@using Bimix.UI.Shared.Services +@inject IJSRuntime JS +@inject IConfiguration Configuration +@inject AuthService AuthService +@inject NavigationManager NavigationManager + + + +@code { + private bool _isLoading = false; + private string _errorMessage = string.Empty; + private static LoginCard? _instance; + + protected override void OnInitialized() + { + _instance = this; + } + + private async Task HandleGoogleSignIn() + { + try + { + _isLoading = true; + _errorMessage = string.Empty; + StateHasChanged(); + + var clientId = Configuration["GoogleAuth:ClientId"]; + + if (string.IsNullOrEmpty(clientId)) + { + throw new Exception("Google ClientId is not configured."); + } + + + await JS.InvokeVoidAsync("initGoogleSignIn", clientId); + } + catch (Exception ex) + { + _errorMessage = "Błąd podczas logownia. Spróbuj ponownie"; + _isLoading = false; + StateHasChanged(); + } + } + + [JSInvokable] + public static async Task OnGoogleSignInSuccess(string idToken) + { + Console.WriteLine($"Google ID Token: {idToken}"); + + if (_instance != null) + { + await _instance.AuthService.SetAuthenticationAsync(idToken); + + _instance._isLoading = false; + _instance._errorMessage = string.Empty; + + _instance.NavigationManager.NavigateTo("/dashboard", replace:true); + + await _instance.InvokeAsync(() => _instance.StateHasChanged()); + } + } + + [JSInvokable] + public static async Task OnGoggleSignInError(string error) + { + Console.WriteLine($"Google SignIn Error: {error}"); + + if (_instance != null) + { + _instance._isLoading = false; + _instance._errorMessage = "Błąd logowanie Google. Spróbuj ponownie"; + await _instance.InvokeAsync(() => _instance.StateHasChanged()); + } + } + +} + + \ No newline at end of file diff --git a/Bimix.UI.Shared/EmptyLayout.razor b/Bimix.UI.Shared/EmptyLayout.razor new file mode 100644 index 0000000..2416b89 --- /dev/null +++ b/Bimix.UI.Shared/EmptyLayout.razor @@ -0,0 +1,8 @@ +@inherits LayoutComponentBase + + + + + + +@Body \ No newline at end of file diff --git a/Bimix.UI.Shared/MainLayout.razor b/Bimix.UI.Shared/MainLayout.razor index ab0bade..0e55d0e 100644 --- a/Bimix.UI.Shared/MainLayout.razor +++ b/Bimix.UI.Shared/MainLayout.razor @@ -1,41 +1,43 @@ @using MudBlazor @inherits LayoutComponentBase - - - + + + + - - - - - - Bimix - + + + + + + Bimix + - - - Dashboard - Products - - + + + Dashboard + Products + + - - - @Body - - - + + + @Body + + + + @code { @@ -60,7 +62,8 @@ _drawerVariant = DrawerVariant.Persistent; _drawerOpen = true; } - + StateHasChanged(); } + } \ No newline at end of file diff --git a/Bimix.UI.Shared/Pages/LoginPage.razor b/Bimix.UI.Shared/Pages/LoginPage.razor new file mode 100644 index 0000000..ee1255e --- /dev/null +++ b/Bimix.UI.Shared/Pages/LoginPage.razor @@ -0,0 +1,46 @@ +@page "/login" +@layout EmptyLayout + + + + \ No newline at end of file diff --git a/Bimix.UI.Shared/Services/AuthService.cs b/Bimix.UI.Shared/Services/AuthService.cs new file mode 100644 index 0000000..cb99e6b --- /dev/null +++ b/Bimix.UI.Shared/Services/AuthService.cs @@ -0,0 +1,86 @@ +using Microsoft.JSInterop; + +namespace Bimix.UI.Shared.Services; + +public class AuthService +{ + private readonly IJSRuntime _jsRuntime; + private bool? _isAuthenticated; + + public event Action? AuthenticationStateChanged; + + public AuthService(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public bool IsAuthenticated => _isAuthenticated ?? false; + + public async Task CheckAuthenticationAsync() + { + try + { + var token = await _jsRuntime.InvokeAsync("localStorage.getItem", "google_token"); + _isAuthenticated = !string.IsNullOrEmpty(token); + + Console.WriteLine($"AuthService.CheckAuthentication: token={(!string.IsNullOrEmpty(token) ? "EXISTS" : "NULL")}, isAuth={_isAuthenticated}"); + + return _isAuthenticated.Value; + } + catch (Exception ex) + { + Console.WriteLine($"AuthService.CheckAuthentication ERROR: {ex.Message}"); + _isAuthenticated = false; + return false; + } + } + + public async Task SetAuthenticationAsync(string token) + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "google_token", token); + _isAuthenticated = true; + Console.WriteLine($"AuthService.SetAuthentication: token saved, isAuth={_isAuthenticated}"); + AuthenticationStateChanged?.Invoke(true); + } + catch (Exception ex) + { + Console.WriteLine($"AuthService.SetAuthentication ERROR: {ex.Message}"); + } + } + + public async Task ClearAuthenticationAsync() + { + try + { + await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "google_token"); + _isAuthenticated = false; + Console.WriteLine($"AuthService.ClearAuthentication: token removed"); + AuthenticationStateChanged?.Invoke(false); + } + catch (Exception ex) + { + Console.WriteLine($"AuthService.ClearAuthentication ERROR: {ex.Message}"); + } + } + + public async Task GetTokenAsync() + { + if (_isAuthenticated != true) + { + await CheckAuthenticationAsync(); + } + + if (_isAuthenticated != true) return null; + + try + { + return await _jsRuntime.InvokeAsync("localStorage.getItem", "google_token"); + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/Bimix.UI.Shared/Services/GoogleAuthConfig.cs b/Bimix.UI.Shared/Services/GoogleAuthConfig.cs new file mode 100644 index 0000000..94e258b --- /dev/null +++ b/Bimix.UI.Shared/Services/GoogleAuthConfig.cs @@ -0,0 +1,7 @@ +namespace Bimix.UI.Shared.Services; + +// TODO it's a good place for this file? +public class GoogleAuthConfig +{ + public string ClientId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Bimix.UI.Shared/wwwroot/images/login-background.jpg b/Bimix.UI.Shared/wwwroot/images/login-background.jpg new file mode 100644 index 0000000..419e8a3 Binary files /dev/null and b/Bimix.UI.Shared/wwwroot/images/login-background.jpg differ diff --git a/Bimix.UI.Shared/wwwroot/js/auth.js b/Bimix.UI.Shared/wwwroot/js/auth.js new file mode 100644 index 0000000..3ed0254 --- /dev/null +++ b/Bimix.UI.Shared/wwwroot/js/auth.js @@ -0,0 +1,35 @@ +window.initGoogleSignIn = async function(clientId) { + try { + if (!clientId) { + throw new Error('ClientId is required'); + } + + // Inicjalizacja Google Sign-In z dynamicznym ClientId + google.accounts.id.initialize({ + client_id: clientId, + callback: handleGoogleResponse, + auto_select: false, + cancel_on_tap_outside: true + }); + + // Wyświetl popup logowania + google.accounts.id.prompt((notification) => { + if (notification.isNotDisplayed() || notification.isSkippedMoment()) { + console.log('Google Sign-In popup not displayed'); + } + }); + + } catch (error) { + console.error('Google Sign-In initialization error:', error); + DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInError', error.message); + } +}; + +function handleGoogleResponse(response) { + if (response.credential) { + // Token otrzymany - wyślij do Blazor + DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInSuccess', response.credential); + } else { + DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInError', 'No credential received'); + } +} diff --git a/Bimix.UI.Web/Components/App.razor b/Bimix.UI.Web/Components/App.razor index f7d4146..c0ae221 100644 --- a/Bimix.UI.Web/Components/App.razor +++ b/Bimix.UI.Web/Components/App.razor @@ -30,6 +30,9 @@ + + + \ No newline at end of file diff --git a/Bimix.UI.Web/Program.cs b/Bimix.UI.Web/Program.cs index 7636888..74290b3 100644 --- a/Bimix.UI.Web/Program.cs +++ b/Bimix.UI.Web/Program.cs @@ -1,6 +1,7 @@ using Bimix.UI.Shared; using Bimix.UI.Shared.Extensions; using Bimix.UI.Shared.Interfaces; +using Bimix.UI.Shared.Services; using Bimix.UI.Web.Components; using MudBlazor.Services; @@ -13,6 +14,8 @@ builder.Services.AddMudServices(); builder.Services.AddSharedServices("http://localhost:7142"); builder.Services.AddSingleton(); +builder.Services.AddScoped(); + var app = builder.Build(); diff --git a/Bimix.UI.Web/appsettings.Development.json b/Bimix.UI.Web/appsettings.Development.json index 74d2c36..8c786ed 100644 Binary files a/Bimix.UI.Web/appsettings.Development.json and b/Bimix.UI.Web/appsettings.Development.json differ