Migrate auth/logon handler
This commit is contained in:
parent
28b116925b
commit
77f402d9dd
@ -8,6 +8,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Auth.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="App.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -20,6 +22,7 @@
|
||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
||||
<PackageReference Include="Polly" Version="7.2.2" />
|
||||
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.11.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -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<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
|
||||
svc.AddSingleton conn |> ignore
|
||||
Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore
|
||||
|
108
src/JobsJobsJobs/Api/Auth.fs
Normal file
108
src/JobsJobsJobs/Api/Auth.fs
Normal 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
|
||||
|
@ -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
|
||||
[<RequireQualifiedAccess>]
|
||||
|
101
src/JobsJobsJobs/Api/Handlers.fs
Normal file
101
src/JobsJobsJobs/Api/Handlers.fs
Normal 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
|
||||
}
|
@ -33,17 +33,21 @@ let createHostBuilder argv =
|
||||
Startup.establishEnvironment cfg log conn |> awaitIgnore
|
||||
))
|
||||
.Build()
|
||||
|
||||
|
||||
[<EntryPoint>]
|
||||
let main argv =
|
||||
let host = createHostBuilder argv
|
||||
let r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
printfn "0) Connecting to databases..."
|
||||
|
||||
use db = host.Services.GetRequiredService<JobsDbContext> ()
|
||||
let conn = host.Services.GetRequiredService<IConnection> ()
|
||||
|
||||
task {
|
||||
// Migrate continents
|
||||
|
||||
printfn "1) Migrating continents..."
|
||||
|
||||
let mutable continentXref = Map.empty<string, Types.ContinentId>
|
||||
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<string, Types.CitizenId>
|
||||
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
|
||||
printfn "Migration complete"
|
||||
|
||||
0
|
||||
|
1
src/JobsJobsJobs/Domain/.gitignore
vendored
Normal file
1
src/JobsJobsJobs/Domain/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.js
|
@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<Compile Include="Types.fs" />
|
||||
<Compile Include="Modules.fs" />
|
||||
<Compile Include="SharedTypes.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
14
src/JobsJobsJobs/Domain/SharedTypes.fs
Normal file
14
src/JobsJobsJobs/Domain/SharedTypes.fs
Normal 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
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -10,6 +10,6 @@ namespace JobsJobsJobs.Shared
|
||||
CitizenId CitizenId,
|
||||
Instant RecordedOn,
|
||||
bool FromHere,
|
||||
string Source,
|
||||
// string Source,
|
||||
MarkdownString? Story);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user