Implement Google authentication (for Web) and user management system

This commit is contained in:
Michał Zieliński
2025-07-19 22:50:38 +02:00
parent b673fd2da3
commit 14c61ca1ee
25 changed files with 1072 additions and 41 deletions

View File

@@ -7,16 +7,18 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Google.Apis.Auth" Version="1.70.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.17" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.17" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.17"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.17">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Bimix.Domain\Bimix.Domain.csproj" />
<ProjectReference Include="..\Bimix.Application\Bimix.Application.csproj" /> <ProjectReference Include="..\Bimix.Application\Bimix.Application.csproj" />
<ProjectReference Include="..\Bimix.Infrastructure\Bimix.Infrastructure.csproj" /> <ProjectReference Include="..\Bimix.Infrastructure\Bimix.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -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<AuthController> logger)
: ControllerBase
{
[HttpPost("google")]
public async Task<IActionResult> 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,
});
}
}

View File

@@ -1,6 +1,11 @@
using System.Text;
using Bimix.API.Services;
using Bimix.Infrastructure.Data; using Bimix.Infrastructure.Data;
using Bimix.Infrastructure.Sync; using Bimix.Infrastructure.Sync;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -13,6 +18,43 @@ builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); 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<GoogleAuthService>();
builder.Services.AddScoped<JwtTokenService>();
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// End auth section
var app = builder.Build(); var app = builder.Build();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -22,6 +64,8 @@ if (app.Environment.IsDevelopment())
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseCors("AllowAll");
app.UseAuthorization();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View File

@@ -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<GoogleAuthService> logger)
{
private readonly BimixDbContext _context = context;
private readonly IConfiguration _configuration = configuration;
private readonly ILogger<GoogleAuthService> _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");
}
}
}

View File

@@ -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<JwtTokenService> logger)
{
private readonly IConfiguration _configuration = configuration;
private readonly ILogger<JwtTokenService> _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;
}
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -7,17 +7,84 @@ public class BimixDbContext(DbContextOptions<BimixDbContext> options) : DbContex
{ {
public DbSet<Product> Products { get; set; } public DbSet<Product> Products { get; set; }
public DbSet<SyncState> SyncStates { get; set; } public DbSet<SyncState> SyncStates { get; set; }
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
// Product properties
modelBuilder.Entity<Product>().HasKey(x => x.Id); modelBuilder.Entity<Product>().HasKey(x => x.Id);
modelBuilder.Entity<Product>().Property(x => x.Name).IsRequired().HasMaxLength(512); modelBuilder.Entity<Product>().Property(x => x.Name).IsRequired().HasMaxLength(512);
modelBuilder.Entity<Product>().Property(x => x.Code).IsRequired().HasMaxLength(40); modelBuilder.Entity<Product>().Property(x => x.Code).IsRequired().HasMaxLength(40);
modelBuilder.Entity<Product>().Property(x => x.Ean).IsRequired().HasMaxLength(50); modelBuilder.Entity<Product>().Property(x => x.Ean).IsRequired().HasMaxLength(50);
modelBuilder.Entity<Product>().Property(x => x.StockAddresses).IsRequired().HasMaxLength(512); modelBuilder.Entity<Product>().Property(x => x.StockAddresses).IsRequired().HasMaxLength(512);
// SyncState properties
modelBuilder.Entity<SyncState>().HasKey((x => x.Entity)); modelBuilder.Entity<SyncState>().HasKey((x => x.Entity));
// User properties
modelBuilder.Entity<User>().HasKey(x => x.Id);
modelBuilder.Entity<User>().Property(x => x.GoogleId).IsRequired().HasMaxLength(100);
modelBuilder.Entity<User>().Property(x => x.Email).IsRequired().HasMaxLength(255);
modelBuilder.Entity<User>().Property(x => x.FullName).IsRequired().HasMaxLength(255);
modelBuilder.Entity<User>().Property(x => x.IsActive).IsRequired().HasDefaultValue(false);
modelBuilder.Entity<User>().Property(x => x.LastLoginAt).IsRequired(false);
// User indexes
modelBuilder.Entity<User>().HasIndex(x => x.GoogleId).IsUnique().HasDatabaseName("IX_Users_GoogleId");
modelBuilder.Entity<User>().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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
UpdateTimestamps();
return base.SaveChangesAsync(cancellationToken);
}
private void UpdateTimestamps()
{
var entities = ChangeTracker.Entries<BaseEntity>();
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;
}
}
} }
} }

View File

@@ -0,0 +1,136 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Ean")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("StockAddresses")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.HasKey("Id");
b.ToTable("Products");
});
modelBuilder.Entity("Bimix.Domain.Entities.SyncState", b =>
{
b.Property<string>("Entity")
.HasColumnType("nvarchar(450)");
b.Property<long>("LastSynced")
.HasColumnType("bigint");
b.HasKey("Entity");
b.ToTable("SyncStates");
});
modelBuilder.Entity("Bimix.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("GoogleId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<DateTime>("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
}
}
}

View File

@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bimix.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddUsersTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "Products",
type: "datetime2",
nullable: false,
defaultValueSql: "GETUTCDATE()",
oldClrType: typeof(DateTime),
oldType: "datetime2");
migrationBuilder.AlterColumn<DateTime>(
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<Guid>(type: "uniqueidentifier", nullable: false),
GoogleId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Email = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
FullName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
LastLoginAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
UpdatedAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "Products",
type: "datetime2",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "datetime2",
oldDefaultValueSql: "GETUTCDATE()");
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "Products",
type: "datetime2",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "datetime2",
oldDefaultValueSql: "GETUTCDATE()");
}
}
}

View File

@@ -34,7 +34,9 @@ namespace Bimix.Infrastructure.Migrations
.HasColumnType("nvarchar(40)"); .HasColumnType("nvarchar(40)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2"); .ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Ean") b.Property<string>("Ean")
.IsRequired() .IsRequired()
@@ -52,7 +54,9 @@ namespace Bimix.Infrastructure.Migrations
.HasColumnType("nvarchar(512)"); .HasColumnType("nvarchar(512)");
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2"); .ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.HasKey("Id"); b.HasKey("Id");
@@ -71,6 +75,58 @@ namespace Bimix.Infrastructure.Migrations
b.ToTable("SyncStates"); b.ToTable("SyncStates");
}); });
modelBuilder.Entity("Bimix.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("GoogleId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<DateTime>("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 #pragma warning restore 612, 618
} }
} }

View File

@@ -24,11 +24,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="Pages\ProductList.razor" /> <Folder Include="wwwroot\images\" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,43 @@
@using Bimix.UI.Shared.Services
@inject AuthService AuthService
@inject NavigationManager Navigation
@if (_isLoading)
{
<div class="d-flex justify-center align-center" style="height: 100vh;">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
</div>
}
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();
}
}
}

View File

@@ -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
<MudCard Class="login-card" Elevation="8">
<MudCardContent Class="pa-8 d-flex flex-column align-center">
<MudText Typo="Typo.h4" Class="mb-4">Witaj w Bimix</MudText>
<MudText Typo="Typo.body1" Class="mb-6 text-center">
Zaloguj się używając konta Google
</MudText>
<MudButton
Variant="Variant.Filled"
StartIcon="@Icons.Custom.Brands.Google"
Size="Size.Large"
OnClick="HandleGoogleSignIn"
Disabled="@_isLoading">
@if (_isLoading)
{
<MudProgressCircular Class="mr-3" Size="Size.Small" Indeterminate="true"></MudProgressCircular>
}
else
{
<span>Zaloguj z Google</span>
}
</MudButton>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mt-4">
@_errorMessage
</MudAlert>
}
</MudCardContent>
</MudCard>
@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());
}
}
}
<style>
.login-card {
max-width: 400px;
width: 100%;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
.google-signin-button {
width: 100%;
padding: 12px 24px;
}
</style>

View File

@@ -0,0 +1,8 @@
@inherits LayoutComponentBase
<MudThemeProvider/>
<MudDialogProvider/>
<MudSnackbarProvider/>
@Body

View File

@@ -1,41 +1,43 @@
@using MudBlazor @using MudBlazor
@inherits LayoutComponentBase @inherits LayoutComponentBase
<MudThemeProvider/> <AuthGuard>
<MudDialogProvider/> <MudThemeProvider/>
<MudSnackbarProvider/> <MudDialogProvider/>
<MudSnackbarProvider/>
<MudLayout> <MudLayout>
<MudBreakpointProvider OnBreakpointChanged="OnBreakpointChanged"></MudBreakpointProvider> <MudBreakpointProvider OnBreakpointChanged="OnBreakpointChanged"></MudBreakpointProvider>
<MudAppBar Elevation="0"> <MudAppBar Elevation="0">
<MudIconButton <MudIconButton
Icon="@Icons.Material.Filled.Menu" Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit" Color="Color.Inherit"
Edge="Edge.Start" Edge="Edge.Start"
OnClick="ToggleDrawer" OnClick="ToggleDrawer"
Class="mud-hidden-md-up"/> Class="mud-hidden-md-up"/>
<MudSpacer/> <MudSpacer/>
<MudText Typo="Typo.h6">Bimix</MudText> <MudText Typo="Typo.h6">Bimix</MudText>
</MudAppBar> </MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" <MudDrawer @bind-Open="_drawerOpen"
Anchor="Anchor.Start" Anchor="Anchor.Start"
Variant="@_drawerVariant" Variant="@_drawerVariant"
Elevation="1" Elevation="1"
ClipMode="DrawerClipMode.Always" ClipMode="DrawerClipMode.Always"
Class="mud-width-250"> Class="mud-width-250">
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/dashboard" Icon="@Icons.Material.Filled.Dashboard">Dashboard</MudNavLink> <MudNavLink Href="/dashboard" Icon="@Icons.Material.Filled.Dashboard">Dashboard</MudNavLink>
<MudNavLink Href="/products" Icon="@Icons.Material.Filled.Inventory">Products</MudNavLink> <MudNavLink Href="/products" Icon="@Icons.Material.Filled.Inventory">Products</MudNavLink>
</MudNavMenu> </MudNavMenu>
</MudDrawer> </MudDrawer>
<MudMainContent> <MudMainContent>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="my-4"> <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="my-4">
@Body @Body
</MudContainer> </MudContainer>
</MudMainContent> </MudMainContent>
</MudLayout> </MudLayout>
</AuthGuard>
@code { @code {
@@ -63,4 +65,5 @@
StateHasChanged(); StateHasChanged();
} }
} }

View File

@@ -0,0 +1,46 @@
@page "/login"
@layout EmptyLayout
<div class="login-page">
<div class="login-container">
<LoginCard />
</div>
</div>
<style>
html, body {
margin: 0 !important;
padding: 0 !important;
height: 100% !important;
overflow: hidden !important;
}
#app {
height: 100% !important;
}
.login-page {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url('_content/Bimix.UI.Shared/images/login-background.jpg') no-repeat center;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
padding: 20px;
width: 100%;
max-width: 450px;
}
</style>

View File

@@ -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<bool>? AuthenticationStateChanged;
public AuthService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public bool IsAuthenticated => _isAuthenticated ?? false;
public async Task<bool> CheckAuthenticationAsync()
{
try
{
var token = await _jsRuntime.InvokeAsync<string?>("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<string?> GetTokenAsync()
{
if (_isAuthenticated != true)
{
await CheckAuthenticationAsync();
}
if (_isAuthenticated != true) return null;
try
{
return await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "google_token");
}
catch
{
return null;
}
}
}

View File

@@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 KiB

View File

@@ -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');
}
}

View File

@@ -30,6 +30,9 @@
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<script src="_content/Bimix.UI.Shared/js/auth.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,7 @@
using Bimix.UI.Shared; using Bimix.UI.Shared;
using Bimix.UI.Shared.Extensions; using Bimix.UI.Shared.Extensions;
using Bimix.UI.Shared.Interfaces; using Bimix.UI.Shared.Interfaces;
using Bimix.UI.Shared.Services;
using Bimix.UI.Web.Components; using Bimix.UI.Web.Components;
using MudBlazor.Services; using MudBlazor.Services;
@@ -13,6 +14,8 @@ builder.Services.AddMudServices();
builder.Services.AddSharedServices("http://localhost:7142"); builder.Services.AddSharedServices("http://localhost:7142");
builder.Services.AddSingleton<IScannerService, NoOpScannerService>(); builder.Services.AddSingleton<IScannerService, NoOpScannerService>();
builder.Services.AddScoped<AuthService>();
var app = builder.Build(); var app = builder.Build();