F# Giraffe Authentication with ASP.NET Core Identity and Entity Framework Core
By Tosh N. for Carpe Noctem.
Changelog:
- 2022-02-13 - Initial publication.
- 2022-02-14 - Minor improvement: migration files can be included with wildcard in fsproj, credit to EFCore.FSharp creator
Introduction
This article will cover how to create a 'traditional' username and password protected website, including user registration, login/logout, email verification, and a user profile page. We will use the Giraffe framework for F#, along with ASP.NET Core Identity ("Identity" from now on) and Entity Framework Core ("EF Core") as the data store.
This is Part 4 in a series of Giraffe/F# development articles, and I'll assume you're familiar with basic Giraffe development. The previous parts are Part 1 - ASP.NET Core for Giraffe, Part 2 - HttpHandlers, and Part 3 - JWT Auth.
The objective of this article are to:
- Introduce Identity and EF Core.
- Set up a user database with EF Core, using the EFCore.FSharp package.
- Configure Identity within Giraffe to use this user database.
- Create views for registration, login, logout, email-verification, and displaying the user profile.
- Create handlers for these views.
Authentication is a huge topic, so here are a few caveats to limit the scope of this article:
- We will be making a 'traditional' web app with multiple pages (i.e. not a SPA) using Giraffe.ViewEngine. I will discuss the changes needed for SPA's at the end of the article.
- We will not be covering OAuth based authentication using Azure AD or other services.
- We will not cover JWT authentication - that was in Part 3 - JWT Auth. Everything here will be based on cookies.
- We will be using SQLite for the example data store. I'll include instructions on how to switch to a different DB.
Working example project can be found in this repo.
Packages
Let's install some packages first. Note that I am specifying version numbers meant for .NET 5 for these packages (except Microsoft.AspNetCore.Identity, which is apparently version-agnostic) because Giraffe's project template is still .NET 5 at the time of writing. Please adjust accordingly in the future.
dotnet tool install --global dotnet-ef
dotnet add package Microsoft.AspNetCore.Identity
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version 5.0.14
dotnet add package EntityFrameworkCore.FSharp --version 5.0.3
dotnet add package Microsoft.EntityFrameworkCore.Design --version 5.0.14
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 5.0.14
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 5.0.14
Of course, there is support for other databases. For instance, Microsoft.EntityFrameworkCore.SqlServer is for SQL Server, and Npgsql.EntityFrameworkCore.PostgreSQL supports PostgreSQL.
Creating an Identity Database with Entity Framework Core
Executive summary of this section:
- Create a file named something like
ApplicationDbContext.fs
with the following content (and add it to your fsproj file):
module MyDbContext
open Microsoft.AspNetCore.Identity
open Microsoft.AspNetCore.Identity.EntityFrameworkCore
open Microsoft.EntityFrameworkCore
open Microsoft.EntityFrameworkCore.Sqlite // replace with DB of your choice
open Microsoft.EntityFrameworkCore.Design // for IDesignTimeDbContextFactory
type ApplicationDbContext(options : DbContextOptions<ApplicationDbContext>) =
inherit IdentityDbContext(options)
// OPTIONAL. seed the database with some initial roles.
override __.OnModelCreating (modelBuilder : ModelBuilder) =
base.OnModelCreating(modelBuilder)
modelBuilder.Entity<IdentityRole>().HasData(
[|
IdentityRole(Name = "admin", NormalizedName = "ADMIN")
IdentityRole(Name = "user", NormalizedName = "USER")
|]) |> ignore
type ApplicationDbContextFactory() =
interface IDesignTimeDbContextFactory<ApplicationDbContext> with
member __.CreateDbContext (args: string[]) =
let optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>()
optionsBuilder.UseSqlite("Data Source=identity.db") |> ignore
new ApplicationDbContext(optionsBuilder.Options)
- Create an initial migration by running
dotnet ef migrations add Initial
. Add the generated files to your fsproj as well. - Deploy the database with
dotnet ef database update
.
For those with previous EF Core experience in C#
The above should all seem familiar to you. A few things to note are:
- Unlike in C#, F# source files are required to be explicitly listed in fsproj, so don't forget to add the migrations after creation.
- The
ApplicationDbContextFactory
class is necessary to tell the EF Core migration tool how to build a database during 'design time' (when you rundotnet ef
). For C# ASP.NET Core apps, EF Core looks for a method calledCreateHostBuilder
in the Public class. Giraffe lacks this class, so you need to be more explicit. More details here.
That's it. You can skip to the next section.
For those not familiar with EF Core
EF Core is basically two things: an Object Relational Mapper (ORM) and a migration tool. The basic workflow when working with EF Core is:
- Define some classes representing the entities in your domain.
- Create a class that derives from
DbContext
. Connect this context class with your domain classes through accessors. - Run
dotnet ef migrations add SomeMigrationName
to create a "migration". Migrations are source files (in whichever language you are using) that contain instructions to create the database tables and their relationships (foreign key constraints, etc). - Deploy those migrations with
dotnet ef database update
. - Repeat steps 1-4 every time you make a change to your domain classes.
When we use Identity with EF Core, though, we do not need to design our own classes to represent entities like Users and Roles.
Instead, we use IdentityDbContext
from Microsoft.AspNetCore.Identity.EntityFrameworkCore, itself a class derived from DbContext
, which is already hooked up to all the classes you'll need.
Thus, all you really need for steps 1 and 2 above are:
module MyDbContext
open Microsoft.AspNetCore.Identity.EntityFrameworkCore
open Microsoft.EntityFrameworkCore
type ApplicationDbContext(options : DbContextOptions<ApplicationDbContext>) =
inherit IdentityDbContext(options)
Note that IdentityDbContext
is actually an alias for IdentityDbContext<IdentityUser,IdentityRole,string>
(don't worry about the 'string' parameter).
IdentityUser
and IdentityRole
are the built-in classes which should be adequate for the majority of applications.
If you really want, you can extend these and inherit from IdentityDbContext<YourUser, YourRole>
instead.
If you want to seed the database with some data, you can override the OnModelCreating
method. See this article for details:
override __.OnModelCreating (modelBuilder : ModelBuilder) =
base.OnModelCreating(modelBuilder)
// create some roles
modelBuilder.Entity<IdentityRole>().HasData(
[|
IdentityRole(Name = "admin", NormalizedName = "ADMIN")
IdentityRole(Name = "user", NormalizedName = "USER")
|]) |> ignore
If we were working with a C# ASP.NET Core application, we'd be done here.
However, with F# and Giraffe, we need to add a 'factory' class to help the EF Core migration tool construct an instance of your db context at 'design time' (i.e., when you run dotnet ef migrations
).
We need to do this because, for ASP.NET Core apps, the tool usually looks for a static method called CreateHostBuilder
in the Program
class.
However, Giraffe does not use such a class, thus a factory is needed. See more here
Below is all you need:
open Microsoft.EntityFrameworkCore.Design // for IDesignTimeDbContextFactory
open Microsoft.EntityFrameworkCore.Sqlite // adds UseSqlite method
// or, you can use other databases
// open Microsoft.EntityFrameworkCore.SqlServer // UseSqlServer
// open Npgsql.EntityFrameworkCore.PostgreSQL // UseNpgsql
type ApplicationDbContextFactory() =
interface IDesignTimeDbContextFactory<ApplicationDbContext> with
member __.CreateDbContext (args: string[]) =
let optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>()
optionsBuilder.UseSqlite("Data Source=identity.db") |> ignore
// for other databases::
// optionsBuilder.UseNpgsql(connectionString) |> ignore
// optionsBuilder.UseSqlServer(connectionString) |> ignore
new ApplicationDbContext(optionsBuilder.Options)
Now, we can create a 'migration' with dotnet ef migrations add Initial
.
If all goes well, you'll have a new directory called 'Migrations`.
Add these files to your fsproj file:
<ItemGroup>
<Compile Include="MyDbContext.fs" />
<Compile Include="Migrations/ApplicationDbContextModelSnapshot.fs" />
<Compile Include="Migrations/20220213001824_Initial.fs" /> <!-- or whatever the files are called -->
<!-- alternatively, you can use a wildcard so you don't have to update this file every migration:
<Compile Include="Migrations/*.fs" />
-->
<Compile Include="Program.fs" />
</ItemGroup>
And run dotnet ef database update
to deploy the database.
If you used SQLite as shown above, you will find a new SQLite file.
Below is a snippet of the schema (run sqlite3 identity.db .schema
to see the whole thing):
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "AspNetRoles" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
"Name" TEXT NULL,
"NormalizedName" TEXT NULL,
"ConcurrencyStamp" TEXT NULL
);
CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY,
"UserName" TEXT NULL,
"NormalizedUserName" TEXT NULL,
"Email" TEXT NULL,
"NormalizedEmail" TEXT NULL,
"EmailConfirmed" INTEGER NOT NULL,
"PasswordHash" TEXT NULL,
"SecurityStamp" TEXT NULL,
"ConcurrencyStamp" TEXT NULL,
"PhoneNumber" TEXT NULL,
"PhoneNumberConfirmed" INTEGER NOT NULL,
"TwoFactorEnabled" INTEGER NOT NULL,
"LockoutEnd" TEXT NULL,
"LockoutEnabled" INTEGER NOT NULL,
"AccessFailedCount" INTEGER NOT NULL
);
-- and so much more
That's it! Now we're ready to use this thing!
Configuring Identity and EF Core in Giraffe
So far, we have not done anything related to Giraffe. To begin, open some namespaces:
open Microsoft.AspNetCore.Identity
open Microsoft.EntityFrameworkCore
open Microsoft.EntityFrameworkCore.Sqlite // or whichever database you choose
Then, we need to inject some services in the configureServices
function.
(If you don't know what that means, see Part 1 of this blog series.)
We use AddDbContext
to tell ASP.NET Core that we are using EF Core database context we previously created, and specify connection string with the options builder.
(We are specifying the connection string again here because this is what is used at runtime -- the previous is used only for creating the migrations.)
services.AddDbContext<MyDbContext.ApplicationDbContext>(fun options ->
// replace with method and connection string for your database.
options.UseSqlite("Filename=identity.db") |> ignore
) |> ignore
Then, we use AddIdentity
to configure Identity with a bunch of authentication related options and connect it to EF Core with AddEntityFrameworkStores
.
// IdentityUser, IdentityRole from MS.ASP.Identity
services.AddIdentity<IdentityUser, IdentityRole>(fun options ->
options.Password.RequireLowercase <- true
options.Password.RequireUppercase <- true
options.Password.RequireDigit <- true
options.Lockout.MaxFailedAccessAttempts <- 5
options.Lockout.DefaultLockoutTimeSpan <- TimeSpan.FromMinutes(15)
options.User.RequireUniqueEmail <- true
// enable this if we use email verification
// options.SignIn.RequireConfirmedEmail <- true;
)
// tell asp.net identity to use the above store
.AddEntityFrameworkStores<MyDbContext.ApplicationDbContext>()
.AddDefaultTokenProviders() // need for email verification token generation
|> ignore
In the snippet above, notice that we first do AddIdentity<IdentityUser, IdentityRole>
.
As mentioned, you can derive IdentityUser
and IdentityRole
if you'd like.
The options builder for AddIdentity
, IdentityOptions
,
contains a lot of goodies, including Password requirements, email verification requirements, and brute force protection.
Honestly, this functionality is why you use Identity instead of rolling your own!
The example shows some options to get you started.
Lastly, we AddDefaultTokenProviders
so we can generate email verification tokens (as well as other tokens) later.
Creating a Registration Page
Now, we are finally ready to start using Identity in our handlers.
For those unfamiliar with Identity: Identity is a comprehensive API supporting authentication UIs. Given a data store (in our case managed by EF Core, though you can implement your own as well), it handles all the difficult tasks like hashing password securely, setting authentication cookies, and so on.
We will mainly be interacting with two components of Identity:
UserManager
and
SignInManager
.
These were injected into our app when we did AddIdentity
above.
But first, let's do some Giraffe.ViewEngine to build the views. Below are a simple registration page and a 'thank you for registering' page to show afterwards. Note that we accept a string list to display any errors that may have occured with previous login attempts. These are called "flash messages" in other languages. We display such messages in red.
let registerPage (errors : string list) =
[
h1 [] [str "Please Register"]
form [_method "post"] [
input [_type "text"; _placeholder "email"; _name "email"]
input [_type "password"; _placeholder "password"; _name "password"]
input [_type "submit"; _value "Register"]
]
a [_href "/Account/Login"] [str "Already registered?"]
ul [] (errors |> List.map (fun err -> li [] [str err]))
] |> layout
let thanksForRegisteringPage =
[
p [] [ str "thanks for registering, we'll send you a confirmation email soon!" ]
a [_href "/Account/Login"] [str "go back to login page"]
] |> layout
Next we need a corresponding POST handler to handle form submissions. A few things to note in the snippet below:
- We use the
ctx.TryBindFormAsync
to parse the form data. Although it is a method onHttpContext
, it is defined in Giraffe in src/Giraffe/HttpContextExtensions.fs. - We retrieve User Manager instance from the context.
- We use the
userManager.CreateAsync
method to add anIdentityUser
. This method will create a row in our database representing the user.
[<CLIMutable>]
type RegisterModel =
{
// it's okay this is capitalized.
Email : string
Password : string
}
let registerHandler : HttpHandler =
fun next ctx ->
task {
let userManager = ctx.GetService<UserManager<IdentityUser>>()
let! form = ctx.TryBindFormAsync<RegisterModel>()
match form with
| Error _ ->
return! htmlView (Views.registerPage ["Something went wrong, please try again"]) next ctx
| Ok form ->
let user = IdentityUser(Email = form.Email, UserName = form.Email)
let! result = userManager.CreateAsync(user, form.Password)
if result.Succeeded then
return! redirectTo false "/Account/RegisterThanks" next ctx
else
// result.Errors contains stuff like:
// Passwords must have at least one non alphanumeric character.
// Passwords must have at least one digit ('0'-'9').
// Passwords must have at least one uppercase ('A'-'Z').
let errors = result.Errors |> Seq.map (fun e -> e.Description) |> List.ofSeq
return! htmlView (Views.registerPage errors) next ctx
}
Finally, we compose a simple router.
Note how we are calling Views.registerPage
with an empty list to indicate no errors.
let webApp =
choose [
GET >=> route "/Account/Register" >=> htmlView (Views.registerPage [])
POST >=> route "/Account/Register" >=> registerHandler
setStatusCode 404 >=> text "Not Found" ]
Antiforgery
Actually, the registration page above has a major vulnerability: it is susceptible to the Cross-Site Request Forgery (CSRF) attack. The ASP.NET Core-way of mitigating this attack is to:
- For every request, set a cookie called
.AspNetCore.Antiforgery.SomeRandomString
with a unique token. - Require that all form submissions carry this secret token.
Razor projects (in C#) handle this by automatically setting the cookie on every request, and injecting the token as a hidden field into every form. With Giraffe.ViewEngine, we need to do this manually. Fortunately, this is straightforward using the Giraffe.AntiForgery package, which is a thin wrapper around Microsoft.AspNetCore.Antiforgery. Note At the time of writing, this package is out of date. Please see this gist which is a very slightly modified version that you can add directly to your project.
We need to make three changes to our code to use this package correctly:
- Each view that contains a form must be modified to accept an
AntiforgeryTokenSet
as its last parameter, and add that token to the form with theantiforgeryInput
helper function. - We must use
csrfHtmlView
instead ofhtmlView
to render such views. This takes care of creating and passing the token to the view. - We must protect POST endpoints with
requiresCsrfToken
.
These changes are illustrated below:
let registerPage (errors : string list) (token : AntiforgeryTokenSet) = // token should be last parameter
[
h1 [] [str "Please Register"]
form [_method "post"] [
input [_type "text"; _placeholder "email"; _name "email"]
input [_type "password"; _placeholder "password"; _name "password"]
input [_type "submit"; _value "register"]
antiforgeryInput token // Add anywhere in form
]
a [_href "/Account/Login"] [str "Already registered?"]
ul [] (errors |> List.map (fun err -> li [] [str err]))
] |> layout
let webApp =
choose [
GET >=> route "/Account/Register" >=> csrfHtmlView (Views.registerPage [])
POST
>=> requiresCsrfToken (setStatusCode 403 >=> text "Forbidden")
>=> route "/Account/Register"
>=> registerHandler
setStatusCode 404 >=> text "Not Found" ]
When rendering from within a handler, we might do something like:
return! csrfHtmlView (Views.registerPage ["Something went wrong, please try again"]) next ctx
Creating a Login Page
The view for login looks very similar to the registration view. The only difference is that we accept a single boolean for flashing error messages, instead of a string array.
let loginPage (justFailed : bool) (token : AntiforgeryTokenSet) =
[
yield h1 [] [str "Please Login"]
yield form [_method "post"] [
input [_type "text"; _placeholder "email"; _name "email"]
input [_type "password"; _placeholder "password"; _name "password"]
input [_type "submit"; _value "login"]
antiforgeryInput token
]
if justFailed then
yield p [ _style "color: Red;" ] [ str "Login failed." ]
yield a [_href "/Account/Register"] [str "Register"]
] |> layout
Unlike for registration, logins are handled by SignInManager
, similarly available via ctx.GetService
.
We'll use its PasswordSignInAsync
function.
If successful, this function sets an autheticated session cookie.
let loginHandler : HttpHandler =
fun next ctx ->
task {
let! model = ctx.TryBindFormAsync<LoginModel>()
match model with
| Ok model ->
let signInManager = ctx.GetService<SignInManager<IdentityUser>>()
let! result = signInManager.PasswordSignInAsync(model.Email, model.Password, true, false)
match result.Succeeded with
| true -> return! redirectTo false "/User" next ctx
| false -> return! csrfHtmlView (Views.loginPage true) next ctx
| Error _ -> return! csrfHtmlView (Views.loginPage true) next ctx
}
Logging out
SignInManager
also, obviously, handles logging out.
(Logging out does not require a view.)
let logoutHandler : HttpHandler =
fun next ctx ->
task {
let signInManager = ctx.GetService<SignInManager<IdentityUser>>()
do! signInManager.SignOutAsync()
return! redirectTo false "/Account/Login" next ctx
}
At this point, our routes should look something like:
let webApp =
choose [
GET >=>
choose [
route "/Account/Register" >=> csrfHtmlView (Views.registerPage [])
route "/Account/Login" >=> csrfHtmlView (Views.loginPage false)
route "/Account/RegisterThanks" >=> htmlView Views.thanksForRegisteringPage
]
POST >=> requiresCsrfToken (setStatusCode 403 >=> text "Forbidden") >=>
choose [
route "/Account/Register" >=> registerHandler
route "/Account/Login" >=> loginHandler
route "/Account/Logout" >=> logoutHandler
]
setStatusCode 404 >=> text "Not Found" ]
User Profile Page
Below is an example of how to extract information out of IdentityUser
:
let userPage (user : IdentityUser) (token : AntiforgeryTokenSet) =
[
p [] [
sprintf "User name: %s, Email: %s" user.UserName user.Email
|> str
]
form [_method "post"; _action "/Account/Logout"] [
input [_type "submit"; _value "Logout"]
antiforgeryInput token
]
] |> layout
let userPageHandler : HttpHandler =
fun next ctx ->
task {
let userManager = ctx.GetService<UserManager<IdentityUser>>()
let! user = userManager.GetUserAsync ctx.User
return! (user |> Views.userPage |> csrfHtmlView) next ctx
}
We can use the requiresAuthentication
function provided by Giraffe in our route to protect this page.
Our complete webapp router should look like:
let webApp =
choose [
GET >=>
choose [
route "/Account/Register" >=> csrfHtmlView (Views.registerPage [])
route "/Account/Login" >=> csrfHtmlView (Views.loginPage false)
route "/Account/RegisterThanks" >=> htmlView Views.thanksForRegisteringPage
route "/Account/Verify" >=> emailVerificationHandler
requiresAuthentication (redirectTo false "/Account/Login")
>=> route "/User"
>=> userPageHandler
]
POST >=> requiresCsrfToken (setStatusCode 403 >=> text "Forbidden") >=>
choose [
route "/Account/Register" >=> registerHandler
route "/Account/Login" >=> loginHandler
route "/Account/Logout" >=> logoutHandler
]
setStatusCode 404 >=> text "Not Found" ]
Handling Email Verification
To explore some additional functionality in Identity, let's see how easy it is to add email-verification to our site.
To do this, We merely need to call userManager.GenerateEmailConfirmationTokenAsync(user)
after we successfully create the user.
This generates a signed token which can be redeemed with userManager.ConfirmEmailAsync
later.
Also remember that there is an option options.SignIn.RequireConfirmedEmail
which you can enable when configuring the Identity service.
Note that, below, we are not actually sending the email, but instead merely printing it to the console to simulate the process. In practice, you should probably use something like the System.Net.Mail SMTP client to talk to your mail server, or use a API-based service such as Twilio.
let registerHandler : HttpHandler =
fun next ctx ->
task {
let userManager = ctx.GetService<UserManager<IdentityUser>>()
let! form = ctx.TryBindFormAsync<RegisterModel>()
match form with
| Error _ ->
return! csrfHtmlView (Views.registerPage ["Something went wrong, please try again"]) next ctx
| Ok form ->
let user = IdentityUser(Email = form.Email, UserName = form.Email)
let! result = userManager.CreateAsync(user, form.Password)
if result.Succeeded then
// NEW STUFF HERE //
let! token = userManager.GenerateEmailConfirmationTokenAsync(user)
let baseUrl = $"{ctx.Request.Scheme}://{ctx.Request.Host}/Account/Verify"
let url = QueryHelpers.AddQueryString(baseUrl, dict["UserId", user.Id ; "Token", token])
// !!!
// This is where you would send an email to the user, with something like System.Net.Mail or similar
// However, for this sample we're just going to print it to the console so you can visit the site manually
// !!!
printfn "***********\nYour verification url is: %s\n***********" url
return! redirectTo false "/Account/RegisterThanks" next ctx
else
let errors = result.Errors |> Seq.map (fun e -> e.Description) |> List.ofSeq
return! csrfHtmlView (Views.registerPage errors) next ctx
}
When the user visits this url, you can process it with the userManager.ConfirmEmailAsync
method:
let emailVerificationHandler : HttpHandler =
fun next ctx ->
task {
let userManager = ctx.GetService<UserManager<IdentityUser>>()
match ctx.TryBindQueryString<EmailVerificationModel>() with
| Ok qs ->
let! user = userManager.FindByIdAsync(qs.UserId)
if not (isNull user) then
// Interesting part:
let! result = userManager.ConfirmEmailAsync(user, qs.Token);
return! (Views.emailVerificationPage result.Succeeded |> htmlView) next ctx
else
return! (Views.emailVerificationPage false |> htmlView) next ctx
| Error s ->
return! (Views.emailVerificationPage false |> htmlView) next ctx
}
UserManager
also has a GeneratePasswordResetTokenAsync
method for generating password reset tokens, which can be used in the same way as above.
It even has methods like GenerateTwoFactorTokenAsync for setting up two factor authentication and other more advanced functionality.
Authorization
If you want finer grained control over resources than just checking for authentication, you can use Claims, Role, or Policy-based Authorization. Fortunately, such authorization can be done in the exact way as we did for JWT authorization, so we won't repeat it here.
Adding Claims and User Roles
Adding claims to users, and users to roles, can be accomplished through the userManager.AddToRoleAsync
and userManager.AddClaimsAsync
, respectively.
For example, you can modify the registerHandler function to add a role and some claims when the user is first registered:
let user = IdentityUser(Email = form.Email, UserName = form.Email)
let! result = userManager.CreateAsync(user, form.Password)
if result.Succeeded then
let! res1 = userManager.AddToRoleAsync(user, "USER")
let! res2 = userManager.AddClaimsAsync(user, [
Claim("tier", "free") // change this to "paid" after they subscribe, for example
])
// (You should check that these result vars are actually valid in prod code, obviously...)
// ...email verification code omitted...
return! redirectTo false "/Account/RegisterThanks" next ctx
else
let errors = result.Errors |> Seq.map (fun e -> e.Description) |> List.ofSeq
return! csrfHtmlView (Views.registerPage errors) next ctx
Adapting for SPA's
This article built a login system for a 'traditional' multi-page web application, based on cookies. In contrast, the general trend amongst SPA developers seems to be to outsource authentication to a third party OAuth server, and use JWT to talk to the back API server.
However, under certain conditions, you can still use Cookie-based authentication (and thus much of the code and information in this article). In fact, I think this method is far less complicated then dealing with Oauth, and is my preferred method.
The conditions, stolen from this Auth0 article, are:
- The client code must be served by the backend API server,
- The client must have the same domain as the backend, and
- The client must only make AJAX calls to that backend.
Such a SPA could be treated almost identically to a multi-page application. The CSRF token would be retrieved once during startup and be reused throughout the life of the application.
Credits
- Frank Liu has an excellent Udemy course covering ASP.NET Core Identity (in C#).
- A lot of the Giraffe-level code was adapted from this sample project
Comments, Questions?
Feel free to reach out!
My email is toshATcarpenoctemDOTdev
.
My twitter is @carpenoctemdev.