/*
* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
* File: LoginEndpoint.cs
*
* LoginEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Essentials.Accounts 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.Accounts 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.Net;
using System.Text.Json;
using System.Threading.Tasks;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
using FluentValidation;
using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
using VNLib.Utils.Extensions;
using VNLib.Plugins.Essentials.Users;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Essentials.Extensions;
using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Essentials.Accounts.MFA;
using VNLib.Plugins.Essentials.Accounts.Validators;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using static VNLib.Plugins.Essentials.Statics;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
///
/// Provides an authentication endpoint for user-accounts
///
[ConfigurationName("login_endpoint")]
internal sealed class LoginEndpoint : UnprotectedWebEndpoint
{
public const string INVALID_MESSAGE = "Please check your email or password.";
public const string LOCKED_ACCOUNT_MESSAGE = "You have been timed out, please try again later";
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 uint MaxFailedLogins;
private readonly TimeSpan FailedCountTimeout;
public LoginEndpoint(PluginBase pbase, IConfigScope config)
{
string? path = config["path"].GetString();
FailedCountTimeout = config["failed_count_timeout_sec"].GetTimeSpan(TimeParseType.Seconds);
MaxFailedLogins = config["failed_count_max"].GetUInt32();
InitPathAndLog(path, pbase.Log);
Passwords = pbase.GetPasswords();
Users = pbase.GetOrCreateSingleton();
MultiFactor = pbase.GetConfigElement();
}
private class MfaUpgradeWebm : ValErrWebMessage
{
[JsonPropertyName("pwtoken")]
public string? PasswordToken { get; set; }
[JsonPropertyName("mfa")]
public bool? MultiFactorUpgrade { get; set; } = null;
}
protected async override ValueTask PostAsync(HttpEntity entity)
{
//Conflict if user is logged in
if (entity.IsClientAuthorized(AuthorzationCheckLevel.Any))
{
entity.CloseResponse(HttpStatusCode.Conflict);
return VfReturnType.VirtualSkip;
}
//If mfa is enabled, allow processing via mfa
if (MultiFactor != null)
{
if (entity.QueryArgs.ContainsKey("mfa"))
{
return await ProcessMfaAsync(entity);
}
}
return await ProccesLoginAsync(entity);
}
private async ValueTask ProccesLoginAsync(HttpEntity entity)
{
MfaUpgradeWebm webm = new();
try
{
//Make sure the id is regenerated (or upgraded if successful login)
entity.Session.RegenID();
using LoginMessage? loginMessage = await entity.GetJsonFromFileAsync(SR_OPTIONS);
if (webm.Assert(loginMessage != null, "Invalid request data"))
{
entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
return VfReturnType.VirtualSkip;
}
//validate the message
if (!LmValidator.Validate(loginMessage, webm))
{
entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
return VfReturnType.VirtualSkip;
}
//Time to get the user
using IUser? user = await Users.GetUserAndPassFromEmailAsync(loginMessage.UserName);
//Make sure account exists
if (webm.Assert(user != null, INVALID_MESSAGE))
{
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
//Make sure the account has not been locked out
if (webm.Assert(!UserLoginLocked(user, entity.RequestedTimeUtc), LOCKED_ACCOUNT_MESSAGE))
{
goto Cleanup;
}
//Only allow local accounts
if (user.IsLocalAccount() && !PrivateString.IsNullOrEmpty(user.PassHash))
{
//If login return true, the response has been set and we should return
if (LoginUser(entity, loginMessage, user, webm))
{
goto Cleanup;
}
}
//Inc failed login count
user.FailedLoginIncrement();
webm.Result = INVALID_MESSAGE;
Cleanup:
await user.ReleaseAsync();
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
catch (UserUpdateException uue)
{
Log.Warn(uue);
return VfReturnType.Error;
}
}
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, new PrivateString(loginMessage.Password, false)))
{
return false;
}
//Reset flc for account
user.FailedLoginCount(0);
try
{
if (user.Status == UserStatus.Active)
{
//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;
}
//get the new upgrade jwt string
Tuple? message = user.MFAGetUpgradeIfEnabled(MultiFactor, loginMessage);
//if message is null, mfa was not enabled or could not be prepared
if (message != null)
{
//Store the base64 signature
entity.Session.MfaUpgradeSecret(message.Item2);
//send challenge message to client
webm.Result = message.Item1;
webm.Success = true;
webm.MultiFactorUpgrade = true;
return true;
}
//Set password token
webm.PasswordToken = null;
//Elevate the login status of the session to reflect the user's status
entity.GenerateAuthorization(loginMessage, user, webm);
//Send the Username (since they already have it)
webm.Result = new AccountData()
{
EmailAddress = user.EmailAddress,
};
webm.Success = true;
//Write to log
Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
return true;
}
else
{
//This is an unhandled case, and should never happen, but just incase write a warning to the log
Log.Warn("Account {uid} has invalid status key and a login was attempted from {ip}", user.UserID, entity.TrustedRemoteIp);
return false;
}
}
/*
* Account auhorization may throw excetpions if the configuration does not
* match the client, or the client sent invalid or malicous data and
* it could not grant authorization
*/
catch (OutOfMemoryException)
{
webm.Result = "Your browser sent malformatted security information";
}
catch (CryptographicException ce)
{
webm.Result = "Your browser sent malformatted security information";
Log.Debug(ce);
}
return false;
}
private async ValueTask ProcessMfaAsync(HttpEntity entity)
{
MfaUpgradeWebm webm = new();
//Recover request message
using JsonDocument? request = await entity.GetJsonFromFileAsync();
if (webm.Assert(request != null, "Invalid request data"))
{
entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
return VfReturnType.VirtualSkip;
}
//Recover upgrade jwt
string? upgradeJwt = request.RootElement.GetPropString("upgrade");
if (webm.Assert(upgradeJwt != null, "Missing required upgrade data"))
{
entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
return VfReturnType.VirtualSkip;
}
//Recover stored signature
string? storedSig = entity.Session.MfaUpgradeSecret();
if(webm.Assert(!string.IsNullOrWhiteSpace(storedSig), MFA_ERROR_MESSAGE))
{
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
//Recover upgrade data from upgrade message
MFAUpgrade? upgrade = MultiFactor!.RecoverUpgrade(upgradeJwt, storedSig);
if (webm.Assert(upgrade != null, MFA_ERROR_MESSAGE))
{
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
//recover user account
using IUser? user = await Users.GetUserFromEmailAsync(upgrade.UserName!);
if (webm.Assert(user != null, MFA_ERROR_MESSAGE))
{
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
bool locked = UserLoginLocked(user, entity.RequestedTimeUtc);
//Make sure the account has not been locked out
if (!webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE))
{
//process mfa login
LoginMfa(entity, user, request, upgrade, webm);
}
else
{
//Locked, so clear stored signature
entity.Session.MfaUpgradeSecret(null);
}
//Update user on clean process
await user.ReleaseAsync();
//Close rseponse
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
private void LoginMfa(HttpEntity entity, IUser user, JsonDocument request, MFAUpgrade upgrade, MfaUpgradeWebm webm)
{
//Recover the user's local time
DateTimeOffset localTime = request.RootElement.GetProperty("localtime").GetDateTimeOffset();
//Check mode
switch (upgrade.Type)
{
case MFAType.TOTP:
{
//get totp code from request
uint code = request.RootElement.GetProperty("code").GetUInt32();
//Verify totp code
if (!MultiFactor!.VerifyTOTP(user, code))
{
webm.Result = "Please check your code.";
//Increment flc and update the user in the store
user.FailedLoginIncrement();
return;
}
//Valid, complete
}
break;
case MFAType.PGP:
{ }
break;
default:
{
webm.Result = MFA_ERROR_MESSAGE;
}
return;
}
//Wipe session signature
entity.Session.MfaUpgradeSecret(null);
//build login message from upgrade
LoginMessage loginMessage = new()
{
ClientId = upgrade.ClientID,
ClientPublicKey = upgrade.Base64PubKey,
LocalLanguage = upgrade.ClientLocalLanguage,
LocalTime = localTime,
UserName = upgrade.UserName
};
//Elevate the login status of the session to reflect the user's status
entity.GenerateAuthorization(loginMessage, user, webm);
//Set the password token as the password field of the login message
webm.PasswordToken = upgrade.PwClientData;
//Send the Username (since they already have it)
webm.Result = new AccountData()
{
EmailAddress = user.EmailAddress,
};
webm.Success = true;
//Write to log
Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
}
public bool UserLoginLocked(IUser user, DateTimeOffset now)
{
//Recover last counter value
TimestampedCounter flc = user.FailedLoginCount();
if (flc.Count < MaxFailedLogins)
{
//Period exceeded
return false;
}
//See if the flc timeout period has expired
if (flc.LastModified.Add(FailedCountTimeout) < now)
{
//clear flc flag
user.FailedLoginCount(0);
return false;
}
//Count has been exceeded, and has not timed out yet
return true;
}
}
}