Migrate auth/logon handler

This commit is contained in:
Daniel J. Summers 2021-07-07 22:23:45 -04:00
parent 28b116925b
commit 77f402d9dd
12 changed files with 272 additions and 20 deletions

View File

@ -8,6 +8,8 @@
<ItemGroup> <ItemGroup>
<Compile Include="Data.fs" /> <Compile Include="Data.fs" />
<Compile Include="Auth.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="App.fs" /> <Compile Include="App.fs" />
</ItemGroup> </ItemGroup>
@ -20,6 +22,7 @@
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" /> <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageReference Include="Polly" Version="7.2.2" /> <PackageReference Include="Polly" Version="7.2.2" />
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" /> <PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.11.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,26 +1,30 @@
/// The main API application for Jobs, Jobs, Jobs /// The main API application for Jobs, Jobs, Jobs
module JobsJobsJobs.Api.App module JobsJobsJobs.Api.App
//open System
open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Hosting
open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting open Microsoft.Extensions.Hosting
open Giraffe open Giraffe
open Giraffe.EndpointRouting
/// All available routes for the application /// All available routes for the application
let webApp = let webApp = [
choose [ subRoute "/api" [
route "/ping" >=> text "pong" subRoute "/citizen" [
route "/" >=> htmlFile "/pages/index.html" GET [ routef "/log-on/%s" Handlers.Citizen.logOn ]
]
] ]
]
/// Configure the ASP.NET Core pipeline to use Giraffe /// Configure the ASP.NET Core pipeline to use Giraffe
let configureApp (app : IApplicationBuilder) = let configureApp (app : IApplicationBuilder) =
app.UseGiraffe webApp app
.UseRouting()
.UseEndpoints(fun e -> e.MapGiraffeEndpoints webApp)
|> ignore
open NodaTime open NodaTime
open RethinkDb.Driver.Net
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
@ -31,7 +35,7 @@ let configureServices (svc : IServiceCollection) =
svc.AddLogging () |> ignore svc.AddLogging () |> ignore
let svcs = svc.BuildServiceProvider() let svcs = svc.BuildServiceProvider()
let cfg = svcs.GetRequiredService<IConfiguration>().GetSection "Rethink" let cfg = svcs.GetRequiredService<IConfiguration>().GetSection "Rethink"
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger "Data.Startup" let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)
let conn = Data.Startup.createConnection cfg log let conn = Data.Startup.createConnection cfg log
svc.AddSingleton conn |> ignore svc.AddSingleton conn |> ignore
Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore

View File

@ -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
[<NoComparison; NoEquality; AllowNullLiteral>]
type MastodonAccount () =
/// The user name (what we store as naUser)
[<JsonPropertyName "username">]
member val Username = "" with get, set
/// The account name; will be the same as username for local (non-federated) accounts
[<JsonPropertyName "acct">]
member val AccountName = "" with get, set
/// The user's display name as it currently shows on No Agenda Social
[<JsonPropertyName "display_name">]
member val DisplayName = "" with get, set
/// The user's profile URL
[<JsonPropertyName "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<JsonDocument> (ReadOnlySpan<byte> 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<MastodonAccount>(ReadOnlySpan<byte> 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

View File

@ -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 /// Profile data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]

View File

@ -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
[<AutoOpen>]
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<IClock> ()
/// Get the application configuration from the request context
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
/// Get the logger factory from the request context
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
/// Get the RethinkDB connection from the request context
let conn (ctx : HttpContext) = ctx.GetService<IConnection> ()
/// 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
}

View File

@ -39,11 +39,15 @@ let main argv =
let host = createHostBuilder argv let host = createHostBuilder argv
let r = RethinkDb.Driver.RethinkDB.R let r = RethinkDb.Driver.RethinkDB.R
printfn "0) Connecting to databases..."
use db = host.Services.GetRequiredService<JobsDbContext> () use db = host.Services.GetRequiredService<JobsDbContext> ()
let conn = host.Services.GetRequiredService<IConnection> () let conn = host.Services.GetRequiredService<IConnection> ()
task { task {
// Migrate continents
printfn "1) Migrating continents..."
let mutable continentXref = Map.empty<string, Types.ContinentId> let mutable continentXref = Map.empty<string, Types.ContinentId>
let! continents = db.Continents.AsNoTracking().ToListAsync () let! continents = db.Continents.AsNoTracking().ToListAsync ()
let reContinents = let reContinents =
@ -59,7 +63,8 @@ let main argv =
|> List.ofSeq |> List.ofSeq
let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn
// Migrate citizens printfn "2) Migrating citizens..."
let mutable citizenXref = Map.empty<string, Types.CitizenId> let mutable citizenXref = Map.empty<string, Types.CitizenId>
let! citizens = db.Citizens.AsNoTracking().ToListAsync () let! citizens = db.Citizens.AsNoTracking().ToListAsync ()
let reCitizens = let reCitizens =
@ -79,7 +84,8 @@ let main argv =
it) it)
let! _ = r.Table(Table.Citizen).Insert(reCitizens).RunWriteAsync conn 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! profiles = db.Profiles.AsNoTracking().ToListAsync ()
let reProfiles = let reProfiles =
profiles profiles
@ -103,15 +109,16 @@ let main argv =
region = p.Region region = p.Region
remoteWork = p.RemoteWork remoteWork = p.RemoteWork
fullTime = p.FullTime fullTime = p.FullTime
biography = Types.Text (string p.Biography) biography = Types.Text p.Biography.Text
lastUpdatedOn = p.LastUpdatedOn 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 skills = reSkills
} }
it) it)
let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn
// Migrate success stories printfn "4) Migrating success stories..."
let! successes = db.Successes.AsNoTracking().ToListAsync () let! successes = db.Successes.AsNoTracking().ToListAsync ()
let reSuccesses = let reSuccesses =
successes successes
@ -122,7 +129,7 @@ let main argv =
recordedOn = s.RecordedOn recordedOn = s.RecordedOn
fromHere = s.FromHere fromHere = s.FromHere
source = "profile" 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) it)
let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn
@ -130,4 +137,6 @@ let main argv =
} }
|> awaitIgnore |> awaitIgnore
printfn "Migration complete"
0 0

1
src/JobsJobsJobs/Domain/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.js

View File

@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<Compile Include="Types.fs" /> <Compile Include="Types.fs" />
<Compile Include="Modules.fs" /> <Compile Include="Modules.fs" />
<Compile Include="SharedTypes.fs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -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
}

View File

@ -53,7 +53,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
if (form.Id == "new") if (form.Id == "new")
{ {
var story = new Success(await SuccessId.Create(), CurrentCitizenId, _clock.GetCurrentInstant(), 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)); string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story));
await _db.AddAsync(story); await _db.AddAsync(story);
} }

View File

@ -143,7 +143,7 @@ namespace JobsJobsJobs.Server.Data
.HasConversion(Converters.CitizenIdConverter); .HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired(); m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired();
m.Property(e => e.FromHere).HasColumnName("from_here").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") m.Property(e => e.Story).HasColumnName("story")
.HasConversion(Converters.OptionalMarkdownStringConverter); .HasConversion(Converters.OptionalMarkdownStringConverter);
}); });

View File

@ -10,6 +10,6 @@ namespace JobsJobsJobs.Shared
CitizenId CitizenId, CitizenId CitizenId,
Instant RecordedOn, Instant RecordedOn,
bool FromHere, bool FromHere,
string Source, // string Source,
MarkdownString? Story); MarkdownString? Story);
} }