From 77f402d9dda4eb8f405792800a1857f558e46fe6 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 7 Jul 2021 22:23:45 -0400 Subject: [PATCH] Migrate auth/logon handler --- src/JobsJobsJobs/Api/Api.fsproj | 3 + src/JobsJobsJobs/Api/App.fs | 20 ++-- src/JobsJobsJobs/Api/Auth.fs | 108 ++++++++++++++++++ src/JobsJobsJobs/Api/Data.fs | 11 ++ src/JobsJobsJobs/Api/Handlers.fs | 101 ++++++++++++++++ src/JobsJobsJobs/DataMigrate/Program.fs | 27 +++-- src/JobsJobsJobs/Domain/.gitignore | 1 + src/JobsJobsJobs/Domain/Domain.fsproj | 1 + src/JobsJobsJobs/Domain/SharedTypes.fs | 14 +++ .../Api/Controllers/SuccessController.cs | 2 +- src/JobsJobsJobs/Server/Data/JobsDbContext.cs | 2 +- src/JobsJobsJobs/Shared/Domain/Success.cs | 2 +- 12 files changed, 272 insertions(+), 20 deletions(-) create mode 100644 src/JobsJobsJobs/Api/Auth.fs create mode 100644 src/JobsJobsJobs/Api/Handlers.fs create mode 100644 src/JobsJobsJobs/Domain/.gitignore create mode 100644 src/JobsJobsJobs/Domain/SharedTypes.fs diff --git a/src/JobsJobsJobs/Api/Api.fsproj b/src/JobsJobsJobs/Api/Api.fsproj index 8cb0cc1..2070132 100644 --- a/src/JobsJobsJobs/Api/Api.fsproj +++ b/src/JobsJobsJobs/Api/Api.fsproj @@ -8,6 +8,8 @@ + + @@ -20,6 +22,7 @@ + diff --git a/src/JobsJobsJobs/Api/App.fs b/src/JobsJobsJobs/Api/App.fs index bbcbc72..a8307d9 100644 --- a/src/JobsJobsJobs/Api/App.fs +++ b/src/JobsJobsJobs/Api/App.fs @@ -1,26 +1,30 @@ /// The main API application for Jobs, Jobs, Jobs module JobsJobsJobs.Api.App -//open System open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Hosting open Giraffe +open Giraffe.EndpointRouting /// All available routes for the application -let webApp = - choose [ - route "/ping" >=> text "pong" - route "/" >=> htmlFile "/pages/index.html" +let webApp = [ + subRoute "/api" [ + subRoute "/citizen" [ + GET [ routef "/log-on/%s" Handlers.Citizen.logOn ] + ] ] + ] /// Configure the ASP.NET Core pipeline to use Giraffe let configureApp (app : IApplicationBuilder) = - app.UseGiraffe webApp + app + .UseRouting() + .UseEndpoints(fun e -> e.MapGiraffeEndpoints webApp) + |> ignore open NodaTime -open RethinkDb.Driver.Net open Microsoft.Extensions.Configuration open Microsoft.Extensions.Logging @@ -31,7 +35,7 @@ let configureServices (svc : IServiceCollection) = svc.AddLogging () |> ignore let svcs = svc.BuildServiceProvider() let cfg = svcs.GetRequiredService().GetSection "Rethink" - let log = svcs.GetRequiredService().CreateLogger "Data.Startup" + let log = svcs.GetRequiredService().CreateLogger (nameof Data.Startup) let conn = Data.Startup.createConnection cfg log svc.AddSingleton conn |> ignore Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore diff --git a/src/JobsJobsJobs/Api/Auth.fs b/src/JobsJobsJobs/Api/Auth.fs new file mode 100644 index 0000000..376d3bc --- /dev/null +++ b/src/JobsJobsJobs/Api/Auth.fs @@ -0,0 +1,108 @@ +/// Authorization / authentication functions +module JobsJobsJobs.Api.Auth + +open System.Text.Json.Serialization + +/// The variables we need from the account information we get from No Agenda Social +[] +type MastodonAccount () = + /// The user name (what we store as naUser) + [] + member val Username = "" with get, set + /// The account name; will be the same as username for local (non-federated) accounts + [] + member val AccountName = "" with get, set + /// The user's display name as it currently shows on No Agenda Social + [] + member val DisplayName = "" with get, set + /// The user's profile URL + [] + member val Url = "" with get, set + + +open FSharp.Control.Tasks +open Microsoft.Extensions.Configuration +open Microsoft.Extensions.Logging +open System +open System.Net.Http +open System.Net.Http.Headers +open System.Net.Http.Json +open System.Text.Json + +/// Verify the authorization code with Mastodon and get the user's profile +let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task { + + use http = new HttpClient() + + // Use authorization code to get an access token from NAS + use! codeResult = + http.PostAsJsonAsync("https://noagendasocial.com/oauth/token", + {| client_id = cfg.["ClientId"] + client_secret = cfg.["Secret"] + redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"] + grant_type = "authorization_code" + code = authCode + scope = "read" + |}) + match codeResult.IsSuccessStatusCode with + | true -> + let! responseBytes = codeResult.Content.ReadAsByteArrayAsync () + use tokenResponse = JsonSerializer.Deserialize (ReadOnlySpan responseBytes) + match tokenResponse with + | null -> + return Error "Could not parse authorization code result" + | _ -> + // Use access token to get profile from NAS + use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"]) + req.Headers.Authorization <- AuthenticationHeaderValue + ("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ()) + use! profileResult = http.SendAsync req + + match profileResult.IsSuccessStatusCode with + | true -> + let! profileBytes = profileResult.Content.ReadAsByteArrayAsync () + match JsonSerializer.Deserialize(ReadOnlySpan profileBytes) with + | null -> + return Error "Could not parse profile result" + | x when x.Username <> x.AccountName -> + return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}" + | profile -> + return Ok profile + | false -> + return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})" + | false -> + let! err = codeResult.Content.ReadAsStringAsync () + log.LogError $"Could not get token result from Mastodon:\n {err}" + return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})" + + } + + +open JobsJobsJobs.Domain +open JobsJobsJobs.Domain.Types +open Microsoft.IdentityModel.Tokens +open System.IdentityModel.Tokens.Jwt +open System.Security.Claims +open System.Text + +/// Create a JSON Web Token for this citizen to use for further requests to this API +let createJwt (citizen : Citizen) (cfg : IConfigurationSection) = + + let tokenHandler = JwtSecurityTokenHandler () + let token = + tokenHandler.CreateToken ( + SecurityTokenDescriptor ( + Subject = ClaimsIdentity [| + Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.id) + Claim (ClaimTypes.Name, Citizen.name citizen) + |], + Expires = DateTime.UtcNow.AddHours 2., + Issuer = "https://noagendacareers.com", + Audience = "https://noagendacareers.com", + SigningCredentials = SigningCredentials ( + SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]), + SecurityAlgorithms.HmacSha256Signature) + ) + ) + tokenHandler.WriteToken token + diff --git a/src/JobsJobsJobs/Api/Data.fs b/src/JobsJobsJobs/Api/Data.fs index 6075914..de369a3 100644 --- a/src/JobsJobsJobs/Api/Data.fs +++ b/src/JobsJobsJobs/Api/Data.fs @@ -220,6 +220,17 @@ module Citizen = () } + /// Update the display name and last seen on date for a citizen + let logOnUpdate (citizen : Citizen) conn = task { + let! _ = + withReconn(conn).ExecuteAsync(fun () -> + r.Table(Table.Citizen) + .Get(citizen.id) + .Update(r.HashMap(nameof citizen.displayName, citizen.displayName) + .With(nameof citizen.lastSeenOn, citizen.lastSeenOn)) + .RunWriteAsync conn) + () + } /// Profile data access functions [] diff --git a/src/JobsJobsJobs/Api/Handlers.fs b/src/JobsJobsJobs/Api/Handlers.fs new file mode 100644 index 0000000..bc04b93 --- /dev/null +++ b/src/JobsJobsJobs/Api/Handlers.fs @@ -0,0 +1,101 @@ +/// Route handlers for Giraffe endpoints +module JobsJobsJobs.Api.Handlers + +open FSharp.Control.Tasks +open Giraffe +open JobsJobsJobs.Domain +open JobsJobsJobs.Domain.SharedTypes +open JobsJobsJobs.Domain.Types + +/// Helper functions +[] +module Helpers = + + open NodaTime + open Microsoft.AspNetCore.Http + open Microsoft.Extensions.Configuration + open Microsoft.Extensions.Logging + open RethinkDb.Driver.Net + + /// Get the NodaTime clock from the request context + let clock (ctx : HttpContext) = ctx.GetService () + + /// Get the application configuration from the request context + let config (ctx : HttpContext) = ctx.GetService () + + /// Get the logger factory from the request context + let logger (ctx : HttpContext) = ctx.GetService () + + /// Get the RethinkDB connection from the request context + let conn (ctx : HttpContext) = ctx.GetService () + + /// Return None if the string is null, empty, or whitespace; otherwise, return Some and the trimmed string + let noneIfEmpty x = + match (defaultArg (Option.ofObj x) "").Trim () with | "" -> None | it -> Some it + + +/// Handlers for error conditions +module Error = + + /// Handler that will return a status code 404 and the text "Not Found" + let notFound : HttpHandler = + fun next ctx -> + RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next ctx + + +/// Handler to return the files required for the Vue client app +module Vue = + + /// Handler that returns index.html (the Vue client app) + let app : HttpHandler = + fun next ctx -> + match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with + | true -> htmlFile "wwwroot/index.html" next ctx + | false -> Error.notFound next ctx + + +/// Handler for /api/citizen routes +module Citizen = + + // GET: /api/citizen/log-on/[code] + let logOn authCode : HttpHandler = + fun next ctx -> task { + // Step 1 - Verify with Mastodon + let cfg = (config ctx).GetSection "Auth" + let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) + + match! Auth.verifyWithMastodon authCode cfg log with + | Ok account -> + // Step 2 - Find / establish Jobs, Jobs, Jobs account + let now = (clock ctx).GetCurrentInstant () + let dbConn = conn ctx + let! citizen = task { + match! Data.Citizen.findByNaUser account.Username dbConn with + | None -> + let it : Citizen = + { id = CitizenId.create () + naUser = account.Username + displayName = noneIfEmpty account.DisplayName + realName = None + profileUrl = account.Url + joinedOn = now + lastSeenOn = now + } + do! Data.Citizen.add it dbConn + return it + | Some citizen -> + let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now } + do! Data.Citizen.logOnUpdate it dbConn + return it + } + + // Step 3 - Generate JWT + return! + json + { jwt = Auth.createJwt citizen cfg + citizenId = CitizenId.toString citizen.id + name = Citizen.name citizen + } next ctx + | Error err -> + return! RequestErrors.BAD_REQUEST err next ctx + } diff --git a/src/JobsJobsJobs/DataMigrate/Program.fs b/src/JobsJobsJobs/DataMigrate/Program.fs index a11ea96..00e9273 100644 --- a/src/JobsJobsJobs/DataMigrate/Program.fs +++ b/src/JobsJobsJobs/DataMigrate/Program.fs @@ -33,17 +33,21 @@ let createHostBuilder argv = Startup.establishEnvironment cfg log conn |> awaitIgnore )) .Build() - + [] let main argv = let host = createHostBuilder argv let r = RethinkDb.Driver.RethinkDB.R + printfn "0) Connecting to databases..." + use db = host.Services.GetRequiredService () let conn = host.Services.GetRequiredService () task { - // Migrate continents + + printfn "1) Migrating continents..." + let mutable continentXref = Map.empty let! continents = db.Continents.AsNoTracking().ToListAsync () let reContinents = @@ -59,7 +63,8 @@ let main argv = |> List.ofSeq let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn - // Migrate citizens + printfn "2) Migrating citizens..." + let mutable citizenXref = Map.empty let! citizens = db.Citizens.AsNoTracking().ToListAsync () let reCitizens = @@ -79,7 +84,8 @@ let main argv = it) let! _ = r.Table(Table.Citizen).Insert(reCitizens).RunWriteAsync conn - // Migrate profile information (includes skills) + printfn "3) Migrating profiles and skills..." + let! profiles = db.Profiles.AsNoTracking().ToListAsync () let reProfiles = profiles @@ -103,15 +109,16 @@ let main argv = region = p.Region remoteWork = p.RemoteWork fullTime = p.FullTime - biography = Types.Text (string p.Biography) + biography = Types.Text p.Biography.Text lastUpdatedOn = p.LastUpdatedOn - experience = match p.Experience with null -> None | x -> (string >> Types.Text >> Some) x + experience = match p.Experience with null -> None | x -> (Types.Text >> Some) x.Text skills = reSkills } it) let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn - // Migrate success stories + printfn "4) Migrating success stories..." + let! successes = db.Successes.AsNoTracking().ToListAsync () let reSuccesses = successes @@ -122,7 +129,7 @@ let main argv = recordedOn = s.RecordedOn fromHere = s.FromHere source = "profile" - story = match s.Story with null -> None | x -> (string >> Types.Text >> Some) x + story = match s.Story with null -> None | x -> (Types.Text >> Some) x.Text } it) let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn @@ -130,4 +137,6 @@ let main argv = } |> awaitIgnore - 0 \ No newline at end of file + printfn "Migration complete" + + 0 diff --git a/src/JobsJobsJobs/Domain/.gitignore b/src/JobsJobsJobs/Domain/.gitignore new file mode 100644 index 0000000..4c43fe6 --- /dev/null +++ b/src/JobsJobsJobs/Domain/.gitignore @@ -0,0 +1 @@ +*.js \ No newline at end of file diff --git a/src/JobsJobsJobs/Domain/Domain.fsproj b/src/JobsJobsJobs/Domain/Domain.fsproj index ad1f7fe..48c3782 100644 --- a/src/JobsJobsJobs/Domain/Domain.fsproj +++ b/src/JobsJobsJobs/Domain/Domain.fsproj @@ -9,6 +9,7 @@ + diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs new file mode 100644 index 0000000..689aa45 --- /dev/null +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -0,0 +1,14 @@ +/// Types intended to be shared between the API and the client application +module JobsJobsJobs.Domain.SharedTypes + +// fsharplint:disable FieldNames + +/// A successful logon +type LogOnSuccess = { + /// The JSON Web Token (JWT) to use for API access + jwt : string + /// The ID of the logged-in citizen (as a string) + citizenId : string + /// The name of the logged-in citizen + name : string + } diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs index e63ef93..9921291 100644 --- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs +++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs @@ -53,7 +53,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers if (form.Id == "new") { var story = new Success(await SuccessId.Create(), CurrentCitizenId, _clock.GetCurrentInstant(), - form.FromHere, "profile", + form.FromHere, // "profile", string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story)); await _db.AddAsync(story); } diff --git a/src/JobsJobsJobs/Server/Data/JobsDbContext.cs b/src/JobsJobsJobs/Server/Data/JobsDbContext.cs index 7a4e490..3d96e0b 100644 --- a/src/JobsJobsJobs/Server/Data/JobsDbContext.cs +++ b/src/JobsJobsJobs/Server/Data/JobsDbContext.cs @@ -143,7 +143,7 @@ namespace JobsJobsJobs.Server.Data .HasConversion(Converters.CitizenIdConverter); m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired(); m.Property(e => e.FromHere).HasColumnName("from_here").IsRequired(); - m.Property(e => e.Source).HasColumnName("source").IsRequired().HasMaxLength(7); + // m.Property(e => e.Source).HasColumnName("source").IsRequired().HasMaxLength(7); m.Property(e => e.Story).HasColumnName("story") .HasConversion(Converters.OptionalMarkdownStringConverter); }); diff --git a/src/JobsJobsJobs/Shared/Domain/Success.cs b/src/JobsJobsJobs/Shared/Domain/Success.cs index e049b10..e8eed9c 100644 --- a/src/JobsJobsJobs/Shared/Domain/Success.cs +++ b/src/JobsJobsJobs/Shared/Domain/Success.cs @@ -10,6 +10,6 @@ namespace JobsJobsJobs.Shared CitizenId CitizenId, Instant RecordedOn, bool FromHere, - string Source, + // string Source, MarkdownString? Story); }