Add anti-CSRF; add settings page

This commit is contained in:
2022-04-18 01:05:06 -04:00
parent 1897095ff2
commit 8ce2d5a2ed
21 changed files with 260 additions and 969 deletions

View File

@@ -1,6 +1,7 @@
[<RequireQualifiedAccess>]
module MyWebLog.Handlers
open System.Collections.Generic
open DotLiquid
open Giraffe
open Microsoft.AspNetCore.Http
@@ -40,6 +41,7 @@ module Error =
[<AutoOpen>]
module private Helpers =
open Microsoft.AspNetCore.Antiforgery
open Microsoft.Extensions.DependencyInjection
open System.Collections.Concurrent
open System.IO
@@ -97,38 +99,162 @@ module private Helpers =
let webLogId ctx = (WebLogCache.getByCtx ctx).id
let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IConnection> ()
let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> ()
/// Get the cross-site request forgery token set
let csrfToken (ctx : HttpContext) =
(antiForgery ctx).GetAndStoreTokens ctx
/// Validate the cross-site request forgery token in the current request
let validateCsrf : HttpHandler = fun next ctx -> task {
match! (antiForgery ctx).IsRequestValidAsync ctx with
| true -> return! next ctx
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" next ctx
}
/// Require a user to be logged on
let requireUser = requiresAuthentication Error.notAuthorized
/// Handlers to manipulate admin functions
module Admin =
// GET /admin/
let dashboard : HttpHandler =
requiresAuthentication Error.notFound
>=> fun next ctx -> task {
let webLogId' = webLogId ctx
let conn' = conn ctx
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
let! posts = Data.Post.countByStatus Published |> getCount
let! drafts = Data.Post.countByStatus Draft |> getCount
let! pages = Data.Page.countAll |> getCount
let! listed = Data.Page.countListed |> getCount
let! cats = Data.Category.countAll |> getCount
let! topCats = Data.Category.countTopLevel |> getCount
return!
Hash.FromAnonymousObject
{| page_title = "Dashboard"
model =
{ posts = posts
drafts = drafts
pages = pages
listedPages = listed
categories = cats
topLevelCategories = topCats
}
|}
|> viewForTheme "admin" "dashboard" None next ctx
}
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
let webLogId' = webLogId ctx
let conn' = conn ctx
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
let! posts = Data.Post.countByStatus Published |> getCount
let! drafts = Data.Post.countByStatus Draft |> getCount
let! pages = Data.Page.countAll |> getCount
let! listed = Data.Page.countListed |> getCount
let! cats = Data.Category.countAll |> getCount
let! topCats = Data.Category.countTopLevel |> getCount
return!
Hash.FromAnonymousObject
{| page_title = "Dashboard"
model =
{ posts = posts
drafts = drafts
pages = pages
listedPages = listed
categories = cats
topLevelCategories = topCats
}
|}
|> viewForTheme "admin" "dashboard" None next ctx
}
// GET /admin/settings
let settings : HttpHandler = requireUser >=> fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let! allPages = Data.Page.findAll webLog.id (conn ctx)
return!
Hash.FromAnonymousObject
{| csrf = csrfToken ctx
model =
{ name = webLog.name
subtitle = defaultArg webLog.subtitle ""
defaultPage = webLog.defaultPage
postsPerPage = webLog.postsPerPage
timeZone = webLog.timeZone
}
pages =
seq {
KeyValuePair.Create ("posts", "- First Page of Posts -")
yield! allPages
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.id, p.title))
}
|> Array.ofSeq
web_log = webLog
page_title = "Web Log Settings"
|}
|> viewForTheme "admin" "settings" None next ctx
}
// POST /admin/settings
let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let conn' = conn ctx
let! model = ctx.BindFormAsync<SettingsModel> ()
match! Data.WebLog.findByHost (WebLogCache.getByCtx ctx).urlBase conn' with
| Some webLog ->
let updated =
{ webLog with
name = model.name
subtitle = match model.subtitle with "" -> None | it -> Some it
defaultPage = model.defaultPage
postsPerPage = model.postsPerPage
timeZone = model.timeZone
}
do! Data.WebLog.updateSettings updated conn'
// Update cache
WebLogCache.set updated.urlBase updated
// TODO: confirmation message
return! redirectTo false "/admin/" next ctx
| None -> return! Error.notFound next ctx
}
/// Handlers to manipulate posts
module Post =
// GET /page/{pageNbr}
let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
let hash = Hash.FromAnonymousObject {| posts = posts |}
let title =
match pageNbr, webLog.defaultPage with
| 1, "posts" -> None
| _, "posts" -> Some $"Page {pageNbr}"
| _, _ -> Some $"Page {pageNbr} &#xab; Posts"
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
return! themedView "index" None next ctx hash
}
// GET /
let home : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
match webLog.defaultPage with
| "posts" -> return! pageOfPosts 1 next ctx
| pageId ->
match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with
| Some page ->
return!
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|> themedView "single-page" page.template next ctx
| None -> return! Error.notFound next ctx
}
// GET *
let catchAll (link : string) : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let conn' = conn ctx
let permalink = Permalink link
match! Data.Post.findByPermalink permalink webLog.id conn' with
| Some post -> return! Error.notFound next ctx
// TODO: return via single-post action
| None ->
match! Data.Page.findByPermalink permalink webLog.id conn' with
| Some page ->
return!
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|> themedView "single-page" page.template next ctx
| None ->
// TOOD: search prior permalinks for posts and pages
// We tried, we really tried...
Console.Write($"Returning 404 for permalink |{permalink}|");
return! Error.notFound next ctx
}
/// Handlers to manipulate users
module User =
open Microsoft.AspNetCore.Authentication;
@@ -146,12 +272,12 @@ module User =
// GET /user/log-on
let logOn : HttpHandler = fun next ctx -> task {
return!
Hash.FromAnonymousObject {| page_title = "Log On" |}
Hash.FromAnonymousObject {| page_title = "Log On"; csrf = (csrfToken ctx) |}
|> viewForTheme "admin" "log-on" None next ctx
}
// POST /user/log-on
let doLogOn : HttpHandler = fun next ctx -> task {
let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<LogOnModel> ()
match! Data.WebLogUser.findByEmail model.emailAddress (webLogId ctx) (conn ctx) with
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
@@ -181,47 +307,27 @@ module User =
return! redirectTo false "/" next ctx
}
module CatchAll =
// GET /
let home : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
match webLog.defaultPage with
| "posts" ->
// TODO: page of posts
return! Error.notFound next ctx
| pageId ->
match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with
| Some page ->
return!
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|> themedView "single-page" page.template next ctx
| None -> return! Error.notFound next ctx
}
let catchAll : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let pageId = PageId webLog.defaultPage
match! Data.Page.findById pageId webLog.id (conn ctx) with
| Some page ->
return!
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|> themedView "single-page" page.template next ctx
| None -> return! Error.notFound next ctx
}
open Giraffe.EndpointRouting
/// The endpoints defined in the above handlers
let endpoints = [
GET [
route "/" CatchAll.home
route "/" Post.home
]
subRoute "/admin" [
GET [
route "/" Admin.dashboard
route "/" Admin.dashboard
route "/settings" Admin.settings
]
POST [
route "/settings" Admin.saveSettings
]
]
subRoute "/page" [
GET [
routef "/%d" Post.pageOfPosts
]
]
subRoute "/user" [

View File

@@ -1,13 +1,7 @@
open Giraffe.EndpointRouting
open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Builder
open System.Collections.Generic
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open MyWebLog
open RethinkDb.Driver.FSharp
open RethinkDb.Driver.Net
open System
@@ -103,8 +97,17 @@ let initDb args sp = task {
return! System.Threading.Tasks.Task.CompletedTask
}
open DotLiquid
open Giraffe
open Giraffe.EndpointRouting
open Microsoft.AspNetCore.Antiforgery
open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging
open MyWebLog.ViewModels
open RethinkDb.Driver.FSharp
[<EntryPoint>]
let main args =
@@ -118,7 +121,9 @@ let main args =
opts.SlidingExpiration <- true
opts.AccessDeniedPath <- "/forbidden")
let _ = builder.Services.AddLogging ()
let _ = builder.Services.AddAuthorization()
let _ = builder.Services.AddAuthorization ()
let _ = builder.Services.AddAntiforgery ()
let _ = builder.Services.AddGiraffe ()
// Configure RethinkDB's connection
JsonConverters.all () |> Seq.iter Converter.Serializer.Converters.Add
@@ -139,6 +144,11 @@ let main args =
Template.RegisterSafeType (typeof<Page>, all)
Template.RegisterSafeType (typeof<WebLog>, all)
Template.RegisterSafeType (typeof<DashboardModel>, all)
Template.RegisterSafeType (typeof<SettingsModel>, all)
Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all)
Template.RegisterSafeType (typeof<Option<_>>, all) // doesn't quite get the job done....
Template.RegisterSafeType (typeof<KeyValuePair>, all)
let app = builder.Build ()

View File

@@ -1,6 +1,7 @@
<h2 class="p-3 ">Log On to {{ web_log.name }}</h2>
<article class="pb-3">
<form action="/user/log-on" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container">
<div class="row pb-3">
<div class="col col-md-6 col-lg-4 offset-lg-2">

View File

@@ -0,0 +1,55 @@
<article class="pt-3">
<form action="/admin/settings" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container">
<div class="row">
<div class="col-12 col-md-6 col-xl-4 offset-xl-2 pb-3">
<div class="form-floating">
<input type="text" name="name" id="name" class="form-control" value="{{ model.name }}" required autofocus>
<label for="name">Name</label>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="subtitle" id="subtitle" class="form-control" value="{{ model.subtitle }}">
<label for="subtitle">Subtitle</label>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-md-4 col-xl-2 offset-xl-2 pb-3">
<div class="form-floating">
<input type="number" name="postsPerPage" id="postsPerPage" class="form-control" min="0" max="50" required
value="{{ model.posts_per_page }}">
<label for="postsPerPage">Posts per Page</label>
</div>
</div>
<div class="col-12 col-md-4 col-xl-3 pb-3">
<div class="form-floating">
<input type="text" name="timeZone" id="timeZone" class="form-control" required
value="{{ model.time_zone }}">
<label for="timeZone">Time Zone</label>
</div>
</div>
<div class="col-12 col-md-4 col-xl-3 pb-3">
<div class="form-floating">
<select name="defaultPage" id="defaultPage" class="form-control" required>
{% for pg in pages -%}
<option value="{{ pg[0] }}"
{%- if pg[0] == model.default_page %} selected="selected"{% endif %}>
{{ pg[1] }}
</option>
{%- endfor %}
</select>
<label for="defaultPage">Default Page</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</form>
</article>