From 825bd80ef0af837dc9f1b801e76978bb95f2b689 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 30 Nov 2025 15:19:49 +0100 Subject: [PATCH] Implemented Login workflow --- .../.idea/dictionaries/project.xml | 7 ++ .idea/.idea.SpotiParty/.idea/encodings.xml | 4 +- SpotiParty.AppHost/.aspire/settings.json | 3 + SpotiParty.AppHost/AppHost.cs | 9 ++- SpotiParty.AppHost/SpotiParty.AppHost.csproj | 1 + .../Components/Pages/CallbackPage.razor | 27 +++++++ .../Components/Pages/EnqueuePage.razor | 2 +- .../Components/Pages/EnqueuePage.razor.cs | 26 +++++-- .../Components/Pages/EnqueuePage.razor.css | 13 ---- .../Components/Pages/LoginPage.razor | 17 +++++ .../Components/Pages/NotFound.razor | 2 +- SpotiParty.Web/DatabaseContext.cs | 10 +++ .../20251130141048_Initial.Designer.cs | 51 +++++++++++++ .../Migrations/20251130141048_Initial.cs | 35 +++++++++ .../DatabaseContextModelSnapshot.cs | 48 ++++++++++++ SpotiParty.Web/Models/User.cs | 14 ++++ SpotiParty.Web/Program.cs | 12 ++- .../Services/AuthorizationHandler.cs | 75 +++++++++++-------- SpotiParty.Web/SpotiParty.Web.csproj | 6 ++ SpotiParty.Web/wwwroot/app.css | 18 +++++ SpotiParty.sln.DotSettings | 2 + SpotiParty.sln.DotSettings.user | 4 + 22 files changed, 330 insertions(+), 56 deletions(-) create mode 100644 .idea/.idea.SpotiParty/.idea/dictionaries/project.xml create mode 100644 SpotiParty.AppHost/.aspire/settings.json create mode 100644 SpotiParty.Web/Components/Pages/CallbackPage.razor create mode 100644 SpotiParty.Web/Components/Pages/LoginPage.razor create mode 100644 SpotiParty.Web/DatabaseContext.cs create mode 100644 SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs create mode 100644 SpotiParty.Web/Migrations/20251130141048_Initial.cs create mode 100644 SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs create mode 100644 SpotiParty.Web/Models/User.cs create mode 100644 SpotiParty.sln.DotSettings create mode 100644 SpotiParty.sln.DotSettings.user diff --git a/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml b/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml new file mode 100644 index 0000000..a1bee2d --- /dev/null +++ b/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml @@ -0,0 +1,7 @@ + + + + Spoti + + + \ No newline at end of file diff --git a/.idea/.idea.SpotiParty/.idea/encodings.xml b/.idea/.idea.SpotiParty/.idea/encodings.xml index df87cf9..180f21a 100644 --- a/.idea/.idea.SpotiParty/.idea/encodings.xml +++ b/.idea/.idea.SpotiParty/.idea/encodings.xml @@ -1,4 +1,6 @@ - + + + \ No newline at end of file diff --git a/SpotiParty.AppHost/.aspire/settings.json b/SpotiParty.AppHost/.aspire/settings.json new file mode 100644 index 0000000..b74b76a --- /dev/null +++ b/SpotiParty.AppHost/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../SpotiParty.AppHost.csproj" +} \ No newline at end of file diff --git a/SpotiParty.AppHost/AppHost.cs b/SpotiParty.AppHost/AppHost.cs index d7cf9c6..180f365 100644 --- a/SpotiParty.AppHost/AppHost.cs +++ b/SpotiParty.AppHost/AppHost.cs @@ -2,6 +2,13 @@ using Projects; var builder = DistributedApplication.CreateBuilder(args); -var web = builder.AddProject("web"); +var dbServer = builder.AddPostgres("database") + .WithDataVolume("database-data"); + +var database = dbServer.AddDatabase("SpotiParty"); + +var web = builder.AddProject("web") + .WithReference(database) + .WaitForStart(database); builder.Build().Run(); \ No newline at end of file diff --git a/SpotiParty.AppHost/SpotiParty.AppHost.csproj b/SpotiParty.AppHost/SpotiParty.AppHost.csproj index d217efc..6b1b894 100644 --- a/SpotiParty.AppHost/SpotiParty.AppHost.csproj +++ b/SpotiParty.AppHost/SpotiParty.AppHost.csproj @@ -12,6 +12,7 @@ + diff --git a/SpotiParty.Web/Components/Pages/CallbackPage.razor b/SpotiParty.Web/Components/Pages/CallbackPage.razor new file mode 100644 index 0000000..515e0b0 --- /dev/null +++ b/SpotiParty.Web/Components/Pages/CallbackPage.razor @@ -0,0 +1,27 @@ +@page "/callback" +@using SpotiParty.Web.Services +@inject NavigationManager Navigator +@inject AuthorizationHandler AuthHandler + +@code { + [Parameter, SupplyParameterFromQuery(Name = "error")] + public string? Error { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "code")] + public string? Code { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "state")] + public string? State { get; set; } + + protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + if (string.IsNullOrWhiteSpace(Code)) { + Navigator.NavigateTo("/login", forceLoad: true); + return; + } + + await AuthHandler.HandleCallback(Code); + Navigator.NavigateTo("/enqueue", forceLoad: true); + } +} \ No newline at end of file diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor b/SpotiParty.Web/Components/Pages/EnqueuePage.razor index 4aec937..5a95e06 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor @@ -1,4 +1,4 @@ -@page "/enqueue" +@page "/enqueue/{userid}" @using SpotiParty.Web.Components.Components @rendermode InteractiveServer diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs index 4033a6c..5d7b76d 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs @@ -4,7 +4,11 @@ using SpotiParty.Web.Services; namespace SpotiParty.Web.Components.Pages; -public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBase { +public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationManager navigator) : ComponentBase { + + [Parameter] + public string UserId { get; set; } = string.Empty; + private readonly int _currentYear = DateTime.Now.Year; private SpotifyClient _client = null!; @@ -17,7 +21,20 @@ public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBa protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - _client = await authHandler.ConfigureClient(); + + if (!Guid.TryParse(UserId, out var guid)) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + var client = await authHandler.ConfigureClient(guid); + + if (client is null) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + _client = client; } private async Task ExecuteSearch() { @@ -51,9 +68,8 @@ public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBa private async Task DialogAccept() { if (_selectedTrack is null || _isAdding) return; _isAdding = true; - /*var request = new PlayerAddToQueueRequest(_selectedTrack.Uri); - await _client.Player.AddToQueue(request);*/ - await Task.Delay(3000); //TODO: Simulate adding + var request = new PlayerAddToQueueRequest(_selectedTrack.Uri); + await _client.Player.AddToQueue(request); _isAdding = false; _success = true; diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css index 5fa30c7..3a1ded2 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css @@ -105,16 +105,3 @@ footer { background: var(--color-background-light) !important; } -.button-secondary { - padding: 0.6rem 1rem; - border: none; - border-radius: 5px; - background: var(--color-accent); - color: var(--color-text); - cursor: pointer; -} - -.button-secondary:hover { - background: #555555; -} - diff --git a/SpotiParty.Web/Components/Pages/LoginPage.razor b/SpotiParty.Web/Components/Pages/LoginPage.razor new file mode 100644 index 0000000..31adca1 --- /dev/null +++ b/SpotiParty.Web/Components/Pages/LoginPage.razor @@ -0,0 +1,17 @@ +@page "/login" +@using SpotiParty.Web.Services + +Mit Spotify einloggen + +@inject AuthorizationHandler AuthHandler + +@code { + + private Uri _uri = null!; + + protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + _uri = await AuthHandler.ConstructLoginUri(); + } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Components/Pages/NotFound.razor b/SpotiParty.Web/Components/Pages/NotFound.razor index 3a9526b..76193cc 100644 --- a/SpotiParty.Web/Components/Pages/NotFound.razor +++ b/SpotiParty.Web/Components/Pages/NotFound.razor @@ -6,7 +6,7 @@ protected override void OnInitialized() { base.OnInitialized(); - Navigator.NavigateTo("/enqueue", forceLoad: true); + Navigator.NavigateTo("/login", forceLoad: true); } } diff --git a/SpotiParty.Web/DatabaseContext.cs b/SpotiParty.Web/DatabaseContext.cs new file mode 100644 index 0000000..1887175 --- /dev/null +++ b/SpotiParty.Web/DatabaseContext.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore; +using SpotiParty.Web.Models; + +namespace SpotiParty.Web; + +public class DatabaseContext(DbContextOptions options) : DbContext(options) { + + public DbSet Users { get; set; } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs b/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs new file mode 100644 index 0000000..a6212ea --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs @@ -0,0 +1,51 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpotiParty.Web; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20251130141048_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpotiParty.Web.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SpotiParty.Web/Migrations/20251130141048_Initial.cs b/SpotiParty.Web/Migrations/20251130141048_Initial.cs new file mode 100644 index 0000000..e17be45 --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130141048_Initial.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + DisplayName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + RefreshToken = table.Column(type: "character varying(255)", maxLength: 255, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.UserId); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs new file mode 100644 index 0000000..715d899 --- /dev/null +++ b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs @@ -0,0 +1,48 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpotiParty.Web; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + [DbContext(typeof(DatabaseContext))] + partial class DatabaseContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpotiParty.Web.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SpotiParty.Web/Models/User.cs b/SpotiParty.Web/Models/User.cs new file mode 100644 index 0000000..2e4da79 --- /dev/null +++ b/SpotiParty.Web/Models/User.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace SpotiParty.Web.Models; + +public class User { + [Key] + public Guid UserId { get; init; } = Guid.CreateVersion7(); + + [MaxLength(255)] + public string DisplayName { get; init; } + + [MaxLength(255)] + public string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/SpotiParty.Web/Program.cs b/SpotiParty.Web/Program.cs index 3d59ae1..f0a16a0 100644 --- a/SpotiParty.Web/Program.cs +++ b/SpotiParty.Web/Program.cs @@ -1,3 +1,5 @@ +using Microsoft.EntityFrameworkCore; +using SpotiParty.Web; using SpotiParty.Web.Components; using SpotiParty.Web.Services; @@ -9,7 +11,8 @@ builder.Services.AddRazorComponents() builder.AddServiceDefaults(); -builder.Services.AddSingleton(); +builder.AddNpgsqlDbContext("SpotiParty"); +builder.Services.AddScoped(); var app = builder.Build(); @@ -20,10 +23,15 @@ if (!app.Environment.IsDevelopment()) { app.UseHsts(); } +await using (var scope = app.Services.CreateAsyncScope()) { + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); +} + app.MapDefaultEndpoints(); app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); -app.UseHttpsRedirection(); +//app.UseHttpsRedirection(); app.UseAntiforgery(); diff --git a/SpotiParty.Web/Services/AuthorizationHandler.cs b/SpotiParty.Web/Services/AuthorizationHandler.cs index 1127112..1cb8e80 100644 --- a/SpotiParty.Web/Services/AuthorizationHandler.cs +++ b/SpotiParty.Web/Services/AuthorizationHandler.cs @@ -1,46 +1,57 @@ -using System.Net.Http.Headers; -using System.Security.Authentication; -using System.Text; -using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Components; using SpotifyAPI.Web; +using SpotiParty.Web.Models; namespace SpotiParty.Web.Services; -public sealed class AuthorizationHandler { +public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseContext context) { - private AuthResponse? _token; - - private class AuthResponse { - [JsonPropertyName("access_token")] - public string AccessToken { get; set; } - - [JsonPropertyName("token_type")] - public string TokenType { get; set; } - - [JsonPropertyName("expires_in")] - public int ExpiresIn { get; set; } + private async Task<(string clientId, string clientSecret)> GetClientSecrets() { + var fileLines = await File.ReadAllLinesAsync(Path.Combine(Environment.CurrentDirectory, ".dev-token")); + return (fileLines[0], fileLines[1]); } - public async Task ConfigureClient() { - if (_token is null) { - var fileLines = await File.ReadAllLinesAsync(Path.Combine(Environment.CurrentDirectory, ".dev-token")); - var clientId = fileLines[0]; - var clientSecret = fileLines[1]; + public async Task ConfigureClient(Guid userId) { + var user = await context.Users.FindAsync(userId); + if (user is null) return null; + + var (clientId, clientSecret) = await GetClientSecrets(); + var request = new AuthorizationCodeRefreshRequest(clientId, clientSecret, user.RefreshToken); + var response = await new OAuthClient().RequestToken(request); + + return new SpotifyClient(response.AccessToken); + } - var basicAuthToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse($"Basic {basicAuthToken}"); + public async Task ConstructLoginUri() { + var (clientId, _) = await GetClientSecrets(); + var request = new LoginRequest( + new Uri(navigator.BaseUri + "callback"), + clientId, + LoginRequest.ResponseType.Code) { + Scope = [Scopes.UserReadPlaybackState, Scopes.UserModifyPlaybackState, Scopes.UserReadPrivate, Scopes.UserReadEmail] + }; - var response = await httpClient.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(new[] { - new KeyValuePair("grant_type", "client_credentials") - })); - response.EnsureSuccessStatusCode(); - _token = await response.Content.ReadFromJsonAsync(); + return request.ToUri(); + } - if (_token is null) throw new AuthenticationException("Spotify auth failed!"); - } + public async Task HandleCallback(string code) { + var (clientId, clientSecret) = await GetClientSecrets(); + var response = await new OAuthClient().RequestToken( + new AuthorizationCodeTokenRequest( + clientId, + clientSecret, + code, + new Uri(navigator.BaseUri + "callback"))); - return new SpotifyClient(_token.AccessToken, _token.TokenType); + var client = new SpotifyClient(response.AccessToken); + var spotiUser = await client.UserProfile.Current(); + var user = new User { + DisplayName = spotiUser.DisplayName, + RefreshToken = response.RefreshToken + }; + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); } } \ No newline at end of file diff --git a/SpotiParty.Web/SpotiParty.Web.csproj b/SpotiParty.Web/SpotiParty.Web.csproj index b092286..2e2e953 100644 --- a/SpotiParty.Web/SpotiParty.Web.csproj +++ b/SpotiParty.Web/SpotiParty.Web.csproj @@ -19,6 +19,12 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/SpotiParty.Web/wwwroot/app.css b/SpotiParty.Web/wwwroot/app.css index 08d4626..c5ea085 100644 --- a/SpotiParty.Web/wwwroot/app.css +++ b/SpotiParty.Web/wwwroot/app.css @@ -2,6 +2,7 @@ --color-primary: #1db954; --color-primary-dark: #17a74a; --color-accent: #333333; + --color-accent-dark: #555555; --color-background: #121212; --color-background-light: #181818; --color-text: #ffffff; @@ -38,3 +39,20 @@ body { background: var(--color-background); } } + +.button-secondary { + padding: 0.6rem 1rem; + border: none; + border-radius: 5px; + background: var(--color-accent); + color: var(--color-text); + cursor: pointer; + + &:hover { + background: var(--color-accent-dark); + } + + &:disabled { + background: var(--color-background); + } +} diff --git a/SpotiParty.sln.DotSettings b/SpotiParty.sln.DotSettings new file mode 100644 index 0000000..436523d --- /dev/null +++ b/SpotiParty.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/SpotiParty.sln.DotSettings.user b/SpotiParty.sln.DotSettings.user new file mode 100644 index 0000000..8696f24 --- /dev/null +++ b/SpotiParty.sln.DotSettings.user @@ -0,0 +1,4 @@ + + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file