Sign in works and redirects!

Sign in now goes from Auth0, back to the app, which gets the user ID
from the response and redirects to the journal page.  Woot!
This commit is contained in:
Daniel J. Summers 2017-04-24 20:49:28 -05:00
parent d34302aa52
commit c98c7bd5bf
9 changed files with 175 additions and 64 deletions

View File

@ -3,6 +3,7 @@ module MyPrayerJournal.App
open Auth0.AuthenticationApi open Auth0.AuthenticationApi
open Auth0.AuthenticationApi.Models open Auth0.AuthenticationApi.Models
open Microsoft.EntityFrameworkCore
open Newtonsoft.Json open Newtonsoft.Json
open Newtonsoft.Json.Linq open Newtonsoft.Json.Linq
open Reader open Reader
@ -11,10 +12,20 @@ open System.IO
open Suave open Suave
open Suave.Filters open Suave.Filters
open Suave.Operators open Suave.Operators
open Suave.Redirection
open Suave.RequestErrors open Suave.RequestErrors
open Suave.State.CookieStateStore open Suave.State.CookieStateStore
open Suave.Successful open Suave.Successful
let utf8 = System.Text.Encoding.UTF8
type JsonNetCookieSerializer() =
interface CookieSerialiser with
member x.serialise m =
utf8.GetBytes (JsonConvert.SerializeObject m)
member x.deserialise m =
JsonConvert.DeserializeObject<Map<string, obj>> (utf8.GetString m)
type Auth0Config = { type Auth0Config = {
Domain : string Domain : string
ClientId : string ClientId : string
@ -27,15 +38,28 @@ with
ClientSecret = "" ClientSecret = ""
} }
let auth0 = type Config = {
Conn : string
Auth0 : Auth0Config
}
with
static member empty =
{ Conn = ""
Auth0 = Auth0Config.empty
}
let cfg =
try try
use sr = File.OpenText "appsettings.json" use sr = File.OpenText "appsettings.json"
let settings = JToken.ReadFrom(new JsonTextReader(sr)) :?> JObject let settings = JToken.ReadFrom(new JsonTextReader(sr)) :?> JObject
{ Domain = settings.["auth0"].["domain"].ToObject<string>() { Conn = settings.["conn"].ToObject<string>()
ClientId = settings.["auth0"].["client-id"].ToObject<string>() Auth0 =
ClientSecret = settings.["auth0"].["client-secret"].ToObject<string>() { Domain = settings.["auth0"].["domain"].ToObject<string>()
ClientId = settings.["auth0"].["client-id"].ToObject<string>()
ClientSecret = settings.["auth0"].["client-secret"].ToObject<string>()
}
} }
with _ -> Auth0Config.empty with _ -> Config.empty
/// Data Configuration singleton /// Data Configuration singleton
//let lazyCfg = lazy (DataConfig.FromJson <| try File.ReadAllText "data-config.json" with _ -> "{}") //let lazyCfg = lazy (DataConfig.FromJson <| try File.ReadAllText "data-config.json" with _ -> "{}")
@ -47,61 +71,135 @@ let auth0 =
// member __.Conn with get () = lazyConn.Force () // member __.Conn with get () = lazyConn.Force ()
// } // }
let auth code = context (fun ctx -> /// Get the scheme, host, and port of the URL
async { let schemeHostPort (req : HttpRequest) =
let client = AuthenticationApiClient(Uri(sprintf "https://%s" auth0.Domain)) sprintf "%s://%s" req.url.Scheme (req.headers |> List.filter (fun x -> fst x = "host") |> List.head |> snd)
let! req =
client.ExchangeCodeForAccessTokenAsync
(ExchangeCodeRequest
(AuthorizationCode = code,
ClientId = auth0.ClientId,
ClientSecret = auth0.ClientSecret,
RedirectUri = "http://localhost:8080/user/log-on"))
let! user = client.GetUserInfoAsync((req : AccessToken).AccessToken)
return
ctx
|> HttpContext.state
|> function
| None -> FORBIDDEN "Cannot sign in without state"
| Some state ->
state.set "auth-token" req.IdToken
>=> Writers.setUserData "user" user
}
|> Async.RunSynchronously
)
let viewHome = /// Authorization functions
Suave.Writers.setUserData "test" "howdy" module Auth =
>=> fun x -> OK (Views.page Views.home (string x.userState.["test"])) x
let handleSignIn = open Views
context (fun ctx ->
GET
>=> match ctx.request.queryParam "code" with
| Choice1Of2 authCode ->
auth authCode >=> OK (Views.page Views.home (Newtonsoft.Json.JsonConvert.SerializeObject(ctx.userState.["user"])))
| Choice2Of2 msg -> BAD_REQUEST msg
)
let session = statefulForSession let exchangeCodeForToken code = context (fun ctx ->
async {
let client = AuthenticationApiClient (Uri (sprintf "https://%s" cfg.Auth0.Domain))
let! req =
client.ExchangeCodeForAccessTokenAsync
(ExchangeCodeRequest
(AuthorizationCode = code,
ClientId = cfg.Auth0.ClientId,
ClientSecret = cfg.Auth0.ClientSecret,
RedirectUri = sprintf "%s/user/log-on" (schemeHostPort ctx.request)))
let! user = client.GetUserInfoAsync ((req : AccessToken).AccessToken)
return
ctx
|> HttpContext.state
|> function
| None -> FORBIDDEN "Cannot sign in without state"
| Some state ->
state.set "auth-token" req.IdToken
>=> Writers.setUserData "user" user
}
|> Async.RunSynchronously
)
/// Handle the sign-in callback from Auth0
let handleSignIn =
context (fun ctx ->
GET
>=> match ctx.request.queryParam "code" with
| Choice1Of2 authCode ->
exchangeCodeForToken authCode
>=> FOUND (sprintf "%s/journal" (schemeHostPort ctx.request))
| Choice2Of2 msg -> BAD_REQUEST msg
)
/// Handle signing out a user
let handleSignOut =
context (fun ctx ->
match ctx |> HttpContext.state with
| Some state -> state.set "auth-key" null
| _ -> succeed
>=> FOUND (sprintf "%s/" (schemeHostPort ctx.request)))
let cw (x : string) = Console.WriteLine x
/// Convert microtime to ticks, add difference from 1/1/1 to 1/1/1970
let jsDate jsTicks =
DateTime(jsTicks * 10000000L).AddTicks(DateTime(1970, 1, 1).Ticks)
let getIdFromToken token =
match token with
| Some jwt ->
try
let key = Convert.FromBase64String(cfg.Auth0.ClientSecret.Replace("-", "+").Replace("_", "/"))
let payload = Jose.JWT.Decode<JObject>(jwt, key)
let tokenExpires = jsDate (payload.["exp"].ToObject<int64>())
match tokenExpires > DateTime.UtcNow with
| true -> Some (payload.["sub"].ToObject<string>())
| _ -> None
with ex ->
sprintf "Token Deserialization Exception - %s" (ex.GetType().FullName) |> cw
sprintf "Message - %s" ex.Message |> cw
ex.StackTrace |> cw
None
| _ -> None
/// Add the logged on user Id to the context if it exists
let loggedOn = warbler (fun ctx ->
match ctx |> HttpContext.state with
| Some state -> Writers.setUserData "user" (state.get "auth-token" |> getIdFromToken)
| _ -> Writers.setUserData "user" None)
/// Create a user context for the currently assigned user
let userCtx ctx = { Id = ctx.userState.["user"] :?> string option }
/// Create a new data context
let dataCtx () =
new DataContext (((DbContextOptionsBuilder<DataContext>()).UseNpgsql cfg.Conn).Options)
/// Home page
let viewHome = warbler (fun ctx -> OK (Views.page (Auth.userCtx ctx) Views.home))
/// Journal page
let viewJournal = warbler (fun ctx -> OK (Views.page (Auth.userCtx ctx) Views.journal))
/// Suave application /// Suave application
let app = let app =
session statefulForSession
>=> Auth.loggedOn
>=> choose [ >=> choose [
path Route.home >=> viewHome path Route.home >=> viewHome
path Route.User.logOn >=> handleSignIn path Route.journal >=> viewJournal
path Route.User.logOn >=> Auth.handleSignIn
path Route.User.logOff >=> Auth.handleSignOut
Files.browseHome Files.browseHome
NOT_FOUND "Page not found." NOT_FOUND "Page not found."
] ]
let suaveCfg = { defaultConfig with homeFolder = Some (Path.GetFullPath "./wwwroot/") } /// Ensure the EF context is created in the right format
let ensureDatabase () =
async {
use data = dataCtx()
do! data.Database.MigrateAsync ()
}
|> Async.RunSynchronously
let suaveCfg =
{ defaultConfig with
homeFolder = Some (Path.GetFullPath "./wwwroot/")
serverKey = Text.Encoding.UTF8.GetBytes("12345678901234567890123456789012")
cookieSerialiser = new JsonNetCookieSerializer()
}
[<EntryPoint>] [<EntryPoint>]
let main argv = let main argv =
// Establish the data environment // Establish the data environment
//liftDep getConn (Data.establishEnvironment >> Async.RunSynchronously) //liftDep getConn (Data.establishEnvironment >> Async.RunSynchronously)
//|> run deps //|> run deps
ensureDatabase ()
startWebServer suaveCfg app startWebServer suaveCfg app
0 0
(*
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2Rqcy1jb25zdWx0aW5nLmF1dGgwLmNvbS8iLCJzdWIiOiJ3aW5kb3dzbGl2ZXw3OTMyNGZhMTM4MzZlZGNiIiwiYXVkIjoiT2YyczBSUUNRM210M2R3SWtPQlk1aDg1SjlzWGJGMm4iLCJleHAiOjE0OTI5MDc1OTAsImlhdCI6MTQ5Mjg3MTU5MH0.61JPm3Hz7XW-iaSq8Esv1cajQPbK0o9L5xz-RHIYq9g
*)

View File

@ -27,12 +27,6 @@ type DataContext =
/// History /// History
member this.History with get () = this.history and set v = this.history <- v member this.History with get () = this.history and set v = this.history <- v
override this.OnConfiguring (optionsBuilder) =
base.OnConfiguring optionsBuilder
optionsBuilder.UseNpgsql
"Host=severus-server;Database=mpj;Username=mpj;Password=devpassword;Application Name=myPrayerJournal"
|> ignore
override this.OnModelCreating (modelBuilder) = override this.OnModelCreating (modelBuilder) =
base.OnModelCreating modelBuilder base.OnModelCreating modelBuilder

View File

@ -14,7 +14,7 @@ type Request() =
/// The Id of the prayer request /// The Id of the prayer request
member val RequestId = Guid.Empty with get, set member val RequestId = Guid.Empty with get, set
/// The Id of the user to whom the request belongs /// The Id of the user to whom the request belongs
member val UserId = Guid.Empty with get, set member val UserId = "" with get, set
/// The ticks when the request was entered /// The ticks when the request was entered
member val EnteredOn = 0L with get, set member val EnteredOn = 0L with get, set

View File

@ -40,7 +40,7 @@ type InitialDb () =
(fun table -> (fun table ->
{ RequestId = table.Column<Guid>(nullable = false) { RequestId = table.Column<Guid>(nullable = false)
EnteredOn = table.Column<int64>(nullable = false) EnteredOn = table.Column<int64>(nullable = false)
UserId = table.Column<Guid>(nullable = false) UserId = table.Column<string>(nullable = false)
} }
), ),
constraints = constraints =

View File

@ -41,7 +41,7 @@ type DataContextModelSnapshot () =
|> ignore |> ignore
b.Property<int64>("EnteredOn") b.Property<int64>("EnteredOn")
|> ignore |> ignore
b.Property<Guid>("UserId") b.Property<string>("UserId")
|> ignore |> ignore
b.HasKey("RequestId") b.HasKey("RequestId")
|> ignore |> ignore

View File

@ -33,6 +33,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Auth0.AuthenticationApi" Version="3.6.0" /> <PackageReference Include="Auth0.AuthenticationApi" Version="3.6.0" />
<PackageReference Include="jose-jwt" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="1.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="1.0.0">
<PrivateAssets>All</PrivateAssets> <PrivateAssets>All</PrivateAssets>
</PackageReference> </PackageReference>

View File

@ -4,7 +4,12 @@ module MyPrayerJournal.Route
/// The home page /// The home page
let home = "/" let home = "/"
/// The main journal page
let journal = "/journal"
/// Routes dealing with users /// Routes dealing with users
module User = module User =
/// The route for user log on response from Auth0 /// The route for user log on response from Auth0
let logOn = "/user/log-on" let logOn = "/user/log-on"
let logOff = "/user/log-off"

View File

@ -3,6 +3,8 @@ module MyPrayerJournal.Views
//open Suave.Html //open Suave.Html
open Suave.Xml open Suave.Xml
type UserContext = { Id: string option }
[<AutoOpen>] [<AutoOpen>]
module Tags = module Tags =
/// Generate a meta tag /// Generate a meta tag
@ -37,13 +39,17 @@ module PageComponents =
let prependDoctype document = sprintf "<!DOCTYPE html>\n%s" document let prependDoctype document = sprintf "<!DOCTYPE html>\n%s" document
let render = xmlToString >> prependDoctype let render = xmlToString >> prependDoctype
let navigation = let navigation userCtx =
[ navLink "/user/password/change" "Change Your Password" [
navLink "/user/log-off" "Log Off" match userCtx.Id with
jsLink "mpj.signIn()" "Log On" | Some _ ->
yield navLink Route.journal "Journal"
yield navLink Route.User.logOff "Log Off"
| _ -> yield jsLink "mpj.signIn()" "Log On"
] ]
|> List.map (fun x -> tag "li" [] x) |> List.map (fun x -> tag "li" [] x)
let pageHeader = let pageHeader userCtx =
divAttr [ "class", "navbar navbar-inverse navbar-fixed-top" ] [ divAttr [ "class", "navbar navbar-inverse navbar-fixed-top" ] [
divAttr [ "class", "container" ] [ divAttr [ "class", "container" ] [
divAttr [ "class", "navbar-header" ] [ divAttr [ "class", "navbar-header" ] [
@ -56,7 +62,7 @@ module PageComponents =
navLinkAttr [ "class", "navbar-brand" ] "/" "myPrayerJournal" navLinkAttr [ "class", "navbar-brand" ] "/" "myPrayerJournal"
] ]
divAttr [ "class", "navbar-collapse collapse" ] [ divAttr [ "class", "navbar-collapse collapse" ] [
ulAttr [ "class", "nav navbar-nav navbar-right" ] navigation ulAttr [ "class", "nav navbar-nav navbar-right" ] (navigation userCtx)
] ]
] ]
] ]
@ -72,7 +78,7 @@ module PageComponents =
row [ divAttr [ "class", "col-xs-12" ] xml ] row [ divAttr [ "class", "col-xs-12" ] xml ]
/// Display a page /// Display a page
let page content somethingElse = let page userCtx content =
html [ html [
head [ head [
meta [ "charset", "UTF-8" ] meta [ "charset", "UTF-8" ]
@ -83,10 +89,9 @@ let page content somethingElse =
stylesheet "https://fonts.googleapis.com/icon?family=Material+Icons" stylesheet "https://fonts.googleapis.com/icon?family=Material+Icons"
] ]
body [ body [
pageHeader pageHeader userCtx
divAttr [ "class", "container body-content" ] [ divAttr [ "class", "container body-content" ] [
content content
div [ text somethingElse ]
pageFooter pageFooter
] ]
js "https://cdn.auth0.com/js/lock/10.14/lock.min.js" js "https://cdn.auth0.com/js/lock/10.14/lock.min.js"
@ -100,4 +105,9 @@ let home =
p [ text "&nbsp;"] p [ text "&nbsp;"]
p [ text "myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them, update them as God moves in the situation, and record a final answer received on that request. It will also allow individuals to review their answered prayers." ] p [ text "myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them, update them as God moves in the situation, and record a final answer received on that request. It will also allow individuals to review their answered prayers." ]
p [ text "This site is currently in very limited alpha, as it is being developed with a core group of test users. If this is something you are interested in using, check back around mid-February 2017 to check on the development progress." ] p [ text "This site is currently in very limited alpha, as it is being developed with a core group of test users. If this is something you are interested in using, check back around mid-February 2017 to check on the development progress." ]
] ]
let journal =
fullRow [
p [ text "journal goes here" ]
]

View File

@ -3,7 +3,10 @@
*/ */
var mpj = { var mpj = {
lock: new Auth0Lock('Of2s0RQCQ3mt3dwIkOBY5h85J9sXbF2n', 'djs-consulting.auth0.com', { lock: new Auth0Lock('Of2s0RQCQ3mt3dwIkOBY5h85J9sXbF2n', 'djs-consulting.auth0.com', {
auth: { redirectUrl: 'http://localhost:8080/user/log-on' } auth: {
redirectUrl: 'http://localhost:8080/user/log-on',
allowSignUp: false
}
}), }),
signIn: function() { signIn: function() {