aboutsummaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'plugins')
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs8
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs154
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs26
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs60
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs26
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs26
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs5
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs4
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs2
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientClaimManager.cs126
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs5
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs11
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginClaim.cs73
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginUriBuilder.cs127
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs458
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs55
16 files changed, 634 insertions, 532 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs
index b84728b..3292e6b 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts.Registration
@@ -34,7 +34,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration
/// <summary>
/// Central password requirement validator
/// </summary>
- public static IValidator<string> PasswordValidator { get; } = GetPassVal();
+ public static IValidator<string?> PasswordValidator { get; } = GetPassVal();
public static IValidator<AccountData> AccountDataValidator { get; } = GetAcVal();
@@ -43,9 +43,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration
/// </summary>
public static IValidator<RegRequestMessage> RegRequestValidator { get; } = GetRequestValidator();
- static IValidator<string> GetPassVal()
+ static IValidator<string?> GetPassVal()
{
- InlineValidator<string> passVal = new();
+ InlineValidator<string?> passVal = new();
passVal.RuleFor(static password => password)
.NotEmpty()
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs
index 6a81e7e..2172760 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs
@@ -25,6 +25,7 @@
using System;
using System.Net;
using System.Text.Json;
+using System.Text.Json.Serialization;
using FluentValidation;
@@ -49,7 +50,6 @@ using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Essentials.Accounts.Registration.TokenRevocation;
using static VNLib.Plugins.Essentials.Accounts.AccountUtil;
-
namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
{
@@ -63,10 +63,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
const string FAILED_AUTH_ERR = "Your registration does not exist, you should try to regisiter again.";
const string REG_ERR_MESSAGE = "Please check your email inbox.";
-
+
+ private static readonly IValidator<RegCompletionRequest> RegCompletionValidator = RegCompletionRequest.GetValidator();
+
private readonly IUserManager Users;
- private readonly IValidator<string> RegJwtValdidator;
- private readonly IPasswordHashingProvider Passwords;
private readonly RevokedTokenStore RevokedTokens;
private readonly TransactionalEmailConfig Emails;
private readonly IAsyncLazy<ReadOnlyJsonWebKey> RegSignatureKey;
@@ -84,11 +84,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
InitPathAndLog(path, plugin.Log);
RegExpiresSec = config["reg_expires_sec"].GetTimeSpan(TimeParseType.Seconds);
-
- //Init reg jwt validator
- RegJwtValdidator = GetJwtValidator();
-
- Passwords = plugin.GetOrCreateSingleton<ManagedPasswordHashing>();
+
Users = plugin.GetOrCreateSingleton<UserManager>();
Emails = plugin.GetOrCreateSingleton<TEmailConfig>();
RevokedTokens = new(plugin.GetContextOptions());
@@ -98,21 +94,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
.ToLazy(static sr => sr.GetJsonWebKey());
}
-
- private static IValidator<string> GetJwtValidator()
- {
- InlineValidator<string> val = new();
-
- val.RuleFor(static s => s)
- .NotEmpty()
- //Must contain 2 periods for jwt limitation
- .Must(static s => s.Count(s => s == '.') == 2)
- //Guard length
- .Length(20, 500)
- .IllegalCharacters();
- return val;
- }
-
//Schedule cleanup interval
[AsyncInterval(Minutes = 5)]
public async Task OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken)
@@ -125,66 +106,39 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
+
//Get the json request data from client
- using JsonDocument? request = await entity.GetJsonFromFileAsync();
+ using RegCompletionRequest? request = await entity.GetJsonFromFileAsync<RegCompletionRequest>();
if(webm.Assert(request != null, "No request data present"))
{
- entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
- return VfReturnType.VirtualSkip;
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
- //Get the jwt string from client
- string? regJwt = request.RootElement.GetPropString("token");
- using PrivateString? password = (PrivateString?)request.RootElement.GetPropString("password");
-
- //validate inputs
+ if(!RegCompletionValidator.Validate(request, webm))
{
- if (webm.Assert(regJwt != null, FAILED_AUTH_ERR))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
-
- if (webm.Assert(password != null, "You must specify a password."))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
- //validate new password
- if(!AccountValidations.PasswordValidator.Validate((string)password, webm))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
- //Validate jwt
- if (webm.Assert(RegJwtValdidator.Validate(regJwt).IsValid, FAILED_AUTH_ERR))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
}
- //Verify jwt has not been revoked
- if(await RevokedTokens.IsRevokedAsync(regJwt, entity.EventCancellation))
+ //Verify jwt has not been revoked
+ bool isRevoked = await RevokedTokens.IsRevokedAsync(request.Token!, entity.EventCancellation);
+ if (webm.Assert(!isRevoked, FAILED_AUTH_ERR))
{
- webm.Result = FAILED_AUTH_ERR;
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
string emailAddress;
try
{
//get jwt
- using JsonWebToken jwt = JsonWebToken.Parse(regJwt);
+ using JsonWebToken jwt = JsonWebToken.Parse(request.Token);
+
//verify signature
bool verified = jwt.VerifyFromJwk(RegSignatureKey.Value);
if (webm.Assert(verified, FAILED_AUTH_ERR))
{
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
//recover iat and email address
@@ -195,32 +149,28 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
//Verify IAT against expiration at second resolution
if (webm.Assert(iat.Add(RegExpiresSec) > entity.RequestedTimeUtc, FAILED_AUTH_ERR))
{
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
}
catch (FormatException fe)
{
Log.Debug(fe);
webm.Result = FAILED_AUTH_ERR;
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
-
-
- //Always hash the new password, even if failed
- using PrivateString passHash = Passwords.Hash(password);
try
{
- //Generate userid from email
- string uid = GetRandomUserId();
-
- //Create the new user
- using IUser user = await Users.CreateUserAsync(uid, emailAddress, MINIMUM_LEVEL, passHash, entity.EventCancellation);
+ UserCreationRequest creation = new()
+ {
+ EmailAddress = emailAddress,
+ InitialStatus = UserStatus.Active,
+ Password = request.GetPassPrivString(),
+ };
- //Set active status
- user.Status = UserStatus.Active;
+ //Create the new user with random user-id
+ using IUser user = await Users.CreateUserAsync(creation, null, entity.EventCancellation);
+
//set local account origin
user.SetAccountOrigin(LOCAL_ACCOUNT_ORIGIN);
@@ -228,12 +178,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
await user.ReleaseAsync();
//Revoke token now complete
- _ = RevokedTokens.RevokeAsync(regJwt, CancellationToken.None).ConfigureAwait(false);
+ _ = RevokedTokens.RevokeAsync(request.Token, CancellationToken.None).ConfigureAwait(false);
webm.Result = "Successfully created your new account. You may now log in";
webm.Success = true;
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+
+ return VirtualOk(entity, webm);
}
//Capture creation failed, this may be a replay
catch (UserExistsException)
@@ -244,8 +194,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
}
webm.Result = FAILED_AUTH_ERR;
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
@@ -366,6 +315,43 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints
Log.Error(ex);
}
}
-
+
+
+ private sealed class RegCompletionRequest : PrivateStringManager
+ {
+ public RegCompletionRequest() : base(1)
+ { }
+
+ [JsonPropertyName("password")]
+ public string? Password
+ {
+ get => this[0];
+ set => this[0] = value;
+ }
+
+ [JsonPropertyName("token")]
+ public string? Token { get; set; }
+
+ public PrivateString? GetPassPrivString() => PrivateString.ToPrivateString(this[0], false);
+
+ public static IValidator<RegCompletionRequest> GetValidator()
+ {
+ InlineValidator<RegCompletionRequest> validator = new();
+
+ validator.RuleFor(x => x.Password)
+ .NotEmpty()
+ .SetValidator(AccountValidations.PasswordValidator);
+
+ validator.RuleFor(x => x.Token)
+ .NotEmpty()
+ //Must contain 2 periods for jwt limitation
+ .Must(static s => s!.Count(static s => s == '.') == 2)
+ //Guard length
+ .Length(20, 500)
+ .IllegalCharacters();
+
+ return validator;
+ }
+ }
}
} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
index 5f171cd..d524902 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
@@ -29,7 +29,6 @@ using System.ComponentModel.Design;
using FluentValidation.Results;
using VNLib.Utils;
-using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
using VNLib.Plugins.Attributes;
using VNLib.Plugins.Essentials.Users;
@@ -40,9 +39,11 @@ using VNLib.Plugins.Essentials.Accounts.SecurityProvider;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using VNLib.Plugins.Extensions.Loading.Routing;
+using VNLib.Utils.Memory;
namespace VNLib.Plugins.Essentials.Accounts
{
+
public sealed class AccountsEntryPoint : PluginBase
{
@@ -139,7 +140,6 @@ namespace VNLib.Plugins.Essentials.Accounts
ArgumentList args = new(cmd.Split(' '));
IUserManager Users = this.GetOrCreateSingleton<UserManager>();
- IPasswordHashingProvider Passwords = this.GetOrCreateSingleton<ManagedPasswordHashing>();
string? username = args.GetArgument("-u");
string? password = args.GetArgument("-p");
@@ -187,13 +187,18 @@ Commands:
privLevel = AccountUtil.MINIMUM_LEVEL;
}
- //Hash the password
- using PrivateString passHash = Passwords.Hash(password);
+ //Create the user creation request
+ UserCreationRequest creation = new()
+ {
+ EmailAddress = username,
+ InitialStatus = UserStatus.Active,
+ Privileges = privLevel,
+ Password = PrivateString.ToPrivateString(password, false)
+ };
+
//Create the user
- using IUser user = await Users.CreateUserAsync(username, passHash, privLevel);
-
- //Set active flag
- user.Status = UserStatus.Active;
+ using IUser user = await Users.CreateUserAsync(creation, null);
+
//Set local account
user.SetAccountOrigin(AccountUtil.LOCAL_ACCOUNT_ORIGIN);
@@ -210,9 +215,6 @@ Commands:
break;
}
- //Hash the password
- using PrivateString passHash = Passwords.Hash(password);
-
//Get the user
using IUser? user = await Users.GetUserFromEmailAsync(username);
@@ -223,7 +225,7 @@ Commands:
}
//Set the password
- await Users.UpdatePassAsync(user, passHash);
+ await Users.UpdatePasswordAsync(user, password);
Log.Information("Successfully reset password for {id}", username);
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
index 66a099e..2475f36 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
@@ -25,6 +25,7 @@
using System;
using System.Net;
using System.Text.Json;
+using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
@@ -32,7 +33,6 @@ using System.Text.Json.Serialization;
using FluentValidation;
using VNLib.Utils;
-using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
using VNLib.Utils.Extensions;
using VNLib.Plugins.Essentials.Users;
@@ -45,6 +45,7 @@ using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using static VNLib.Plugins.Essentials.Statics;
+
/*
* Password only log-ins should be immune to repeat attacks on the same backend, because sessions are
* guarunteed to be mutally exclusive on the same system, therefor a successful login cannot be repeated
@@ -71,8 +72,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
public const string MFA_ERROR_MESSAGE = "Invalid or expired request.";
private static readonly LoginMessageValidation LmValidator = new();
-
- private readonly IPasswordHashingProvider Passwords;
+
private readonly MFAConfig MultiFactor;
private readonly IUserManager Users;
private readonly FailedLoginLockout _lockout;
@@ -84,8 +84,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
uint maxLogins = config["max_login_attempts"].GetUInt32();
InitPathAndLog(path, pbase.Log);
-
- Passwords = pbase.GetOrCreateSingleton<ManagedPasswordHashing>();
+
Users = pbase.GetOrCreateSingleton<UserManager>();
MultiFactor = pbase.GetConfigElement<MFAConfig>();
_lockout = new(maxLogins, duration);
@@ -136,9 +135,8 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
}
-
- //Time to get the user
- using IUser? user = await Users.GetUserAndPassFromEmailAsync(loginMessage.UserName);
+
+ using IUser? user = await Users.GetUserFromEmailAsync(loginMessage.UserName);
//Make sure account exists
if (webm.Assert(user != null, INVALID_MESSAGE))
@@ -155,15 +153,25 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Only allow local accounts
- if (user.IsLocalAccount() && !PrivateString.IsNullOrEmpty(user.PassHash))
+ if (!user.IsLocalAccount())
{
- //If login return true, the response has been set and we should return
- if (LoginUser(entity, loginMessage, user, webm))
- {
- goto Cleanup;
- }
+ goto Failed;
+ }
+
+ //Validate password
+ if (await ValidatePasswordAsync(user, loginMessage, entity.EventCancellation) == false)
+ {
+ goto Failed;
+ }
+
+ //If login return true, the response has been set and we should return
+ if (LoginUser(entity, loginMessage, user, webm))
+ {
+ goto Cleanup;
}
+ Failed:
+
//Inc failed login count
_lockout.Increment(user, entity.RequestedTimeUtc);
webm.Result = INVALID_MESSAGE;
@@ -177,16 +185,19 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
Log.Warn(uue);
return VfReturnType.Error;
}
- }
+ }
+
+ private async Task<bool> ValidatePasswordAsync(IUser user, LoginMessage login, CancellationToken cancellation)
+ {
+ //Validate password against store
+ ERRNO valResult = await Users.ValidatePasswordAsync(user, login.Password!, PassValidateFlags.None, cancellation);
+
+ //Valid results are greater than 0;
+ return valResult > 0;
+ }
private bool LoginUser(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm)
{
- //Verify password before we tell the user the status of their account for security reasons
- if (!Passwords.Verify(user.PassHash!, loginMessage.Password))
- {
- return false;
- }
-
//Only allow active users
if (user.Status != UserStatus.Active)
{
@@ -195,13 +206,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return false;
}
- //Is the account restricted to a local network connection?
- if (user.LocalOnly && !entity.IsLocalConnection)
- {
- Log.Information("User {uid} attempted a login from a non-local network with the correct password. Access was denied", user.UserID);
- return false;
- }
-
//Reset flc for account, either the user will be authorized, or the mfa will be triggered, but the flc should be reset
user.ClearFailedLoginCount();
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
index d9cfd49..a156ccc 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
@@ -47,10 +47,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
internal sealed class MFAEndpoint : ProtectedWebEndpoint
{
public const int TOTP_URL_MAX_CHARS = 1024;
+ private const string CHECK_PASSWORD = "Please check your password";
private readonly IUserManager Users;
private readonly MFAConfig? MultiFactor;
- private readonly IPasswordHashingProvider Passwords;
public MFAEndpoint(PluginBase pbase, IConfigScope config)
{
@@ -59,7 +59,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
Users = pbase.GetOrCreateSingleton<UserManager>();
MultiFactor = pbase.GetConfigElement<MFAConfig>();
- Passwords = pbase.GetOrCreateSingleton<ManagedPasswordHashing>();
}
protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity)
@@ -124,7 +123,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Get the user entry
- using IUser? user = await Users.GetUserAndPassFromIDAsync(entity.Session.UserID);
+ using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
if (webm.Assert(user != null, "Please log-out and try again."))
{
@@ -134,16 +133,16 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//get the user's password challenge
using (PrivateString? password = (PrivateString?)mfaRequest.RootElement.GetPropString("password"))
{
- if (PrivateString.IsNullOrEmpty(password))
+ if (webm.Assert(!PrivateString.IsNullOrEmpty(password), CHECK_PASSWORD))
{
- webm.Result = "Please check your password";
return VirtualClose(entity, webm, HttpStatusCode.Unauthorized);
}
//Verify password against the user
- if (!user.VerifyPassword(password, Passwords))
+ ERRNO result = await Users.ValidatePasswordAsync(user, password, PassValidateFlags.None, entity.EventCancellation);
+
+ if (webm.Assert(result > 0, CHECK_PASSWORD))
{
- webm.Result = "Please check your password";
return VirtualClose(entity, webm, HttpStatusCode.Unauthorized);
}
}
@@ -192,7 +191,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
string? mfaType = request.RootElement.GetProperty("type").GetString();
//get the user
- using IUser? user = await Users.GetUserAndPassFromIDAsync(entity.Session.UserID);
+ using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
if (user == null)
{
return VfReturnType.NotFound;
@@ -204,16 +203,16 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
*/
using (PrivateString? password = (PrivateString?)request.RootElement.GetPropString("password"))
{
- if (PrivateString.IsNullOrEmpty(password))
+ if (webm.Assert(!PrivateString.IsNullOrEmpty(password), CHECK_PASSWORD))
{
- webm.Result = "Please check your password";
return VirtualClose(entity, webm, HttpStatusCode.Unauthorized);
}
//Verify password against the user
- if (!user.VerifyPassword(password, Passwords))
+ ERRNO result = await Users.ValidatePasswordAsync(user, password, PassValidateFlags.None, entity.EventCancellation);
+
+ if (webm.Assert(result > 0, CHECK_PASSWORD))
{
- webm.Result = "Please check your password";
return VirtualClose(entity, webm, HttpStatusCode.Unauthorized);
}
}
@@ -221,8 +220,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//Check for totp disable
if ("totp".Equals(mfaType, StringComparison.OrdinalIgnoreCase))
{
- //Clear the TOTP secret
+ //Clear the TOTP secret to disable it
user.MFASetTOTPSecret(null);
+
//write changes
await user.ReleaseAsync();
webm.Result = "Successfully disabled your TOTP authentication";
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
index 6f8cb77..33c72a7 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
@@ -29,16 +29,15 @@ using System.Text.Json.Serialization;
using FluentValidation;
+using VNLib.Utils;
using VNLib.Utils.Memory;
-using VNLib.Utils.Extensions;
using VNLib.Plugins.Essentials.Users;
using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Essentials.Accounts.MFA;
using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
-using VNLib.Plugins.Essentials.Endpoints;
-using VNLib.Plugins.Essentials.Accounts.MFA;
-
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
@@ -61,7 +60,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
internal sealed class PasswordChangeEndpoint : ProtectedWebEndpoint
{
private readonly IUserManager Users;
- private readonly IPasswordHashingProvider Passwords;
private readonly MFAConfig? mFAConfig;
private readonly IValidator<PasswordResetMesage> ResetMessValidator;
@@ -71,7 +69,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
InitPathAndLog(path, pbase.Log);
Users = pbase.GetOrCreateSingleton<UserManager>();
- Passwords = pbase.GetOrCreateSingleton<ManagedPasswordHashing>();
ResetMessValidator = GetMessageValidator();
mFAConfig = pbase.GetConfigElement<MFAConfig>();
}
@@ -95,10 +92,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return rules;
}
- /*
- * If mfa config
- */
-
protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
@@ -118,7 +111,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//get the user's entry in the table
- using IUser? user = await Users.GetUserAndPassFromIDAsync(entity.Session.UserID);
+ using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation);
if(webm.Assert(user != null, "An error has occured, please log-out and try again"))
{
@@ -131,10 +124,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualOk(entity, webm);
}
+ //Validate the user's current password
+ ERRNO isPassValid = await Users.ValidatePasswordAsync(user, pwReset.Current!, PassValidateFlags.None, entity.EventCancellation);
+
//Verify the user's old password
- if (!Passwords.Verify(user.PassHash, pwReset.Current.AsSpan()))
+ if (webm.Assert(isPassValid > 0, "Please check your current password"))
{
- webm.Result = "Please check your current password";
return VirtualOk(entity, webm);
}
@@ -160,11 +155,8 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//continue
}
- //Hash the user's new password
- using PrivateString newPassHash = Passwords.Hash(pwReset.NewPassword.AsSpan());
-
//Update the user's password
- if (!await Users.UpdatePassAsync(user, newPassHash))
+ if (!await Users.UpdatePasswordAsync(user, pwReset.NewPassword!, entity.EventCancellation))
{
//error
webm.Result = "Your password could not be updated";
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
index 0abe657..4f4c830 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
@@ -204,6 +204,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
EmailAddress = user.EmailAddress,
};
+ //Write to log
+ Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
+
//Close response, user is now logged-in
return VirtualOk(entity, webm);
}
@@ -422,8 +425,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
[JsonPropertyName("pubkey")]
public string? PublicKey { get; set; }
+
[JsonPropertyName("clientid")]
public string? ClientId { get; set; }
+
[JsonPropertyName("login")]
public string? LoginJwt { get; set; }
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
index d9c1703..0d1a714 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
@@ -757,7 +757,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
Domain = Config.CookieDomain,
Path = Config.CookiePath,
ValidFor = TimeSpan.Zero,
- SameSite = CookieSameSite.SameSite,
+ SameSite = CookieSameSite.Strict,
HttpOnly = true,
Secure = entity.IsSecure
};
@@ -779,7 +779,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
Domain = Config.CookieDomain,
Path = Config.CookiePath,
ValidFor = Config.AuthorizationValidFor,
- SameSite = CookieSameSite.SameSite,
+ SameSite = CookieSameSite.Strict,
HttpOnly = httpOnly,
Secure = entity.IsSecure
};
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs
index 18f4081..6a86cef 100644
--- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs
+++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs
@@ -27,7 +27,7 @@ using System.Text.Json.Serialization;
namespace VNLib.Plugins.Essentials.SocialOauth
{
- public sealed class OAuthAccessState : IOAuthAccessState
+ public class OAuthAccessState : IOAuthAccessState
{
///<inheritdoc/>
[JsonPropertyName("access_token")]
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientClaimManager.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientClaimManager.cs
new file mode 100644
index 0000000..1e5a82e
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientClaimManager.cs
@@ -0,0 +1,126 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: ClientClaimManager.cs
+*
+* ClientClaimManager.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.SocialOauth is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.SocialOauth is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+using VNLib.Hashing;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Essentials.Extensions;
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+ internal sealed record class ClientClaimManager(ICookieController Cookies)
+ {
+ const string SESSION_SIG_KEY_NAME = "soa.sig";
+ const int SIGNING_KEY_SIZE = 32;
+
+ public bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim)
+ {
+ claim = null;
+
+ string? cookieValue = Cookies.GetCookie(entity);
+
+ //Try to get the cookie
+ if (cookieValue == null)
+ {
+ return false;
+ }
+
+ //Recover the signing key from the user's session
+ string sigKey = entity.Session[SESSION_SIG_KEY_NAME];
+ Span<byte> key = stackalloc byte[SIGNING_KEY_SIZE + 16];
+
+ ERRNO keySize = VnEncoding.Base64UrlDecode(sigKey, key);
+
+ if (keySize < 1)
+ {
+ return false;
+ }
+
+ try
+ {
+ //Try to parse the jwt
+ using JsonWebToken jwt = JsonWebToken.Parse(cookieValue);
+
+ //Verify the jwt
+ if (!jwt.Verify(key[..(int)keySize], HashAlg.SHA256))
+ {
+ return false;
+ }
+
+ //Recover the clam from the jwt
+ claim = jwt.GetPayload<LoginClaim>();
+
+ //Verify the expiration time
+ return claim.ExpirationSeconds > entity.RequestedTimeUtc.ToUnixTimeSeconds();
+ }
+ catch (FormatException)
+ {
+ //JWT was corrupted and could not be parsed
+ return false;
+ }
+ finally
+ {
+ MemoryUtil.InitializeBlock(key);
+ }
+ }
+
+ public void ClearClaimData(HttpEntity entity)
+ {
+ //Remove the upgrade cookie
+ Cookies.ExpireCookie(entity, false);
+
+ //Clear the signing key from the session
+ entity.Session[SESSION_SIG_KEY_NAME] = null!;
+ }
+
+ public void SignAndSetCookie(HttpEntity entity, LoginClaim claim)
+ {
+ //Setup Jwt
+ using JsonWebToken jwt = new();
+
+ //Write claim body, we dont need a header
+ jwt.WritePayload(claim, Statics.SR_OPTIONS);
+
+ //Generate signing key
+ byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE);
+
+ //Sign the jwt
+ jwt.Sign(sigKey, HashAlg.SHA256);
+
+ Cookies.SetCookie(entity, jwt.Compile());
+
+ //Encode and store the signing key in the clien't session
+ entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase64UrlSafeString(sigKey, false);
+
+ //Clear the signing key
+ MemoryUtil.InitializeBlock(sigKey.AsSpan());
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs
index 2136d8a..f64d1c4 100644
--- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs
+++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs
@@ -56,10 +56,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints
* Creates a user-id from the users discord username, that is repeatable
* and matches the Auth0 social user-id format
*/
- private static string GetUserIdFromPlatform(string userName)
- {
- return ManagedHash.ComputeHash($"discord|{userName}", HashAlg.SHA1, HashEncodingMode.Hexadecimal);
- }
+ private static string GetUserIdFromPlatform(string userName) => $"discord|{userName}";
///<inheritdoc/>
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs
index 1fd691b..e8abf5a 100644
--- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs
+++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs
@@ -65,19 +65,16 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints
.WithEndpoint<GetEmailRequest>()
.WithMethod(Method.Get)
.WithUrl(UserEmailUrl)
- .WithHeader("Authorization", at => $"{at.AccessToken.Type} {at.AccessToken.Token}")
- .WithHeader("Accept", GITHUB_V3_ACCEPT);
+ .WithHeader("Accept", GITHUB_V3_ACCEPT)
+ .WithHeader("Authorization", at => $"{at.AccessToken.Type} {at.AccessToken.Token}");
}
-
+
/*
* Creates a repeatable, and source specific user id for
* GitHub users. This format is identical to the algorithim used
* in the Auth0 Github connection, so it is compatible with Auth0
*/
- private static string GetUserIdFromPlatform(int userId)
- {
- return ManagedHash.ComputeHash($"github|{userId}", HashAlg.SHA1, HashEncodingMode.Hexadecimal);
- }
+ private static string GetUserIdFromPlatform(int userId) => $"github|{userId}";
protected override async Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken)
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginClaim.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginClaim.cs
new file mode 100644
index 0000000..fa425cc
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginClaim.cs
@@ -0,0 +1,73 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: LoginClaim.cs
+*
+* LoginClaim.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.SocialOauth is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.SocialOauth is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+using VNLib.Hashing;
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Plugins.Essentials.Accounts;
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+ internal sealed class LoginClaim : IClientSecInfo
+ {
+ [JsonPropertyName("exp")]
+ public long ExpirationSeconds { get; set; }
+
+ [JsonPropertyName("iat")]
+ public long IssuedAtTime { get; set; }
+
+ [JsonPropertyName("nonce")]
+ public string? Nonce { get; set; }
+
+ [JsonPropertyName("locallanguage")]
+ public string? LocalLanguage { get; set; }
+
+ [JsonPropertyName("pubkey")]
+ public string? PublicKey { get; set; }
+
+ [JsonPropertyName("clientid")]
+ public string? ClientId { get; set; }
+
+
+ public void ComputeNonce(int nonceSize)
+ {
+ //Alloc nonce buffer
+ using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc(nonceSize);
+ try
+ {
+ //fill the buffer with random data
+ RandomHash.GetRandomBytes(buffer.Span);
+
+ //Base32-Encode nonce and save it
+ Nonce = VnEncoding.ToBase64UrlSafeString(buffer.Span, false);
+ }
+ finally
+ {
+ MemoryUtil.InitializeBlock(buffer.Span);
+ }
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginUriBuilder.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginUriBuilder.cs
new file mode 100644
index 0000000..95334c6
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginUriBuilder.cs
@@ -0,0 +1,127 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: LoginUriBuilder.cs
+*
+* LoginUriBuilder.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.SocialOauth is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.SocialOauth is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Text;
+using System.Runtime.InteropServices;
+
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Accounts;
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+ /*
+ * Construct the client's redirect url based on their login claim, which contains
+ * a public key which can be used to encrypt the url so that only the client
+ * private-key holder can decrypt the url and redirect themselves to the
+ * target OAuth website.
+ *
+ * The result is an encrypted nonce that should guard against replay attacks and MITM
+ */
+
+ internal sealed record class LoginUriBuilder(OauthClientConfig Config)
+ {
+ private string? redirectUrl;
+ private string? nonce;
+ private Encoding _encoding = Encoding.UTF8;
+
+ public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path)
+ {
+ //Alloc stack buffer for url
+ Span<char> buffer = stackalloc char[1024];
+
+ //buffer writer for easier syntax
+ ForwardOnlyWriter<char> writer = new(buffer);
+ //first build the redirect url to re-encode it
+ writer.Append(scheme);
+ writer.Append("://");
+ //Create redirect url (current page, default action is to authorize the client)
+ writer.Append(authority);
+ writer.Append(path);
+ //url encode the redirect path and save it for later
+ redirectUrl = Uri.EscapeDataString(writer.ToString());
+
+ return this;
+ }
+
+ public LoginUriBuilder WithEncoding(Encoding encoding)
+ {
+ _encoding = encoding;
+ return this;
+ }
+
+ public LoginUriBuilder WithNonce(string base32Nonce)
+ {
+ nonce = base32Nonce;
+ return this;
+ }
+
+ public string Encrypt(HttpEntity client, IClientSecInfo secInfo)
+ {
+ //Alloc buffer and split it into binary and char buffers
+ using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(8000);
+
+ Span<byte> binBuffer = buffer.Span[2048..];
+ Span<char> charBuffer = MemoryMarshal.Cast<byte, char>(buffer.Span[..2048]);
+
+
+ /*
+ * Build the character uri so we can encode it to binary,
+ * encrypt it and return it to the client
+ */
+
+ ForwardOnlyWriter<char> writer = new(charBuffer);
+
+ //Append the config redirect path
+ writer.Append(Config.AccessCodeUrl.OriginalString);
+ //begin query arguments
+ writer.Append("&client_id=");
+ writer.Append(Config.ClientID.Value);
+ //add the redirect url
+ writer.Append("&redirect_uri=");
+ writer.Append(redirectUrl);
+ //Append the state parameter
+ writer.Append("&state=");
+ writer.Append(nonce);
+
+ //Collect the written character data
+ ReadOnlySpan<char> url = writer.AsSpan();
+
+ //Separate bin buffers for encryption and encoding
+ Span<byte> encryptionBuffer = binBuffer[1024..];
+ Span<byte> encodingBuffer = binBuffer[..1024];
+
+ //Encode the url to binary
+ int byteCount = _encoding.GetBytes(url, encodingBuffer);
+
+ //Encrypt the binary data
+ ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer);
+
+ //base64 encode the encrypted
+ return Convert.ToBase64String(encryptionBuffer[0..(int)count]);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs
index d053fc8..561962a 100644
--- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs
+++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs
@@ -24,24 +24,18 @@
using System;
using System.Net;
-using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
-using System.Runtime.InteropServices;
-using System.Diagnostics.CodeAnalysis;
using FluentValidation;
using RestSharp;
using VNLib.Net.Http;
-using VNLib.Hashing;
-using VNLib.Hashing.IdentityUtility;
using VNLib.Utils;
-using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
using VNLib.Utils.Extensions;
using VNLib.Net.Rest.Client.Construction;
@@ -67,10 +61,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth
const string AUTH_ERROR_MESSAGE = "You have no pending authentication requests.";
const string AUTH_GRANT_SESSION_NAME = "auth";
- const string SESSION_SIG_KEY_NAME = "soa.sig";
const string SESSION_TOKEN_KEY_NAME = "soa.tkn";
const string CLAIM_COOKIE_NAME = "extern-claim";
- const int SIGNING_KEY_SIZE = 32;
+
/// <summary>
/// The client configuration struct passed during base class construction
@@ -78,7 +71,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth
protected virtual OauthClientConfig Config { get; }
///<inheritdoc/>
- protected override ProtectionSettings EndpointProtectionSettings { get; } = new();
+ protected override ProtectionSettings EndpointProtectionSettings { get; }
/// <summary>
/// The site adapter used to make requests to the OAuth2 provider
@@ -90,14 +83,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth
/// </summary>
protected IUserManager Users { get; }
- /// <summary>
- /// The password hashing provider used to hash user passwords
- /// </summary>
- protected IPasswordHashingProvider Passwords { get; }
-
private readonly IValidator<LoginClaim> ClaimValidator;
private readonly IValidator<string> NonceValidator;
private readonly IValidator<AccountData> AccountDataValidator;
+ private readonly ClientClaimManager _claims;
protected SocialOauthBase(PluginBase plugin, IConfigScope config)
{
@@ -112,7 +101,18 @@ namespace VNLib.Plugins.Essentials.SocialOauth
InitPathAndLog(Config.EndpointPath, plugin.Log);
Users = plugin.GetOrCreateSingleton<UserManager>();
- Passwords = plugin.GetOrCreateSingleton<ManagedPasswordHashing>();
+
+
+ //Setup cookie controller and claim manager
+ SingleCookieController cookies = new(CLAIM_COOKIE_NAME, Config.InitClaimValidFor)
+ {
+ Secure = true,
+ HttpOnly = true,
+ SameSite = CookieSameSite.None,
+ Path = Path
+ };
+
+ _claims = new(cookies);
//Define the site adapter
SiteAdapter = new();
@@ -128,7 +128,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth
.WithParameter("grant_type", "authorization_code")
.WithParameter("code", r => r.Code)
.WithParameter("redirect_uri", r => r.RedirectUrl);
-
}
private static IValidator<LoginClaim> GetClaimValidator()
@@ -197,9 +196,15 @@ namespace VNLib.Plugins.Essentials.SocialOauth
{
//Create new request object
GetTokenRequest req = new(code, $"{ev.Server.RequestUri.Scheme}://{ev.Server.RequestUri.Authority}{Path}");
-
+
//Execute request and attempt to recover the authorization response
- OAuthAccessState? response = await SiteAdapter.ExecuteAsync(req, cancellationToken).AsJson<OAuthAccessState>();
+ Oauth2TokenResult? response = await SiteAdapter.ExecuteAsync(req, cancellationToken).AsJson<Oauth2TokenResult>();
+
+ if(response?.Error != null)
+ {
+ Log.Debug("Error result from {conf} code {code} description: {err}", Config.AccountOrigin, response.Error, response.ErrorDescription);
+ return null;
+ }
return response;
}
@@ -262,17 +267,16 @@ namespace VNLib.Plugins.Essentials.SocialOauth
//Build the redirect uri
webm.Result = new LoginUriBuilder(Config)
.WithEncoding(entity.Server.Encoding)
- .WithUrl(entity.IsSecure ? "https" : "http", entity.Server.RequestUri.Authority, Path)
+ .WithUrl(entity.Server.RequestUri.Scheme, entity.Server.RequestUri.Authority, Path)
.WithNonce(claim.Nonce!)
.Encrypt(entity, claim);
//Sign and set the claim cookie
- SignAndSetCookie(entity, claim);
+ _claims.SignAndSetCookie(entity, claim);
webm.Success = true;
//Response
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
/*
@@ -295,16 +299,16 @@ namespace VNLib.Plugins.Essentials.SocialOauth
//Check for security navigation headers. This should be a browser redirect,
if (!entity.Server.IsNavigation() || !entity.Server.IsUserInvoked())
{
- ClearClaimData(entity);
+ _claims.ClearClaimData(entity);
//The connection was not a browser redirect
entity.Redirect(RedirectType.Temporary, $"{Path}?result=bad_sec");
return VfReturnType.VirtualSkip;
}
//Try to get the claim from the state parameter
- if (!VerifyAndGetClaim(entity, out LoginClaim? claim))
+ if (!_claims.VerifyAndGetClaim(entity, out LoginClaim? claim))
{
- ClearClaimData(entity);
+ _claims.ClearClaimData(entity);
entity.Redirect(RedirectType.Temporary, $"{Path}?result=expired");
return VfReturnType.VirtualSkip;
}
@@ -312,7 +316,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth
//Confirm the nonce matches the claim
if (string.CompareOrdinal(claim.Nonce, state) != 0)
{
- ClearClaimData(entity);
+ _claims.ClearClaimData(entity);
entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid");
return VfReturnType.VirtualSkip;
}
@@ -323,7 +327,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth
//Token may be null
if (token == null)
{
- ClearClaimData(entity);
+ _claims.ClearClaimData(entity);
entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid");
return VfReturnType.VirtualSkip;
}
@@ -335,7 +339,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth
entity.Session.SetObject(SESSION_TOKEN_KEY_NAME, token);
//Sign and set cookie
- SignAndSetCookie(entity, claim);
+ _claims.SignAndSetCookie(entity, claim);
//Prepare redirect
entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={claim.Nonce}");
@@ -345,7 +349,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth
//Check to see if there was an error code set
if (entity.QueryArgs.TryGetNonEmptyValue("error", out string? errorCode))
{
- ClearClaimData(entity);
+ _claims.ClearClaimData(entity);
Log.Debug("{Type} error {err}:{des}", Config.AccountOrigin, errorCode, entity.QueryArgs["error_description"]);
entity.Redirect(RedirectType.Temporary, $"{Path}?result=error");
return VfReturnType.VirtualSkip;
@@ -362,51 +366,44 @@ namespace VNLib.Plugins.Essentials.SocialOauth
protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
-
+
//Get the finalization message
using JsonDocument? request = await entity.GetJsonFromFileAsync();
if (webm.Assert(request != null, "Request message is required"))
{
- entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
- return VfReturnType.VirtualSkip;
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
//Recover the nonce
string? base32Nonce = request.RootElement.GetPropString("nonce");
- if(webm.Assert(base32Nonce != null, message: "Nonce parameter is required"))
+ if(webm.Assert(base32Nonce != null, "Nonce parameter is required"))
{
- entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
- return VfReturnType.VirtualSkip;
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
}
//Validate nonce
if (!NonceValidator.Validate(base32Nonce, webm))
{
- entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
- return VfReturnType.VirtualSkip;
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
}
//Recover the access token
- bool cookieValid = VerifyAndGetClaim(entity, out LoginClaim? claim);
-
- if (webm.Assert(cookieValid, AUTH_ERROR_MESSAGE))
+ if (webm.Assert(_claims.VerifyAndGetClaim(entity, out LoginClaim? claim), AUTH_ERROR_MESSAGE))
{
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
//We can clear the client's access claim
- ClearClaimData(entity);
+ _claims.ClearClaimData(entity);
//Confirm nonce matches the client's nonce string
bool nonceValid = string.CompareOrdinal(claim.Nonce, base32Nonce) == 0;
if (webm.Assert(nonceValid, AUTH_ERROR_MESSAGE))
{
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
//Safe to recover the access token
@@ -417,90 +414,99 @@ namespace VNLib.Plugins.Essentials.SocialOauth
if(webm.Assert(userLogin?.UserId != null, AUTH_ERROR_MESSAGE))
{
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
+
+ //Convert the platform user-id to a database-safe user-id
+ string computedId = Users.ComputeSafeUserId(userLogin.UserId!);
//Fetch the user from the database
- IUser? user = await Users.GetUserFromIDAsync(userLogin.UserId, entity.EventCancellation);
+ IUser? user = await Users.GetUserFromIDAsync(computedId, entity.EventCancellation);
+ /*
+ * If a user is not found, we can optionally create a new user account
+ * if the configuration allows it.
+ */
if(user == null)
{
+ //make sure registration is enabled
+ if (webm.Assert(Config.AllowRegistration, AUTH_ERROR_MESSAGE))
+ {
+ return VirtualOk(entity, webm);
+ }
+
//Get the clients personal info to being login process
AccountData? userAccount = await GetAccountDataAsync(token, entity.EventCancellation);
if (webm.Assert(userAccount != null, AUTH_ERROR_MESSAGE))
{
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
//Validate the account data
if (webm.Assert(AccountDataValidator.Validate(userAccount).IsValid, AUTH_ERROR_MESSAGE))
{
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ return VirtualOk(entity, webm);
}
- //make sure registration is enabled
- if (webm.Assert(Config.AllowRegistration, AUTH_ERROR_MESSAGE))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
-
- //Generate a new random passowrd incase the user wants to use a local account to log in sometime in the future
- using PrivateString passhash = Passwords.GetRandomPassword(Config.RandomPasswordSize);
- try
- {
- //Create the user with the specified email address, minimum privilage level, and an empty password
- user = await Users.CreateUserAsync(userLogin.UserId!, userAccount.EmailAddress, AccountUtil.MINIMUM_LEVEL, passhash, entity.EventCancellation);
- //Set active status
- user.Status = UserStatus.Active;
- //Store the new profile
- user.SetProfile(userAccount);
- //Set the account creation origin
- user.SetAccountOrigin(Config.AccountOrigin);
- }
- catch(UserCreationFailedException)
+ //See if user by email address exists
+ user = await Users.GetUserFromEmailAsync(userAccount.EmailAddress!, entity.EventCancellation);
+
+ if (user == null)
{
- Log.Warn("Failed to create new user from new OAuth2 login, because a creation exception occured");
- webm.Result = "Please try again later";
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
+ //Create the new user account
+ UserCreationRequest creation = new()
+ {
+ EmailAddress = userAccount.EmailAddress!,
+ InitialStatus = UserStatus.Active,
+ };
+
+ try
+ {
+ //Create the user with the specified email address, minimum privilage level, and an empty password
+ user = await Users.CreateUserAsync(creation, computedId, entity.EventCancellation);
+
+ //Store the new profile and origin
+ user.SetProfile(userAccount);
+ user.SetAccountOrigin(Config.AccountOrigin);
+ }
+ catch (UserCreationFailedException)
+ {
+ Log.Warn("Failed to create new user from new OAuth2 login, because a creation exception occured");
+ webm.Result = "Please try again later";
+ return VirtualOk(entity, webm);
+ }
+
+ //Skip check since we just created the user
+ goto Authorize;
}
+
+ /*
+ * User account already exists via email address but not
+ * user-id
+ */
}
- else
+
+ //Make sure local accounts are allowed
+ if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE))
{
- //Check for local only
- if (webm.Assert(!user.LocalOnly, AUTH_ERROR_MESSAGE))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
+ return VirtualOk(entity, webm);
+ }
- //Make sure local accounts are allowed
- if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
+ //Reactivate inactive accounts
+ if (user.Status == UserStatus.Inactive)
+ {
+ user.Status = UserStatus.Active;
+ }
- //Reactivate inactive accounts
- if(user.Status == UserStatus.Inactive)
- {
- user.Status = UserStatus.Active;
- }
-
- //Make sure the account is active
- if(webm.Assert(user.Status == UserStatus.Active, AUTH_ERROR_MESSAGE))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
+ //Make sure the account is active
+ if (webm.Assert(user.Status == UserStatus.Active, AUTH_ERROR_MESSAGE))
+ {
+ return VirtualOk(entity, webm);
}
- //Finalze login
+ Authorize:
+
try
{
//Generate authoization
@@ -517,8 +523,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth
//Set the success flag
webm.Success = true;
+
//Write to log
- Log.Debug("Successful login for user {uid}... from {ip}", user.UserID[..8], entity.TrustedRemoteIp);
+ Log.Debug("Successful social login for user {uid}... from {ip}", user.UserID[..8], entity.TrustedRemoteIp);
+
//release the user
await user.ReleaseAsync();
}
@@ -541,248 +549,24 @@ namespace VNLib.Plugins.Essentials.SocialOauth
//destroy any login data on failure
entity.InvalidateLogin();
- Log.Error(uue);
+ Log.Error("Failed to update the user's account cause:\n{err}",uue);
}
finally
{
user.Dispose();
}
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
-
- private static bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim)
- {
- claim = null;
-
- //Try to get the cookie
- if (!entity.Server.GetCookie(CLAIM_COOKIE_NAME, out string? cookieValue))
- {
- return false;
- }
-
- //Recover the signing key from the user's session
- string sigKey = entity.Session[SESSION_SIG_KEY_NAME];
- byte[]? key = VnEncoding.FromBase32String(sigKey);
-
- if (key == null)
- {
- return false;
- }
-
- try
- {
- //Try to parse the jwt
- using JsonWebToken jwt = JsonWebToken.Parse(cookieValue);
-
- //Verify the jwt
- if (!jwt.Verify(key, HashAlg.SHA256))
- {
- return false;
- }
-
- //Recover the clam from the jwt
- claim = jwt.GetPayload<LoginClaim>();
-
- //Verify the expiration time
- return claim.ExpirationSeconds > entity.RequestedTimeUtc.ToUnixTimeSeconds();
- }
- catch (FormatException)
- {
- //JWT was corrupted and could not be parsed
- return false;
- }
- finally
- {
- MemoryUtil.InitializeBlock(key.AsSpan());
- }
- }
-
- private static void ClearClaimData(HttpEntity entity)
- {
- //Remove the upgrade cookie
- if (entity.Server.RequestCookies.ContainsKey(CLAIM_COOKIE_NAME))
- {
- //Expire cookie
- HttpCookie cookie = new(CLAIM_COOKIE_NAME, string.Empty)
- {
- Secure = true,
- HttpOnly = true,
- ValidFor = TimeSpan.Zero,
- SameSite = CookieSameSite.SameSite
- };
-
- entity.Server.SetCookie(in cookie);
- }
-
- //Clear the signing key from the session
- entity.Session[SESSION_SIG_KEY_NAME] = null!;
- }
-
- private void SignAndSetCookie(HttpEntity entity, LoginClaim claim)
- {
- //Setup Jwt
- using JsonWebToken jwt = new();
-
- //Write claim body, we dont need a header
- jwt.WritePayload(claim, Statics.SR_OPTIONS);
-
- //Generate signing key
- byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE);
-
- //Sign the jwt
- jwt.Sign(sigKey, HashAlg.SHA256);
-
- //Build and set cookie
- HttpCookie cookie = new(CLAIM_COOKIE_NAME, jwt.Compile())
- {
- Secure = true,
- HttpOnly = true,
- ValidFor = Config.InitClaimValidFor,
- SameSite = CookieSameSite.SameSite,
- Path = this.Path
- };
-
- entity.Server.SetCookie(in cookie);
-
- //Encode and store the signing key in the clien't session
- entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase32String(sigKey);
-
- //Clear the signing key
- MemoryUtil.InitializeBlock(sigKey.AsSpan());
- }
-
- /*
- * Construct the client's redirect url based on their login claim, which contains
- * a public key which can be used to encrypt the url so that only the client
- * private-key holder can decrypt the url and redirect themselves to the
- * target OAuth website.
- *
- * The result is an encrypted nonce that should guard against replay attacks and MITM
- */
-
- sealed record class LoginUriBuilder(OauthClientConfig Config)
- {
- private string? redirectUrl;
- private string? nonce;
- private Encoding _encoding = Encoding.UTF8;
-
- public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path)
- {
- //Alloc stack buffer for url
- Span<char> buffer = stackalloc char[1024];
-
- //buffer writer for easier syntax
- ForwardOnlyWriter<char> writer = new(buffer);
- //first build the redirect url to re-encode it
- writer.Append(scheme);
- writer.Append("://");
- //Create redirect url (current page, default action is to authorize the client)
- writer.Append(authority);
- writer.Append(path);
- //url encode the redirect path and save it for later
- redirectUrl = Uri.EscapeDataString(writer.ToString());
-
- return this;
- }
-
- public LoginUriBuilder WithEncoding(Encoding encoding)
- {
- _encoding = encoding;
- return this;
- }
-
- public LoginUriBuilder WithNonce(string base32Nonce)
- {
- nonce = base32Nonce;
- return this;
- }
-
- public string Encrypt(HttpEntity client, IClientSecInfo secInfo)
- {
- //Alloc buffer and split it into binary and char buffers
- using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(8000);
-
- Span<byte> binBuffer = buffer.Span[2048..];
- Span<char> charBuffer = MemoryMarshal.Cast<byte, char>(buffer.Span[..2048]);
-
-
- /*
- * Build the character uri so we can encode it to binary,
- * encrypt it and return it to the client
- */
-
- ForwardOnlyWriter<char> writer = new(charBuffer);
-
- //Append the config redirect path
- writer.Append(Config.AccessCodeUrl.OriginalString);
- //begin query arguments
- writer.Append("&client_id=");
- writer.Append(Config.ClientID.Value);
- //add the redirect url
- writer.Append("&redirect_uri=");
- writer.Append(redirectUrl);
- //Append the state parameter
- writer.Append("&state=");
- writer.Append(nonce);
-
- //Collect the written character data
- ReadOnlySpan<char> url = writer.AsSpan();
-
- //Separate bin buffers for encryption and encoding
- Span<byte> encryptionBuffer = binBuffer[1024..];
- Span<byte> encodingBuffer = binBuffer[..1024];
-
- //Encode the url to binary
- int byteCount = _encoding.GetBytes(url, encodingBuffer);
-
- //Encrypt the binary data
- ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer);
-
- //base64 encode the encrypted
- return Convert.ToBase64String(encryptionBuffer[0..(int)count]);
- }
+ return VirtualOk(entity, webm);
}
+
-
- sealed class LoginClaim : IClientSecInfo
+ sealed class Oauth2TokenResult: OAuthAccessState
{
- [JsonPropertyName("exp")]
- public long ExpirationSeconds { get; set; }
-
- [JsonPropertyName("iat")]
- public long IssuedAtTime { get; set; }
-
- [JsonPropertyName("nonce")]
- public string? Nonce { get; set; }
-
- [JsonPropertyName("locallanguage")]
- public string? LocalLanguage { get; set; }
-
- [JsonPropertyName("pubkey")]
- public string? PublicKey { get; set; }
-
- [JsonPropertyName("clientid")]
- public string? ClientId { get; set; }
-
-
- public void ComputeNonce(int nonceSize)
- {
- //Alloc nonce buffer
- using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc(nonceSize);
- try
- {
- //fill the buffer with random data
- RandomHash.GetRandomBytes(buffer.Span);
+ [JsonPropertyName("error")]
+ public string? Error { get; set; }
- //Base32-Encode nonce and save it
- Nonce = VnEncoding.ToBase32String(buffer.Span);
- }
- finally
- {
- MemoryUtil.InitializeBlock(buffer.Span);
- }
- }
+ [JsonPropertyName("error_description")]
+ public string? ErrorDescription { get; set; }
}
+
}
}
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs
index 7ebb37e..0ccda69 100644
--- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs
+++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.SocialOauth
@@ -27,8 +27,6 @@ using FluentValidation;
using VNLib.Plugins.Essentials.Accounts;
using VNLib.Plugins.Extensions.Validation;
-#nullable enable
-
namespace VNLib.Plugins.Essentials.SocialOauth.Validators
{
internal class AccountDataValidator : AbstractValidator<AccountData>
@@ -39,36 +37,47 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Validators
.NotEmpty()
.WithMessage("Your account does not have an email address assigned to it");
- RuleFor(t => t.EmailAddress)
- .EmailAddress()
- .WithMessage("Your account does not have a valid email address assigned to it");
-
- //Validate city
- RuleFor(t => t.City).MaximumLength(50);
- RuleFor(t => t.City).AlphaOnly();
-
- RuleFor(t => t.Company).MaximumLength(50);
- RuleFor(t => t.Company).SpecialCharacters();
+ RuleFor(t => t.City)
+ .MaximumLength(35)
+ .AlphaOnly()
+ .When(t => t.City?.Length > 0);
- RuleFor(t => t.First).MaximumLength(35);
- RuleFor(t => t.First).AlphaOnly();
+ RuleFor(t => t.Company)
+ .MaximumLength(50)
+ .SpecialCharacters()
+ .When(t => t.Company?.Length > 0);
- RuleFor(t => t.Last).MaximumLength(35);
- RuleFor(t => t.Last).AlphaOnly();
+ //Require a first and last names to be set together
+ When(t => t.First?.Length > 0 || t.Last?.Length > 0, () =>
+ {
+ RuleFor(t => t.First)
+ .Length(1, 35)
+ .AlphaOnly();
+ RuleFor(t => t.Last)
+ .Length(1, 35)
+ .AlphaOnly();
+ });
RuleFor(t => t.PhoneNumber)
- .EmptyPhoneNumber()
+ .PhoneNumber()
+ .When(t => t.PhoneNumber?.Length > 0)
.OverridePropertyName("Phone");
//State must be 2 characters for us states if set
- RuleFor(t => t.State).Length(t => t.State?.Length != 0 ? 2 : 0);
+ RuleFor(t => t.State)
+ .Length(2)
+ .When(t => t.State?.Length > 0);
- RuleFor(t => t.Street).MaximumLength(50);
- RuleFor(t => t.Street).AlphaNumericOnly();
+ RuleFor(t => t.Street)
+ .AlphaNumericOnly()
+ .MaximumLength(50)
+ .When(t => t.Street?.Length > 0);
- RuleFor(t => t.Zip).NumericOnly();
//Allow empty zip codes, but if one is defined, is must be less than 7 characters
- RuleFor(t => t.Zip).Length(ad => ad.Zip?.Length != 0 ? 7 : 0);
+ RuleFor(t => t.Zip)
+ .NumericOnly()
+ .MaximumLength(7)
+ .When(t => t.Zip?.Length > 0);
}
}
}