Use ASP.NET Core's password hasher (#39)

- Add migration for old-style passwords
- Remove unused database queries
This commit is contained in:
Daniel J. Summers 2022-08-05 17:02:50 -04:00
parent 5a44c7f767
commit 7ae8e3fa5a
7 changed files with 174 additions and 172 deletions

View File

@ -65,10 +65,6 @@ type AppDbContext (options : DbContextOptions<AppDbContext>) =
with get() = this.userGroupXref
and set v = this.userGroupXref <- v
/// F#-style async for saving changes
member this.AsyncSaveChanges () =
this.SaveChangesAsync () |> Async.AwaitTask
override _.OnConfiguring (optionsBuilder : DbContextOptionsBuilder) =
base.OnConfiguring optionsBuilder
optionsBuilder.UseQueryTrackingBehavior QueryTrackingBehavior.NoTracking |> ignore

View File

@ -25,7 +25,6 @@ module private Helpers =
open System
open System.Collections.Generic
open Microsoft.EntityFrameworkCore
open Microsoft.FSharpLu
@ -198,13 +197,6 @@ type AppDbContext with
| _ -> return None
}
/// Check a cookie log on for a small group
member this.TryGroupLogOnByCookie groupId pwHash (hasher : string -> string) = backgroundTask {
match! this.TryGroupById groupId with
| None -> return None
| Some grp -> return if pwHash = hasher grp.Preferences.GroupPassword then Some grp else None
}
/// Count small groups for the given church Id
member this.CountGroupsByChurch churchId = backgroundTask {
return! this.SmallGroups.CountAsync (fun sg -> sg.ChurchId = churchId)
@ -212,12 +204,6 @@ type AppDbContext with
(*-- TIME ZONE EXTENSIONS --*)
/// Get a time zone by its Id
member this.TryTimeZoneById tzId = backgroundTask {
let! zone = this.TimeZones.SingleOrDefaultAsync (fun tz -> tz.Id = tzId)
return Option.fromObject zone
}
/// Get all time zones
member this.AllTimeZones () = backgroundTask {
let! zones = this.TimeZones.OrderBy(fun tz -> tz.SortOrder).ToListAsync ()
@ -258,26 +244,6 @@ type AppDbContext with
return users |> List.map (fun u -> { Member.empty with Email = u.Email; Name = u.Name })
}
/// Find a user based on their credentials
member this.TryUserLogOnByPassword email pwHash groupId = backgroundTask {
let! usr =
this.Users.SingleOrDefaultAsync (fun u ->
u.Email = email
&& u.PasswordHash = pwHash
&& u.SmallGroups.Any (fun xref -> xref.SmallGroupId = groupId))
return Option.fromObject usr
}
/// Find a user based on credentials stored in a cookie
member this.TryUserLogOnByCookie uId gId pwHash = backgroundTask {
match! this.TryUserByIdWithGroups uId with
| None -> return None
| Some usr ->
if pwHash = usr.PasswordHash && usr.SmallGroups |> Seq.exists (fun xref -> xref.SmallGroupId = gId) then
return Some { usr with PasswordHash = ""; Salt = None; SmallGroups = List<UserSmallGroup>() }
else return None
}
/// Count the number of users for a small group
member this.CountUsersBySmallGroup groupId = backgroundTask {
return! this.Users.CountAsync (fun u -> u.SmallGroups.Any (fun xref -> xref.SmallGroupId = groupId))

View File

@ -417,8 +417,8 @@
<data name="There are no classes with passwords defined" xml:space="preserve">
<value>No hay clases con contraseñas se define</value>
</data>
<data name="This is likely due to one of the following reasons:&lt;ul&gt;&lt;li&gt;The e-mail address “{0}” is invalid.&lt;/li&gt;&lt;li&gt;The password entered does not match the password for the given e-mail address.&lt;/li&gt;&lt;li&gt;You are not authorized to administer the group “{1}”.&lt;/li&gt;&lt;/ul&gt;" xml:space="preserve">
<value>Esto es probablemente debido a una de las siguientes razones:&lt;ul&gt;&lt;li&gt;La dirección de correo electrónico “{0}” no es válida.&lt;/li&gt;&lt;li&gt;La contraseña introducida no coincide con la contraseña de la determinada dirección de correo electrónico.&lt;/li&gt;&lt;li&gt;Usted no está autorizado para administrar el grupo “{1}”.&lt;/li&gt;&lt;/ul&gt;</value>
<data name="This is likely due to one of the following reasons:&lt;ul&gt;&lt;li&gt;The e-mail address “{0}” is invalid.&lt;/li&gt;&lt;li&gt;The password entered does not match the password for the given e-mail address.&lt;/li&gt;&lt;li&gt;You are not authorized to administer the selected group.&lt;/li&gt;&lt;/ul&gt;" xml:space="preserve">
<value>Esto es probablemente debido a una de las siguientes razones:&lt;ul&gt;&lt;li&gt;La dirección de correo electrónico “{0}” no es válida.&lt;/li&gt;&lt;li&gt;La contraseña introducida no coincide con la contraseña de la determinada dirección de correo electrónico.&lt;/li&gt;&lt;li&gt;Usted no está autorizado para administrar el grupo seleccionado.&lt;/li&gt;&lt;/ul&gt;</value>
</data>
<data name="This page loaded in {0:N3} seconds" xml:space="preserve">
<value>Esta página cargada en {0:N3} segundos</value>

View File

@ -5,18 +5,6 @@ open System
open System.Security.Cryptography
open System.Text
/// Hash a string with a SHA1 hash
let sha1Hash (x : string) =
use alg = SHA1.Create ()
alg.ComputeHash (Encoding.ASCII.GetBytes x)
|> Seq.map (fun chr -> chr.ToString "x2")
|> String.concat ""
/// Hash a string using 1,024 rounds of PBKDF2 and a salt
let pbkdf2Hash (salt : Guid) (x : string) =
use alg = new Rfc2898DeriveBytes (x, Encoding.UTF8.GetBytes (salt.ToString "N"), 1024)
(alg.GetBytes >> Convert.ToBase64String) 64
open Giraffe
/// Parse a short-GUID-based ID from a string

View File

@ -49,9 +49,9 @@ module Configure =
let _ =
svc.Configure<RequestLocalizationOptions>(fun (opts : RequestLocalizationOptions) ->
let supportedCultures =[|
CultureInfo "en-US"; CultureInfo "en-GB"; CultureInfo "en-AU"; CultureInfo "en"
CultureInfo "es-MX"; CultureInfo "es-ES"; CultureInfo "es"
|]
CultureInfo "en-US"; CultureInfo "en-GB"; CultureInfo "en-AU"; CultureInfo "en"
CultureInfo "es-MX"; CultureInfo "es-ES"; CultureInfo "es"
|]
opts.DefaultRequestCulture <- RequestCulture ("en-US", "en-US")
opts.SupportedCultures <- supportedCultures
opts.SupportedUICultures <- supportedCultures)
@ -212,20 +212,55 @@ module Configure =
/// The web application
module App =
open System.Text
open Microsoft.EntityFrameworkCore
open Microsoft.Extensions.DependencyInjection
let migratePasswords (app : IWebHost) =
task {
use db = app.Services.GetService<AppDbContext>()
let! v1Users = db.Users.FromSqlRaw("SELECT * FROM pt.pt_user WHERE salt IS NULL").ToListAsync ()
for user in v1Users do
let pw =
[| 254uy
yield! (Encoding.UTF8.GetBytes user.PasswordHash)
|]
|> Convert.ToBase64String
db.UpdateEntry { user with PasswordHash = pw }
let! v1Count = db.SaveChangesAsync ()
printfn $"Updated {v1Count} users with version 1 password"
let! v2Users =
db.Users.FromSqlRaw("SELECT * FROM pt.pt_user WHERE salt IS NOT NULL").ToListAsync ()
for user in v2Users do
let pw =
[| 255uy
yield! (user.Salt.Value.ToByteArray ())
yield! (Encoding.UTF8.GetBytes user.PasswordHash)
|]
|> Convert.ToBase64String
db.UpdateEntry { user with PasswordHash = pw }
let! v2Count = db.SaveChangesAsync ()
printfn $"Updated {v2Count} users with version 2 password"
} |> Async.AwaitTask |> Async.RunSynchronously
open System.IO
[<EntryPoint>]
let main _ =
let main args =
let contentRoot = Directory.GetCurrentDirectory ()
WebHostBuilder()
.UseContentRoot(contentRoot)
.ConfigureAppConfiguration(Configure.configuration)
.UseKestrel(Configure.kestrel)
.UseWebRoot(Path.Combine (contentRoot, "wwwroot"))
.ConfigureServices(Configure.services)
.ConfigureLogging(Configure.logging)
.Configure(System.Action<IApplicationBuilder> Configure.app)
.Build()
.Run ()
let app =
WebHostBuilder()
.UseContentRoot(contentRoot)
.ConfigureAppConfiguration(Configure.configuration)
.UseKestrel(Configure.kestrel)
.UseWebRoot(Path.Combine (contentRoot, "wwwroot"))
.ConfigureServices(Configure.services)
.ConfigureLogging(Configure.logging)
.Configure(System.Action<IApplicationBuilder> Configure.app)
.Build()
if args.Length > 0 then
if args[0] = "migrate-passwords" then migratePasswords app
else printfn $"Unrecognized option {args[0]}"
else app.Run ()
0

View File

@ -107,13 +107,14 @@ let logOnSubmit : HttpHandler = requireAccess [ AccessLevel.Public ] >=> validat
match! ctx.Db.TryGroupLogOnByPassword (idFromShort SmallGroupId model.SmallGroupId) model.Password with
| Some group ->
ctx.Session.CurrentGroup <- Some group
let claims = Claim (ClaimTypes.GroupSid, shortGuid group.Id.Value) |> Seq.singleton
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
do! ctx.SignInAsync
(identity.AuthenticationType, ClaimsPrincipal identity,
AuthenticationProperties (
IssuedUtc = DateTimeOffset.UtcNow,
IsPersistent = defaultArg model.RememberMe false))
let identity = ClaimsIdentity (
Seq.singleton (Claim (ClaimTypes.GroupSid, shortGuid group.Id.Value)),
CookieAuthenticationDefaults.AuthenticationScheme)
do! ctx.SignInAsync (
identity.AuthenticationType, ClaimsPrincipal identity,
AuthenticationProperties (
IssuedUtc = DateTimeOffset.UtcNow,
IsPersistent = defaultArg model.RememberMe false))
addInfo ctx s["Log On Successful Welcome to {0}", s["PrayerTracker"]]
return! redirectTo false "/prayer-requests/view" next ctx
| None ->

View File

@ -1,36 +1,78 @@
module PrayerTracker.Handlers.User
open System
open System.Collections.Generic
open Giraffe
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Identity
open PrayerTracker
open PrayerTracker.Entities
open PrayerTracker.ViewModels
/// Retrieve a user from the database by password
// If the hashes do not match, determine if it matches a previous scheme, and upgrade them if it does
/// Password hashing implementation extending ASP.NET Core's identity implementation
[<AutoOpen>]
module Hashing =
open System.Security.Cryptography
open System.Text
/// Custom password hasher used to verify and upgrade old password hashes
type PrayerTrackerPasswordHasher () =
inherit PasswordHasher<User> ()
override this.VerifyHashedPassword (user, hashedPassword, providedPassword) =
if isNull hashedPassword then nullArg (nameof hashedPassword)
if isNull providedPassword then nullArg (nameof providedPassword)
let hashBytes = Convert.FromBase64String hashedPassword
match hashBytes[0] with
| 255uy ->
// v2 hashes - PBKDF2 (RFC 2898), 1,024 rounds
if hashBytes.Length < 49 then PasswordVerificationResult.Failed
else
let v2Hash =
use alg = new Rfc2898DeriveBytes (
providedPassword, Encoding.UTF8.GetBytes ((Guid hashBytes[1..16]).ToString "N"), 1024)
(alg.GetBytes >> Convert.ToBase64String) 64
if Encoding.UTF8.GetString hashBytes[17..] = v2Hash then
PasswordVerificationResult.SuccessRehashNeeded
else PasswordVerificationResult.Failed
| 254uy ->
// v1 hashes - SHA-1
let v1Hash =
use alg = SHA1.Create ()
alg.ComputeHash (Encoding.ASCII.GetBytes providedPassword)
|> Seq.map (fun byt -> byt.ToString "x2")
|> String.concat ""
if Encoding.UTF8.GetString hashBytes[1..] = v1Hash then
PasswordVerificationResult.SuccessRehashNeeded
else
PasswordVerificationResult.Failed
| _ -> base.VerifyHashedPassword (user, hashedPassword, providedPassword)
/// Retrieve a user from the database by password, upgrading password hashes if required
let private findUserByPassword model (db : AppDbContext) = task {
let bareUser user = Some { user with PasswordHash = ""; SmallGroups = ResizeArray<UserSmallGroup>() }
match! db.TryUserByEmailAndGroup model.Email (idFromShort SmallGroupId model.SmallGroupId) with
| Some u when Option.isSome u.Salt ->
// Already upgraded; match = success
let pwHash = pbkdf2Hash (Option.get u.Salt) model.Password
if u.PasswordHash = pwHash then
return Some { u with PasswordHash = ""; Salt = None; SmallGroups = List<UserSmallGroup>() }
else return None
| Some u when u.PasswordHash = sha1Hash model.Password ->
// Not upgraded, but password is good; upgrade 'em!
// Upgrade 'em!
let salt = Guid.NewGuid ()
let pwHash = pbkdf2Hash salt model.Password
let upgraded = { u with Salt = Some salt; PasswordHash = pwHash }
db.UpdateEntry upgraded
let! _ = db.SaveChangesAsync ()
return Some { u with PasswordHash = ""; Salt = None; SmallGroups = List<UserSmallGroup>() }
| _ -> return None
| Some user ->
let hasher = PrayerTrackerPasswordHasher ()
match hasher.VerifyHashedPassword (user, user.PasswordHash, model.Password) with
| PasswordVerificationResult.Success -> return bareUser user
| PasswordVerificationResult.SuccessRehashNeeded ->
db.UpdateEntry { user with PasswordHash = hasher.HashPassword (user, model.Password) }
let! _ = db.SaveChangesAsync ()
return bareUser user
| _ -> return None
| None -> return None
}
open System.Threading.Tasks
/// Return a default URL if the given URL is non-local or otherwise questionable
let sanitizeUrl providedUrl defaultUrl =
let url = match defaultArg providedUrl "" with "" -> defaultUrl | it -> it
if url.IndexOf "\\" >= 0 || url.IndexOf "//" >= 0 then defaultUrl
elif Seq.exists Char.IsControl url then defaultUrl
else url
/// POST /user/password/change
let changePassword : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun next ctx -> task {
@ -38,25 +80,21 @@ let changePassword : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> f
| Ok model ->
let s = Views.I18N.localizer.Force ()
let curUsr = ctx.Session.CurrentUser.Value
let! dbUsr = ctx.Db.TryUserById curUsr.Id
let group = ctx.Session.CurrentGroup.Value
let! user =
match dbUsr with
let hasher = PrayerTrackerPasswordHasher ()
let! user = task {
match! ctx.Db.TryUserById curUsr.Id with
| Some usr ->
// Check the old password against a possibly non-salted hash
(match usr.Salt with Some salt -> pbkdf2Hash salt | None -> sha1Hash) model.OldPassword
|> ctx.Db.TryUserLogOnByCookie curUsr.Id group.Id
| _ -> Task.FromResult None
if hasher.VerifyHashedPassword (usr, usr.PasswordHash, model.OldPassword)
= PasswordVerificationResult.Success then
return Some usr
else return None
| _ -> return None
}
match user with
| Some _ when model.NewPassword = model.NewPasswordConfirm ->
match dbUsr with
| Some usr ->
// Generate new salt whenever the password is changed
let salt = Guid.NewGuid ()
ctx.Db.UpdateEntry { usr with PasswordHash = pbkdf2Hash salt model.NewPassword; Salt = Some salt }
let! _ = ctx.Db.SaveChangesAsync ()
addInfo ctx s["Your password was changed successfully"]
| None -> addError ctx s["Unable to change password"]
| Some usr when model.NewPassword = model.NewPasswordConfirm ->
ctx.Db.UpdateEntry { usr with PasswordHash = hasher.HashPassword (usr, model.NewPassword) }
let! _ = ctx.Db.SaveChangesAsync ()
addInfo ctx s["Your password was changed successfully"]
return! redirectTo false "/" next ctx
| Some _ ->
addError ctx s["The new passwords did not match - your password was NOT changed"]
@ -90,55 +128,41 @@ open Microsoft.AspNetCore.Html
let doLogOn : HttpHandler = requireAccess [ AccessLevel.Public ] >=> validateCsrf >=> fun next ctx -> task {
match! ctx.TryBindFormAsync<UserLogOn> () with
| Ok model ->
let s = Views.I18N.localizer.Force ()
let! usr = findUserByPassword model ctx.Db
match! ctx.Db.TryGroupById (idFromShort SmallGroupId model.SmallGroupId) with
| Some group ->
let! nextUrl = backgroundTask {
match usr with
| Some user ->
ctx.Session.CurrentUser <- usr
ctx.Session.CurrentGroup <- Some group
let claims = seq {
let s = Views.I18N.localizer.Force ()
match! findUserByPassword model ctx.Db with
| Some user ->
match! ctx.Db.TryGroupById (idFromShort SmallGroupId model.SmallGroupId) with
| Some group ->
ctx.Session.CurrentUser <- Some user
ctx.Session.CurrentGroup <- Some group
let identity = ClaimsIdentity (
seq {
Claim (ClaimTypes.NameIdentifier, shortGuid user.Id.Value)
Claim (ClaimTypes.GroupSid, shortGuid group.Id.Value)
Claim (ClaimTypes.Role, if user.IsAdmin then "Admin" else "User")
}
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
do! ctx.SignInAsync
(identity.AuthenticationType, ClaimsPrincipal identity,
AuthenticationProperties (
IssuedUtc = DateTimeOffset.UtcNow,
IsPersistent = defaultArg model.RememberMe false))
addHtmlInfo ctx s["Log On Successful Welcome to {0}", s["PrayerTracker"]]
return
match model.RedirectUrl with
| None -> "/small-group"
| Some x when x = ""-> "/small-group"
| Some x when x.IndexOf "://" < 0 -> x
| _ -> "/small-group"
| _ ->
{ UserMessage.error with
Text = htmlLocString s["Invalid credentials - log on unsuccessful"]
Description =
[ s["This is likely due to one of the following reasons"].Value
":<ul><li>"
s["The e-mail address “{0}” is invalid.", WebUtility.HtmlEncode model.Email].Value
"</li><li>"
s["The password entered does not match the password for the given e-mail address."].Value
"</li><li>"
s["You are not authorized to administer the group {0}.",
WebUtility.HtmlEncode group.Name].Value
"</li></ul>"
]
|> String.concat ""
|> (HtmlString >> Some)
}
|> addUserMessage ctx
return "/user/log-on"
}, CookieAuthenticationDefaults.AuthenticationScheme)
do! ctx.SignInAsync (
identity.AuthenticationType, ClaimsPrincipal identity,
AuthenticationProperties (
IssuedUtc = DateTimeOffset.UtcNow,
IsPersistent = defaultArg model.RememberMe false))
addHtmlInfo ctx s["Log On Successful Welcome to {0}", s["PrayerTracker"]]
return! redirectTo false (sanitizeUrl model.RedirectUrl "/small-group") next ctx
| None -> return! fourOhFour ctx
| None ->
{ UserMessage.error with
Text = htmlLocString s["Invalid credentials - log on unsuccessful"]
Description =
let detail =
[ "This is likely due to one of the following reasons:<ul>"
"<li>The e-mail address “{0}” is invalid.</li>"
"<li>The password entered does not match the password for the given e-mail address.</li>"
"<li>You are not authorized to administer the selected group.</li></ul>"
]
|> String.concat ""
Some (HtmlString (s[detail, WebUtility.HtmlEncode model.Email].Value))
}
return! redirectTo false nextUrl next ctx
| None -> return! fourOhFour ctx
|> addUserMessage ctx
return! redirectTo false "/user/log-on" next ctx
| Result.Error e -> return! bindError e next ctx
}
@ -191,6 +215,8 @@ let password : HttpHandler = requireAccess [ User ] >=> fun next ctx ->
|> Views.User.changePassword ctx
|> renderHtml next ctx
open System.Threading.Tasks
/// POST /user/save
let save : HttpHandler = requireAccess [ Admin ] >=> validateCsrf >=> fun next ctx -> task {
match! ctx.TryBindFormAsync<EditUser> () with
@ -198,20 +224,10 @@ let save : HttpHandler = requireAccess [ Admin ] >=> validateCsrf >=> fun next c
let! user =
if model.IsNew then Task.FromResult (Some { User.empty with Id = (Guid.NewGuid >> UserId) () })
else ctx.Db.TryUserById (idFromShort UserId model.UserId)
let saltedUser =
match user with
| Some u ->
match u.Salt with
| None when model.Password <> "" ->
// Generate salt so that a new password hash can be generated
Some { u with Salt = Some (Guid.NewGuid ()) }
| _ ->
// Leave the user with no salt, so prior hash can be validated/upgraded
user
| _ -> user
match saltedUser with
| Some u ->
let updatedUser = model.PopulateUser u (pbkdf2Hash (Option.get u.Salt))
match user with
| Some usr ->
let hasher = PrayerTrackerPasswordHasher ()
let updatedUser = model.PopulateUser usr (fun pw -> hasher.HashPassword (usr, pw))
updatedUser |> if model.IsNew then ctx.Db.AddEntry else ctx.Db.UpdateEntry
let! _ = ctx.Db.SaveChangesAsync ()
let s = Views.I18N.localizer.Force ()
@ -224,7 +240,7 @@ let save : HttpHandler = requireAccess [ Admin ] >=> validateCsrf >=> fun next c
|> Some
}
|> addUserMessage ctx
return! redirectTo false $"/user/{shortGuid u.Id.Value}/small-groups" next ctx
return! redirectTo false $"/user/{shortGuid usr.Id.Value}/small-groups" next ctx
else
addInfo ctx s["Successfully {0} user", s["Updated"].Value.ToLower ()]
return! redirectTo false "/users" next ctx