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,35 +1,42 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SpotifyAPI.Web.Auth;
using SpotifyAPI.Web.Enums;
using Microsoft.Extensions.Logging;
using SpotifyAPI.Web.Examples.ASP.Models;
namespace SpotifyAPI.Web.Examples.ASP.Controllers
{
[Authorize]
public class HomeController : Controller
{
public async Task<ViewResult> Index()
{
var claimsIdent = User.Identity as ClaimsIdentity;
private readonly ILogger<HomeController> _logger;
SpotifyWebAPI api = GetSpotifyApiFromUser(claimsIdent);
if (api == null)
return View(new HomeModel());
string userId = claimsIdent.GetSpecificClaim("user_id");
var playlists = await api.GetUserPlaylistsAsync(userId);
return View(new HomeModel
public HomeController(ILogger<HomeController> logger)
{
Playlists = playlists
});
_logger = logger;
}
public async Task<IActionResult> Index()
{
if(!User.Identity.IsAuthenticated)
return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Spotify");
var accessToken = await HttpContext.GetTokenAsync("Spotify", "access_token");
SpotifyWebAPI api = new SpotifyWebAPI
{
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)]
@ -37,18 +44,5 @@ namespace SpotifyAPI.Web.Examples.ASP.Controllers
{
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 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.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace SpotifyAPI.Web.Examples.ASP
@ -14,11 +13,14 @@ namespace SpotifyAPI.Web.Examples.ASP
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
CreateHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}

View File

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

View File

@ -2,10 +2,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
<UserSecretsId>52b27bb1-dc8f-4151-8ad6-b6efdb8edbb7</UserSecretsId>
<UserSecretsId>201e6943-a5b3-473c-b1da-a579b36ca283</UserSecretsId>
</PropertyGroup>
<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>
</Project>

View File

@ -1,23 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using EnvironmentName = Microsoft.AspNetCore.Hosting.EnvironmentName;
using SpotifyAPI.Web.Enums;
namespace SpotifyAPI.Web.Examples.ASP
{
public class Startup
{
private const string CookieScheme = "YourSchemeName";
public Startup(IConfiguration 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.
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
services.AddControllersWithViews();
services.AddAuthentication(o => o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services
.AddAuthentication(CookieScheme) // Sets the default scheme to cookies
.AddCookie(CookieScheme, options =>
options.LoginPath = "/signin";
options.LogoutPath = "/signout";
})
.AddSpotify(options =>
{
options.LoginPath = "/auth/login";
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.
@ -58,23 +49,23 @@ namespace SpotifyAPI.Web.Examples.ASP
}
else
{
app.UseHttpsRedirection();
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.
app.UseHsts();
app.UseHttpsRedirection();
}
// app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy();
app.UseRouting();
app.UseAuthentication();
app.UseMvc(routes =>
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
routes.MapRoute(
endpoints.MapControllerRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}

View File

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

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)
{
<p>
<strong>Message:</strong> <code>@Model.Message</code>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}

View File

@ -1,20 +1,10 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - SpotifyAPI.Web.Examples.ASP</title>
<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="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" />
</head>
<body>
@ -31,14 +21,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
@if (User.Identity.IsAuthenticated)
{
<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 class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
}
</ul>
</div>
</div>
@ -51,28 +36,13 @@
</div>
<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>
<environment include="Development">
<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="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@RenderSection("Scripts", required: false)
</body>
</html>

View File

@ -1,18 +1,2 @@
<environment include="Development">
<script src="~/lib/jquery-validation/dist/jquery.validate.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>
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

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

View File

@ -7,6 +7,23 @@ a.navbar-brand {
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
-------------------------------------------------- */
html {
@ -50,7 +67,5 @@ body {
bottom: 0;
width: 100%;
white-space: nowrap;
/* Set the fixed height of the footer here */
height: 60px;
line-height: 60px; /* Vertically center the text there */
}

View File

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