Convert to Blazor #6

Merged
danieljsummers merged 7 commits from blazor into main 2020-12-19 02:46:28 +00:00
12 changed files with 277 additions and 79 deletions
Showing only changes of commit 404e314f97 - Show all commits

View File

@ -3,5 +3,9 @@
<PropertyGroup>
<RazorPage_SelectedScaffolderID>RazorPageScaffolder</RazorPage_SelectedScaffolderID>
<RazorPage_SelectedScaffolderCategoryPath>root/Common/RazorPage</RazorPage_SelectedScaffolderCategoryPath>
<ActiveDebugProfile>JobsJobsJobs</ActiveDebugProfile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
</PropertyGroup>
</Project>

View File

@ -1,3 +1,36 @@
@page "/citizen/authorized"
@inject HttpClient http
@inject NavigationManager nav
@inject AppState state
<p>@Message</p>
<p>@message</p>
@code {
string message = "Logging you on with No Agenda Social...";
protected override async Task OnInitializedAsync()
{
// Exchange authorization code for a JWT
var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
if (query.TryGetValue("code", out var authCode))
{
var logOnResult = await ServerApi.LogOn(http, authCode);
if (logOnResult.IsOk)
{
var logOn = logOnResult.Ok;
state.User = new UserInfo(logOn.CitizenId, logOn.Name);
state.Jwt = logOn.Jwt;
nav.NavigateTo("/citizen/dashboard");
}
else
{
message = logOnResult.Error;
}
}
else
{
message = "Did not receive a token from No Agenda Social (perhaps you clicked \"Cancel\"?)";
}
}
}

View File

@ -1,69 +0,0 @@
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Pages.Citizen
{
public partial class Authorized : ComponentBase
{
/// <summary>
/// The message to be displayed to the user
/// </summary>
public string Message { get; set; } = "Logging you on with No Agenda Social...";
/// <summary>
/// HTTP client for performing API calls
/// </summary>
[Inject]
public HttpClient Http { get; init; } = default!;
/// <summary>
/// Navigation manager for getting parameters from the URL
/// </summary>
[Inject]
public NavigationManager Navigation { get; set; } = default!;
/// <summary>
/// Application state
/// </summary>
[Inject]
public AppState State { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
// Exchange authorization code for a JWT
var query = QueryHelpers.ParseQuery(Navigation.ToAbsoluteUri(Navigation.Uri).Query);
if (query.TryGetValue("code", out var authCode))
{
var logOnResult = await Http.GetAsync($"api/citizen/log-on/{authCode}");
if (logOnResult != null)
{
if (logOnResult.IsSuccessStatusCode)
{
var logOn = (await logOnResult.Content.ReadFromJsonAsync<LogOnSuccess>())!;
State.User = new UserInfo(logOn.CitizenId, logOn.Name);
State.Jwt = logOn.Jwt;
Navigation.NavigateTo("/citizen/dashboard");
}
else
{
var errorMessage = await logOnResult.Content.ReadAsStringAsync();
Message = $"Unable to log on with No Agenda Social: {errorMessage}";
}
}
else
{
Message = "Unable to log on with No Agenda Social. This should never happen; contact @danieljsummers";
}
}
else
{
Message = "Did not receive a token from No Agenda Social (perhaps you clicked \"Cancel\"?)";
}
}
}
}

View File

@ -1,9 +1,38 @@
@page "/citizen/dashboard"
@inject HttpClient http
@inject AppState state
<h3>Dashboard</h3>
<p>Here's the dashboard, homie...</p>
@if (hasProfile != null)
{
<p>Has profile? @hasProfile</p>
}
@if (errorMessage != null)
{
<p>@errorMessage</p>
}
@code {
bool? hasProfile = null;
string? errorMessage = null;
protected override async Task OnInitializedAsync()
{
if (state.User != null)
{
var profile = await ServerApi.RetrieveProfile(http, state);
if (profile.IsOk)
{
hasProfile = profile.Ok != null;
}
else
{
errorMessage = profile.Error;
}
}
}
}

View File

@ -0,0 +1,81 @@
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client
{
/// <summary>
/// Functions used to access the API
/// </summary>
public static class ServerApi
{
/// <summary>
/// Create an API URL
/// </summary>
/// <param name="url">The URL to append to the API base URL</param>
/// <returns>The full URL to be used in HTTP requests</returns>
private static string ApiUrl(string url) => $"/api/{url}";
/// <summary>
/// Create an HTTP request with an authorization header
/// </summary>
/// <param name="state">The current application state</param>
/// <param name="url">The URL for the request (will be appended to the API root)</param>
/// <param name="method">The request method (optional, defaults to GET)</param>
/// <returns>A request with the header attached, ready for further manipulation</returns>
private static HttpRequestMessage WithHeader(AppState state, string url, HttpMethod? method = null)
{
var req = new HttpRequestMessage(method ?? HttpMethod.Get, ApiUrl(url));
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", state.Jwt);
return req;
}
/// <summary>
/// Log on a user with the authorization code received from No Agenda Social
/// </summary>
/// <param name="http">The HTTP client to use for server communication</param>
/// <param name="authCode">The authorization code received from NAS</param>
/// <returns>The log on details if successful, an error if not</returns>
public static async Task<Result<LogOnSuccess>> LogOn(HttpClient http, string authCode)
{
try
{
var logOn = await http.GetFromJsonAsync<LogOnSuccess>(ApiUrl($"citizen/log-on/{authCode}"));
if (logOn == null) {
return Result<LogOnSuccess>.AsError(
"Unable to log on with No Agenda Social. This should never happen; contact @danieljsummers");
}
return Result<LogOnSuccess>.AsOk(logOn);
}
catch (HttpRequestException ex)
{
return Result<LogOnSuccess>.AsError($"Unable to log on with No Agenda Social: {ex.Message}");
}
}
/// <summary>
/// Retrieve a citizen's profile
/// </summary>
/// <param name="http">The HTTP client to use for server communication</param>
/// <param name="state">The current application state</param>
/// <returns>The citizen's profile, null if it is not found, or an error message if one occurs</returns>
public static async Task<Result<Profile?>> RetrieveProfile(HttpClient http, AppState state)
{
var req = WithHeader(state, "profile/");
var res = await http.SendAsync(req);
return true switch
{
_ when res.StatusCode == HttpStatusCode.NoContent => Result<Profile?>.AsOk(null),
_ when res.IsSuccessStatusCode => Result<Profile?>.AsOk(await res.Content.ReadFromJsonAsync<Profile>()),
_ => Result<Profile?>.AsError(await res.Content.ReadAsStringAsync()),
};
}
}
}

View File

@ -9,3 +9,4 @@
@using Microsoft.JSInterop
@using JobsJobsJobs.Client
@using JobsJobsJobs.Client.Shared
@using JobsJobsJobs.Shared

View File

@ -1,8 +1,14 @@
using Microsoft.AspNetCore.Http;
using JobsJobsJobs.Server.Data;
using JobsJobsJobs.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Areas.Api.Controllers
@ -11,10 +17,24 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
[ApiController]
public class ProfileController : ControllerBase
{
[HttpGet]
/// <summary>
/// The database connection
/// </summary>
private readonly NpgsqlConnection db;
public ProfileController(NpgsqlConnection dbConn)
{
db = dbConn;
}
[Authorize]
[HttpGet("")]
public async Task<IActionResult> Get()
{
return null;
await db.OpenAsync();
var profile = await db.FindProfileByCitizen(
CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value));
return profile == null ? NoContent() : Ok(profile);
}
}
}

View File

@ -13,11 +13,11 @@ namespace JobsJobsJobs.Server.Data
public static class NpgsqlDataReaderExtensions
{
/// <summary>
/// Get a string by its name
/// Get a boolean by its name
/// </summary>
/// <param name="name">The name of the field to be retrieved as a string</param>
/// <returns>The specified field as a string</returns>
public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
/// <param name="name">The name of the field to be retrieved as a boolean</param>
/// <returns>The specified field as a boolean</returns>
public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name));
/// <summary>
/// Get a 64-bit integer by its name
@ -33,5 +33,19 @@ namespace JobsJobsJobs.Server.Data
/// <returns>The specified field as milliseconds</returns>
public static Milliseconds GetMilliseconds(this NpgsqlDataReader rdr, string name) =>
new Milliseconds(rdr.GetInt64(name));
/// <summary>
/// Get a string by its name
/// </summary>
/// <param name="name">The name of the field to be retrieved as a string</param>
/// <returns>The specified field as a string</returns>
public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
/// <summary>
/// Determine if a column is null
/// </summary>
/// <param name="name">The name of the column to check</param>
/// <returns>True if the column is null, false if not</returns>
public static bool IsDBNull(this NpgsqlDataReader rdr, string name) => rdr.IsDBNull(rdr.GetOrdinal(name));
}
}

View File

@ -0,0 +1,52 @@
using JobsJobsJobs.Shared;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Extensions to the NpgsqlConnection type to support manipulation of profiles
/// </summary>
public static class ProfileExtensions
{
/// <summary>
/// Populate a profile object from the given data reader
/// </summary>
/// <param name="rdr">The data reader from which values should be obtained</param>
/// <returns>The populated profile</returns>
private static Profile ToProfile(NpgsqlDataReader rdr)
{
var continentId = ContinentId.Parse(rdr.GetString("continent_id"));
return new Profile(CitizenId.Parse(rdr.GetString("id")), rdr.GetBoolean("seeking_employment"),
rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"),
rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")),
rdr.GetMilliseconds("last_updated_on"),
rdr.IsDBNull("experience") ? null : new MarkdownString(rdr.GetString("experience")))
{
Continent = new Continent(continentId, rdr.GetString("continent_name"))
};
}
/// <summary>
/// Retrieve an employment profile by a citizen ID
/// </summary>
/// <param name="citizen">The ID of the citizen whose profile should be retrieved</param>
/// <returns>The profile, or null if it does not exist</returns>
public static async Task<Profile?> FindProfileByCitizen(this NpgsqlConnection conn, CitizenId citizen)
{
using var cmd = conn.CreateCommand();
cmd.CommandText =
@"SELECT p.*, c.name AS continent_name
FROM profile p
INNER JOIN continent c ON p.continent_id = c.id
WHERE citizen_id = @id";
cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString()));
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null;
}
}
}

View File

@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.0" />
<PackageReference Include="Npgsql" Version="5.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.8.0" />

View File

@ -1,4 +1,5 @@
using JobsJobsJobs.Server.Data;
using JobsJobsJobs.Server.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
@ -6,8 +7,10 @@ using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using Npgsql;
using System.Linq;
using System.Text;
namespace JobsJobsJobs.Server
{
@ -25,8 +28,27 @@ namespace JobsJobsJobs.Server
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped(_ => new NpgsqlConnection(Configuration.GetConnectionString("JobsDb")));
services.AddLogging();
services.AddControllersWithViews();
services.AddRazorPages();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = "https://jobsjobs.jobs",
ValidIssuer = "https://jobsjobs.jobs",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
Configuration.GetSection("Auth")["ServerSecret"]))
};
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@ -43,7 +65,7 @@ namespace JobsJobsJobs.Server
// 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.UseBlazorFrameworkFiles();
app.UseStaticFiles();
@ -52,6 +74,8 @@ namespace JobsJobsJobs.Server
// Alternately, maybe we can even configure the default services and middleware so that it will work
// to give us the currently-logged-on user.
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{

View File

@ -12,5 +12,13 @@ namespace JobsJobsJobs.Shared
/// </summary>
/// <returns>A new continent ID</returns>
public static async Task<ContinentId> Create() => new ContinentId(await ShortId.Create());
/// <summary>
/// Attempt to create a continent ID from a string
/// </summary>
/// <param name="id">The prospective ID</param>
/// <returns>The continent ID</returns>
/// <exception cref="System.FormatException">If the string is not a valid continent ID</exception>
public static ContinentId Parse(string id) => new ContinentId(ShortId.Parse(id));
}
}