Refactor ASP.NET Example to use AspNet.Security.OAuth.Spotify

This commit is contained in:
Jonas Dellinger 2019-11-19 10:37:42 +01:00
parent 9a6bd1b456
commit 58de9d9a4e
18 changed files with 140 additions and 309 deletions

View File

@ -1,15 +0,0 @@
using System.Linq;
using System.Security.Claims;
namespace SpotifyAPI.Web.Examples.ASP
{
public static class ClaimsExtensions
{
public static string GetSpecificClaim(this ClaimsIdentity claimsIdentity, string claimType)
{
Claim claim = claimsIdentity.Claims.FirstOrDefault(x => x.Type == claimType);
return claim != null ? claim.Value : string.Empty;
}
}
}

View File

@ -1,93 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using SpotifyAPI.Web.Auth;
using SpotifyAPI.Web.Enums;
using SpotifyAPI.Web.Examples.ASP.Models;
using SpotifyAPI.Web.Models;
using Microsoft.Extensions.Configuration;
namespace SpotifyAPI.Web.Examples.ASP.Controllers
{
public class Auth : Controller
{
public IConfiguration Configuration { get; set; }
public Auth(IConfiguration config)
{
Configuration = config;
}
private static Dictionary<string, AuthorizationCodeAuth> LoginRequests { get; } = new Dictionary<string, AuthorizationCodeAuth>();
// GET
public IActionResult Login()
{
string guid = Guid.NewGuid().ToString();
var auth = new AuthorizationCodeAuth( // TODO: Extract to own method
Configuration["spotify_client_id"], Configuration["spotify_client_secret"],
"http://localhost:5000/auth/callback", "", Scope.PlaylistReadPrivate | Scope.PlaylistReadPrivate, guid);
LoginRequests.Add(guid, auth); // TODO: Clean up after a timeout, else: DOS possible
return Redirect(auth.GetUri());
}
[HttpPost]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync();
return RedirectToAction("Index", "Home");
}
public async Task<IActionResult> Callback([FromQuery(Name = "code")] string code, [FromQuery(Name = "state")] string state,
[FromQuery(Name = "error")] string error)
{
if (error != null)
return View("Error", new ErrorViewModel { Message = error, RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier});
if(!LoginRequests.ContainsKey(state))
return View("Error", new ErrorViewModel { Message = "Unknown login request", RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier});
AuthorizationCodeAuth auth = LoginRequests[state];
Token token = await auth.ExchangeCode(code);
if (token.HasError())
{
return View("Error",
new ErrorViewModel
{
Message = $"Unable to exchange Code: ${token.Error}",
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier
});
}
var api = new SpotifyWebAPI
{
AccessToken = token.AccessToken,
TokenType = token.TokenType
};
PrivateProfile profile = await api.GetPrivateProfileAsync();
var claims = new List<Claim>
{
// TODO: Extract claim types to either Enum or class
new Claim("user_id", profile.Id),
new Claim("display_name", profile.DisplayName ?? profile.Id),
new Claim("access_token", token.AccessToken),
new Claim("token_type", token.TokenType),
new Claim("refresh_token", token.RefreshToken),
new Claim("expires_in", token.ExpiresIn.ToString(CultureInfo.InvariantCulture))
};
await HttpContext.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user_id", "")));
return Redirect("/");
}
}
}

View File

@ -1,54 +1,48 @@
using System; using System.Diagnostics;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SpotifyAPI.Web.Auth; using Microsoft.Extensions.Logging;
using SpotifyAPI.Web.Enums;
using SpotifyAPI.Web.Examples.ASP.Models; using SpotifyAPI.Web.Examples.ASP.Models;
namespace SpotifyAPI.Web.Examples.ASP.Controllers namespace SpotifyAPI.Web.Examples.ASP.Controllers
{ {
[Authorize]
public class HomeController : Controller public class HomeController : Controller
{ {
public async Task<ViewResult> Index() private readonly ILogger<HomeController> _logger;
{
var claimsIdent = User.Identity as ClaimsIdentity;
SpotifyWebAPI api = GetSpotifyApiFromUser(claimsIdent); public HomeController(ILogger<HomeController> logger)
if (api == null) {
return View(new HomeModel()); _logger = logger;
}
string userId = claimsIdent.GetSpecificClaim("user_id");
var playlists = await api.GetUserPlaylistsAsync(userId); public async Task<IActionResult> Index()
return View(new HomeModel {
if(!User.Identity.IsAuthenticated)
return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Spotify");
var accessToken = await HttpContext.GetTokenAsync("Spotify", "access_token");
SpotifyWebAPI api = new SpotifyWebAPI
{ {
Playlists = playlists AccessToken = accessToken,
}); TokenType = "Bearer"
};
var savedTracks = await api.GetSavedTracksAsync(50);
return View(new IndexModel { SavedTracks = savedTracks });
}
public IActionResult Privacy()
{
return View();
} }
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error() public IActionResult Error()
{ {
return View(new ErrorViewModel {RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier}); return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
private static SpotifyWebAPI GetSpotifyApiFromUser(ClaimsIdentity claimsIdent)
{
// TODO: Add expires_in logic
string accessToken = claimsIdent.GetSpecificClaim("access_token");
string tokenType = claimsIdent.GetSpecificClaim("token_type");
return new SpotifyWebAPI
{
AccessToken = accessToken,
TokenType = tokenType
};
} }
} }
} }

View File

@ -7,7 +7,5 @@ namespace SpotifyAPI.Web.Examples.ASP.Models
public string RequestId { get; set; } public string RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
public string Message { get; set; }
} }
} }

View File

@ -1,9 +0,0 @@
using SpotifyAPI.Web.Models;
namespace SpotifyAPI.Web.Examples.ASP.Models
{
public class HomeModel
{
public Paging<SimplePlaylist> Playlists;
}
}

View File

@ -0,0 +1,10 @@
using System;
using SpotifyAPI.Web.Models;
namespace SpotifyAPI.Web.Examples.ASP.Models
{
public class IndexModel
{
public Paging<SavedTrack> SavedTracks;
}
}

View File

@ -1,11 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace SpotifyAPI.Web.Examples.ASP namespace SpotifyAPI.Web.Examples.ASP
@ -14,11 +13,14 @@ namespace SpotifyAPI.Web.Examples.ASP
{ {
public static void Main(string[] args) public static void Main(string[] args)
{ {
CreateWebHostBuilder(args).Build().Run(); CreateHostBuilder(args).Build().Run();
} }
public static IWebHostBuilder CreateWebHostBuilder(string[] args) => public static IHostBuilder CreateHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args) Host.CreateDefaultBuilder(args)
.UseStartup<Startup>(); .ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
} }
} }

View File

@ -1,10 +1,10 @@
{ {
"iisSettings": { "iisSettings": {
"windowsAuthentication": false, "windowsAuthentication": false,
"anonymousAuthentication": true, "anonymousAuthentication": true,
"iisExpress": { "iisExpress": {
"applicationUrl": "http://localhost:7731", "applicationUrl": "http://localhost:55802",
"sslPort": 44310 "sslPort": 44320
} }
}, },
"profiles": { "profiles": {
@ -18,10 +18,10 @@
"SpotifyAPI.Web.Examples.ASP": { "SpotifyAPI.Web.Examples.ASP": {
"commandName": "Project", "commandName": "Project",
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000", "applicationUrl": "http://localhost:5000",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
} }
} }
} }

View File

@ -2,10 +2,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework> <TargetFramework>netcoreapp3.0</TargetFramework>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel> <UserSecretsId>201e6943-a5b3-473c-b1da-a579b36ca283</UserSecretsId>
<UserSecretsId>52b27bb1-dc8f-4151-8ad6-b6efdb8edbb7</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SpotifyAPI.Web.Auth\SpotifyAPI.Web.Auth.csproj" /> <ProjectReference Include="..\SpotifyAPI.Web\SpotifyAPI.Web.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.Spotify" Version="3.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,23 +1,15 @@
using System; using Microsoft.AspNetCore.Authentication.Cookies;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using EnvironmentName = Microsoft.AspNetCore.Hosting.EnvironmentName; using SpotifyAPI.Web.Enums;
namespace SpotifyAPI.Web.Examples.ASP namespace SpotifyAPI.Web.Examples.ASP
{ {
public class Startup public class Startup
{ {
private const string CookieScheme = "YourSchemeName";
public Startup(IConfiguration configuration) public Startup(IConfiguration configuration)
{ {
Configuration = configuration; Configuration = configuration;
@ -28,25 +20,24 @@ namespace SpotifyAPI.Web.Examples.ASP
// This method gets called by the runtime. Use this method to add services to the container. // This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
services.Configure<CookiePolicyOptions>(options => services.AddControllersWithViews();
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request. services.AddAuthentication(o => o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme)
options.CheckConsentNeeded = context => true; .AddCookie(options =>
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services
.AddAuthentication(CookieScheme) // Sets the default scheme to cookies
.AddCookie(CookieScheme, options =>
{ {
options.LoginPath = "/auth/login"; options.LoginPath = "/signin";
options.LogoutPath = "/signout";
})
.AddSpotify(options =>
{
var scopes = Scope.UserLibraryRead;
options.Scope.Add(scopes.GetStringAttribute(","));
options.SaveTokens = true;
options.ClientId = Configuration["client_id"];
options.ClientSecret = Configuration["client_secret"];
options.CallbackPath = "/callback";
}); });
services
.AddMvc(options => options.EnableEndpointRouting = false)
.SetCompatibilityVersion(CompatibilityVersion.Version_3_0);
} }
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@ -58,24 +49,24 @@ namespace SpotifyAPI.Web.Examples.ASP
} }
else else
{ {
app.UseHttpsRedirection();
app.UseExceptionHandler("/Home/Error"); app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts(); app.UseHsts();
app.UseHttpsRedirection();
} }
// app.UseHttpsRedirection();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseCookiePolicy();
app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseMvc(routes => app.UseAuthorization();
app.UseEndpoints(endpoints =>
{ {
routes.MapRoute( endpoints.MapControllerRoute(
name: "default", name: "default",
template: "{controller=Home}/{action=Index}/{id?}"); pattern: "{controller=Home}/{action=Index}/{id?}");
}); });
} }
} }
} }

View File

@ -1,39 +1,15 @@
 @model HomeModel @model IndexModel
@using System.Security.Claims @{
@using Microsoft.AspNetCore.Authentication
@{
ViewData["Title"] = "Home Page"; ViewData["Title"] = "Home Page";
} }
<h2>Some Playlists (first 20)</h2>
@if (Model.Playlists != null && !Model.Playlists.HasError())
{
<dl>
@foreach (var playlist in Model.Playlists.Items)
{
<dt>@playlist.Name</dt>
<dd>@(playlist.Owner.DisplayName ?? playlist.Owner.Id)</dd>
}
</dl>
}
<h2>HttpContext.User.Claims</h2> <div class="text-center">
You have @Model.SavedTracks.Total saved tracks in your library! Here are 50 of them:
<dl> <ul>
@foreach (Claim claim in User.Claims) @foreach (var item in Model.SavedTracks.Items)
{ {
<dt>@claim.Type</dt> <li>@item.Track.Name</li>
<dd>@claim.Value</dd> }
} </ul>
</dl> </div>
<h2>AuthenticationProperties</h2>
<dl>
@foreach (var prop in (await Context.AuthenticateAsync()).Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
</dl>

View File

@ -0,0 +1,6 @@
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View File

@ -9,7 +9,6 @@
@if (Model.ShowRequestId) @if (Model.ShowRequestId)
{ {
<p> <p>
<strong>Message:</strong> <code>@Model.Message</code>
<strong>Request ID:</strong> <code>@Model.RequestId</code> <strong>Request ID:</strong> <code>@Model.RequestId</code>
</p> </p>
} }

View File

@ -1,20 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - SpotifyAPI.Web.Examples.ASP</title> <title>@ViewData["Title"] - SpotifyAPI.Web.Examples.ASP</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<environment include="Development">
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
</environment>
<environment exclude="Development">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute"
crossorigin="anonymous"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"/>
</environment>
<link rel="stylesheet" href="~/css/site.css" /> <link rel="stylesheet" href="~/css/site.css" />
</head> </head>
<body> <body>
@ -31,14 +21,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li> </li>
@if (User.Identity.IsAuthenticated) <li class="nav-item">
{ <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
<li> </li>
<form class="form-inline" asp-controller="Auth" asp-action="Logout" method="post" >
<button type="submit" class="nav-link btn btn-link">Logout</button>
</form>
</li>
}
</ul> </ul>
</div> </div>
</div> </div>
@ -51,28 +36,13 @@
</div> </div>
<footer class="border-top footer text-muted"> <footer class="border-top footer text-muted">
<div class="container">
&copy; 2019 - SpotifyAPI.Web.Examples.ASP - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</div>
</footer> </footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<environment include="Development"> <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/lib/jquery/dist/jquery.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.js"></script>
</environment>
<environment exclude="Development">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"
asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
asp-fallback-test="window.jQuery"
crossorigin="anonymous"
integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=">
</script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.bundle.min.js"
asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"
asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"
crossorigin="anonymous"
integrity="sha384-xrRywqdh3PHs8keKZN+8zzc5TX0GRTLCcmivcbNJWm2rs5C8PRhcEn3czEjhAO9o">
</script>
</environment>
<script src="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true"></script>
@RenderSection("Scripts", required: false) @RenderSection("Scripts", required: false)
</body> </body>
</html> </html>

View File

@ -1,18 +1,2 @@
<environment include="Development"> <script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation/dist/jquery.validate.js"></script> <script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script>
</environment>
<environment exclude="Development">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.17.0/jquery.validate.min.js"
asp-fallback-src="~/lib/jquery-validation/dist/jquery.validate.min.js"
asp-fallback-test="window.jQuery && window.jQuery.validator"
crossorigin="anonymous"
integrity="sha256-F6h55Qw6sweK+t7SiOJX+2bpSAa3b/fnlrVCJvmEj1A=">
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.11/jquery.validate.unobtrusive.min.js"
asp-fallback-src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"
asp-fallback-test="window.jQuery && window.jQuery.validator && window.jQuery.validator.unobtrusive"
crossorigin="anonymous"
integrity="sha256-9GycpJnliUjJDVDqP0UEu/bsm9U+3dnQUH8+3W10vkY=">
</script>
</environment>

View File

@ -1,7 +1,9 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Warning" "Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*"

View File

@ -7,6 +7,23 @@ a.navbar-brand {
word-break: break-all; word-break: break-all;
} }
/* Provide sufficient contrast against white background */
a {
color: #0366d6;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
/* Sticky footer styles /* Sticky footer styles
-------------------------------------------------- */ -------------------------------------------------- */
html { html {
@ -50,7 +67,5 @@ body {
bottom: 0; bottom: 0;
width: 100%; width: 100%;
white-space: nowrap; white-space: nowrap;
/* Set the fixed height of the footer here */
height: 60px;
line-height: 60px; /* Vertically center the text there */ line-height: 60px; /* Vertically center the text there */
} }

View File

@ -138,14 +138,14 @@ small {
sub, sub,
sup { sup {
position: relative;
font-size: 75%; font-size: 75%;
line-height: 0; line-height: 0;
position: relative;
vertical-align: baseline; vertical-align: baseline;
} }
sub { sub {
bottom: -0.25em; bottom: -.25em;
} }
sup { sup {
@ -305,7 +305,6 @@ fieldset {
padding: 0; padding: 0;
margin: 0; margin: 0;
border: 0; border: 0;
padding: 0;
} }
legend { legend {