diff --git a/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj.user b/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj.user index 1607fbc..8a6c2b4 100644 --- a/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj.user +++ b/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj.user @@ -3,5 +3,9 @@ RazorPageScaffolder root/Common/RazorPage + JobsJobsJobs + + + ProjectDebugger \ No newline at end of file diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor index 46b0700..82e4609 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor +++ b/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor @@ -1,3 +1,36 @@ @page "/citizen/authorized" +@inject HttpClient http +@inject NavigationManager nav +@inject AppState state -

@Message

+

@message

+ +@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\"?)"; + } + } +} diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor.cs b/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor.cs deleted file mode 100644 index 9f905f6..0000000 --- a/src/JobsJobsJobs/Client/Pages/Citizen/Authorized.razor.cs +++ /dev/null @@ -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 - { - /// - /// The message to be displayed to the user - /// - public string Message { get; set; } = "Logging you on with No Agenda Social..."; - - /// - /// HTTP client for performing API calls - /// - [Inject] - public HttpClient Http { get; init; } = default!; - - /// - /// Navigation manager for getting parameters from the URL - /// - [Inject] - public NavigationManager Navigation { get; set; } = default!; - - /// - /// Application state - /// - [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())!; - 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\"?)"; - } - } - } -} diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor index d9aaea6..33a7a87 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor +++ b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor @@ -1,9 +1,38 @@ @page "/citizen/dashboard" +@inject HttpClient http +@inject AppState state

Dashboard

Here's the dashboard, homie...

+@if (hasProfile != null) +{ +

Has profile? @hasProfile

+} + +@if (errorMessage != null) +{ +

@errorMessage

+} @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; + } + } + } } diff --git a/src/JobsJobsJobs/Client/ServerApi.cs b/src/JobsJobsJobs/Client/ServerApi.cs new file mode 100644 index 0000000..008f296 --- /dev/null +++ b/src/JobsJobsJobs/Client/ServerApi.cs @@ -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 +{ + /// + /// Functions used to access the API + /// + public static class ServerApi + { + /// + /// Create an API URL + /// + /// The URL to append to the API base URL + /// The full URL to be used in HTTP requests + private static string ApiUrl(string url) => $"/api/{url}"; + + /// + /// Create an HTTP request with an authorization header + /// + /// The current application state + /// The URL for the request (will be appended to the API root) + /// The request method (optional, defaults to GET) + /// A request with the header attached, ready for further manipulation + 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; + } + + /// + /// Log on a user with the authorization code received from No Agenda Social + /// + /// The HTTP client to use for server communication + /// The authorization code received from NAS + /// The log on details if successful, an error if not + public static async Task> LogOn(HttpClient http, string authCode) + { + try + { + var logOn = await http.GetFromJsonAsync(ApiUrl($"citizen/log-on/{authCode}")); + if (logOn == null) { + return Result.AsError( + "Unable to log on with No Agenda Social. This should never happen; contact @danieljsummers"); + } + return Result.AsOk(logOn); + } + catch (HttpRequestException ex) + { + return Result.AsError($"Unable to log on with No Agenda Social: {ex.Message}"); + } + } + + /// + /// Retrieve a citizen's profile + /// + /// The HTTP client to use for server communication + /// The current application state + /// The citizen's profile, null if it is not found, or an error message if one occurs + public static async Task> 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.AsOk(null), + _ when res.IsSuccessStatusCode => Result.AsOk(await res.Content.ReadFromJsonAsync()), + _ => Result.AsError(await res.Content.ReadAsStringAsync()), + }; + } + } +} diff --git a/src/JobsJobsJobs/Client/_Imports.razor b/src/JobsJobsJobs/Client/_Imports.razor index 9000c22..453a6ec 100644 --- a/src/JobsJobsJobs/Client/_Imports.razor +++ b/src/JobsJobsJobs/Client/_Imports.razor @@ -9,3 +9,4 @@ @using Microsoft.JSInterop @using JobsJobsJobs.Client @using JobsJobsJobs.Client.Shared +@using JobsJobsJobs.Shared diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs index d388b54..33e9b6f 100644 --- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs +++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs @@ -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] + /// + /// The database connection + /// + private readonly NpgsqlConnection db; + + public ProfileController(NpgsqlConnection dbConn) + { + db = dbConn; + } + + [Authorize] + [HttpGet("")] public async Task Get() { - return null; + await db.OpenAsync(); + var profile = await db.FindProfileByCitizen( + CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value)); + return profile == null ? NoContent() : Ok(profile); } } } diff --git a/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs b/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs index f5bbda4..4d3f588 100644 --- a/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs @@ -13,11 +13,11 @@ namespace JobsJobsJobs.Server.Data public static class NpgsqlDataReaderExtensions { /// - /// Get a string by its name + /// Get a boolean by its name /// - /// The name of the field to be retrieved as a string - /// The specified field as a string - public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name)); + /// The name of the field to be retrieved as a boolean + /// The specified field as a boolean + public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name)); /// /// Get a 64-bit integer by its name @@ -33,5 +33,19 @@ namespace JobsJobsJobs.Server.Data /// The specified field as milliseconds public static Milliseconds GetMilliseconds(this NpgsqlDataReader rdr, string name) => new Milliseconds(rdr.GetInt64(name)); + + /// + /// Get a string by its name + /// + /// The name of the field to be retrieved as a string + /// The specified field as a string + public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name)); + + /// + /// Determine if a column is null + /// + /// The name of the column to check + /// True if the column is null, false if not + public static bool IsDBNull(this NpgsqlDataReader rdr, string name) => rdr.IsDBNull(rdr.GetOrdinal(name)); } } diff --git a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs new file mode 100644 index 0000000..b27d777 --- /dev/null +++ b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs @@ -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 +{ + /// + /// Extensions to the NpgsqlConnection type to support manipulation of profiles + /// + public static class ProfileExtensions + { + /// + /// Populate a profile object from the given data reader + /// + /// The data reader from which values should be obtained + /// The populated profile + 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")) + }; + } + + /// + /// Retrieve an employment profile by a citizen ID + /// + /// The ID of the citizen whose profile should be retrieved + /// The profile, or null if it does not exist + public static async Task 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; + } + } +} diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj index b5c0d9f..e18f2c4 100644 --- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj +++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj @@ -6,6 +6,7 @@ + diff --git a/src/JobsJobsJobs/Server/Startup.cs b/src/JobsJobsJobs/Server/Startup.cs index ede6aab..af3d54a 100644 --- a/src/JobsJobsJobs/Server/Startup.cs +++ b/src/JobsJobsJobs/Server/Startup.cs @@ -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 => { diff --git a/src/JobsJobsJobs/Shared/Domain/ContinentId.cs b/src/JobsJobsJobs/Shared/Domain/ContinentId.cs index 7d210ab..c43c00c 100644 --- a/src/JobsJobsJobs/Shared/Domain/ContinentId.cs +++ b/src/JobsJobsJobs/Shared/Domain/ContinentId.cs @@ -12,5 +12,13 @@ namespace JobsJobsJobs.Shared /// /// A new continent ID public static async Task Create() => new ContinentId(await ShortId.Create()); + + /// + /// Attempt to create a continent ID from a string + /// + /// The prospective ID + /// The continent ID + /// If the string is not a valid continent ID + public static ContinentId Parse(string id) => new ContinentId(ShortId.Parse(id)); } }