From 12fef7cbaf2073f9cc349ed765ea140be0259d8e Mon Sep 17 00:00:00 2001 From: Paweł Bernaciak Date: Sat, 21 Oct 2023 11:55:33 +0200 Subject: Basic API auth and Google login --- .gitignore | 5 + .../Elements.Backend/Controllers/AuthController.cs | 106 +++++++++++++++++++++ .../Elements.Backend/Controllers/UserController.cs | 41 ++++++++ .../Controllers/WeatherForecastController.cs | 32 ------- backend/Elements.Backend/Elements.Backend.csproj | 41 ++++---- backend/Elements.Backend/Program.cs | 53 ++++++++--- backend/Elements.Backend/WeatherForecast.cs | 12 --- backend/Elements.Data/ApplicationContextFactory.cs | 15 +++ backend/Elements.Data/ApplicationDbContext.cs | 9 +- backend/Elements.Data/Elements.Data.csproj | 5 + .../Migrations/20231020142907_Initial.Designer.cs | 80 ++++++++++++++++ .../Migrations/20231020142907_Initial.cs | 64 +++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 77 +++++++++++++++ backend/Elements.Data/Models/Element.cs | 13 ++- backend/Elements.Data/Models/User.cs | 9 ++ 15 files changed, 489 insertions(+), 73 deletions(-) create mode 100644 backend/Elements.Backend/Controllers/AuthController.cs create mode 100644 backend/Elements.Backend/Controllers/UserController.cs delete mode 100644 backend/Elements.Backend/Controllers/WeatherForecastController.cs delete mode 100644 backend/Elements.Backend/WeatherForecast.cs create mode 100644 backend/Elements.Data/ApplicationContextFactory.cs create mode 100644 backend/Elements.Data/Migrations/20231020142907_Initial.Designer.cs create mode 100644 backend/Elements.Data/Migrations/20231020142907_Initial.cs create mode 100644 backend/Elements.Data/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 backend/Elements.Data/Models/User.cs diff --git a/.gitignore b/.gitignore index efba808..a6686d0 100644 --- a/.gitignore +++ b/.gitignore @@ -466,3 +466,8 @@ sketch # End of https://www.toptal.com/developers/gitignore/api/macos,dotnetcore,aspnetcore,react,node +elements.db +elements.db-shm +elements.db-wal + + diff --git a/backend/Elements.Backend/Controllers/AuthController.cs b/backend/Elements.Backend/Controllers/AuthController.cs new file mode 100644 index 0000000..56e7c3b --- /dev/null +++ b/backend/Elements.Backend/Controllers/AuthController.cs @@ -0,0 +1,106 @@ +using System.Runtime.Serialization; +using System.Security.Claims; +using System.Text.Json; +using Elements.Data; +using Elements.Data.Models; +using Google.Apis.Auth; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Elements.Backend.Controllers; + +[ApiController] +[Route("[controller]/[action]")] +public class AuthController : ControllerBase +{ + private readonly IConfiguration _config; + private readonly ApplicationDbContext _dbContext; + + public AuthController(IConfiguration config, ApplicationDbContext dbContext) + { + _config = config; + _dbContext = dbContext; + } + + public class LoginModel + { + public required string GoogleToken { get; init; } + } + + [HttpPost] + public async Task Login([FromBody] LoginModel model) + { + GoogleJsonWebSignature.Payload? payload = await VerifyGoogleIdToken(model.GoogleToken); + if (payload == null) + return Unauthorized(); + + User? user = await _dbContext.Users.SingleOrDefaultAsync(u => u.GoogleId == payload.Subject); + if (user != null) + { + //Check if user's name changed and update if it did + if (user.Name != payload.Name) + user.Name = payload.Name; + } + else + { + user = new User() + { + Name = payload.Name, + GoogleId = payload.Subject, + Elements = new List() + }; + + await _dbContext.Users.AddAsync(user); + } + + await _dbContext.SaveChangesAsync(); + + List claims = new() + { + new Claim("id", user.Id.ToString()), + new Claim(ClaimTypes.Role, "User") + }; + ClaimsIdentity claimsIdentity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme); + + AuthenticationProperties authProperties = new() + { + IsPersistent = true, + AllowRefresh = true + }; + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(claimsIdentity), + authProperties); + + var response = new + { + Id = user.Id.ToString() + }; + + return Ok(JsonSerializer.Serialize(response)); + } + + [HttpPost] + public async Task Logout() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Ok(); + } + + private async Task VerifyGoogleIdToken(string token) + { + try + { + GoogleJsonWebSignature.Payload? payload = await GoogleJsonWebSignature.ValidateAsync(token); + return payload; + } + catch (InvalidJwtException) + { + return null; + } + } +} \ No newline at end of file diff --git a/backend/Elements.Backend/Controllers/UserController.cs b/backend/Elements.Backend/Controllers/UserController.cs new file mode 100644 index 0000000..bde93aa --- /dev/null +++ b/backend/Elements.Backend/Controllers/UserController.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using System.Text.Json; +using Elements.Data; +using Elements.Data.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Elements.Backend.Controllers; + +[ApiController] +[Route("[controller]/[action]")] +public class UserController: ControllerBase +{ + private readonly ApplicationDbContext _dbContext; + + public UserController(ApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + + [HttpGet] + [Authorize] + [Route("/user/{id:int}")] + public async Task Users(int id) + { + IEnumerable claims = User.Claims; + string? currentUserId = claims.FirstOrDefault(claim => claim.Type == "id")?.Value; + if (currentUserId == null) + return StatusCode(StatusCodes.Status500InternalServerError); + if (currentUserId != id.ToString()) + return Unauthorized(); + + User? user = await _dbContext.Users.FirstOrDefaultAsync(user => user.Id == id); + if (user == null) + return StatusCode(StatusCodes.Status500InternalServerError); + + string userJson = JsonSerializer.Serialize(user); + return Ok(userJson); + } +} \ No newline at end of file diff --git a/backend/Elements.Backend/Controllers/WeatherForecastController.cs b/backend/Elements.Backend/Controllers/WeatherForecastController.cs deleted file mode 100644 index 9a70e36..0000000 --- a/backend/Elements.Backend/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Elements.Backend.Controllers; - -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } -} diff --git a/backend/Elements.Backend/Elements.Backend.csproj b/backend/Elements.Backend/Elements.Backend.csproj index 0c664a6..787d048 100644 --- a/backend/Elements.Backend/Elements.Backend.csproj +++ b/backend/Elements.Backend/Elements.Backend.csproj @@ -1,18 +1,27 @@ - - - - net7.0 - enable - enable - - - - - - - + + + + net7.0 + enable + enable + dae266ca-b349-4b87-a992-5470f9dd635d + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + - - - + + + diff --git a/backend/Elements.Backend/Program.cs b/backend/Elements.Backend/Program.cs index 15eacee..50e5255 100644 --- a/backend/Elements.Backend/Program.cs +++ b/backend/Elements.Backend/Program.cs @@ -1,23 +1,54 @@ +using System.Text.Json; +using Elements.Data; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.EntityFrameworkCore; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. +builder.Services.AddRouting(options => options.LowercaseUrls = true); +builder.Services.AddControllers().AddJsonOptions(options => +{ + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; +}); -builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) +if (builder.Environment.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(); + builder.Services.AddDbContext(options => + { + options.UseSqlite("Data Source=elements.db"); + }); } +builder.Services +.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; +}) +.AddCookie(options => +{ + options.Events = new() + { + OnRedirectToLogin = (ctx) => + { + ctx.Response.StatusCode = 401; + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = (ctx) => + { + ctx.Response.StatusCode = 401; + return Task.CompletedTask; + } + + }; +}); + +var app = builder.Build(); + app.UseHttpsRedirection(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/backend/Elements.Backend/WeatherForecast.cs b/backend/Elements.Backend/WeatherForecast.cs deleted file mode 100644 index 7c8962e..0000000 --- a/backend/Elements.Backend/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Elements.Backend; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -} diff --git a/backend/Elements.Data/ApplicationContextFactory.cs b/backend/Elements.Data/ApplicationContextFactory.cs new file mode 100644 index 0000000..b8e4ebd --- /dev/null +++ b/backend/Elements.Data/ApplicationContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Elements.Data; + +public class ApplicationContextFactory : IDesignTimeDbContextFactory +{ + public ApplicationDbContext CreateDbContext(string[] args) + { + DbContextOptionsBuilder optionsBuilder = new(); + optionsBuilder.UseSqlite("Data Source=elements.db"); + + return new ApplicationDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/backend/Elements.Data/ApplicationDbContext.cs b/backend/Elements.Data/ApplicationDbContext.cs index 619719a..a912cc4 100644 --- a/backend/Elements.Data/ApplicationDbContext.cs +++ b/backend/Elements.Data/ApplicationDbContext.cs @@ -1,7 +1,14 @@ -using Microsoft.EntityFrameworkCore; +using Elements.Data.Models; +using Microsoft.EntityFrameworkCore; namespace Elements.Data; public class ApplicationDbContext : DbContext { + public DbSet Elements { get; set; } + public DbSet Users { get; set; } + public ApplicationDbContext(DbContextOptions options) : base(options) + { + + } } diff --git a/backend/Elements.Data/Elements.Data.csproj b/backend/Elements.Data/Elements.Data.csproj index 408fea2..2fb1f63 100644 --- a/backend/Elements.Data/Elements.Data.csproj +++ b/backend/Elements.Data/Elements.Data.csproj @@ -7,6 +7,11 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/backend/Elements.Data/Migrations/20231020142907_Initial.Designer.cs b/backend/Elements.Data/Migrations/20231020142907_Initial.Designer.cs new file mode 100644 index 0000000..1272d62 --- /dev/null +++ b/backend/Elements.Data/Migrations/20231020142907_Initial.Designer.cs @@ -0,0 +1,80 @@ +// +using Elements.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elements.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20231020142907_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.12"); + + modelBuilder.Entity("Elements.Data.Models.Element", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Elements"); + }); + + modelBuilder.Entity("Elements.Data.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GoogleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Elements.Data.Models.Element", b => + { + b.HasOne("Elements.Data.Models.User", null) + .WithMany("Elements") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Elements.Data.Models.User", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Elements.Data/Migrations/20231020142907_Initial.cs b/backend/Elements.Data/Migrations/20231020142907_Initial.cs new file mode 100644 index 0000000..8257e5b --- /dev/null +++ b/backend/Elements.Data/Migrations/20231020142907_Initial.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elements.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GoogleId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Elements", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + State = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Elements", x => x.Id); + table.ForeignKey( + name: "FK_Elements_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Elements_UserId", + table: "Elements", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Elements"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/backend/Elements.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/backend/Elements.Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..b713511 --- /dev/null +++ b/backend/Elements.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,77 @@ +// +using Elements.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elements.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.12"); + + modelBuilder.Entity("Elements.Data.Models.Element", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Elements"); + }); + + modelBuilder.Entity("Elements.Data.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GoogleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Elements.Data.Models.Element", b => + { + b.HasOne("Elements.Data.Models.User", null) + .WithMany("Elements") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Elements.Data.Models.User", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Elements.Data/Models/Element.cs b/backend/Elements.Data/Models/Element.cs index 2f602e5..08f25a8 100644 --- a/backend/Elements.Data/Models/Element.cs +++ b/backend/Elements.Data/Models/Element.cs @@ -1,5 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + namespace Elements.Data.Models; +public enum ElementState { + HasColor, + HasIcon +} + public class Element { - + public int Id {get; init;} + public required int UserId { get; init; } + public required string Name {get; init;} + public required ElementState State {get; init;} } \ No newline at end of file diff --git a/backend/Elements.Data/Models/User.cs b/backend/Elements.Data/Models/User.cs new file mode 100644 index 0000000..b44a1e2 --- /dev/null +++ b/backend/Elements.Data/Models/User.cs @@ -0,0 +1,9 @@ +namespace Elements.Data.Models; + +public class User +{ + public int Id { get; init; } + public required string GoogleId { get; init; } + public required string Name { get; set; } + public required ICollection Elements { get; set; } +} \ No newline at end of file -- cgit v1.2.3