diff options
Diffstat (limited to 'plugins')
4 files changed, 326 insertions, 161 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs index f61647f..96b56b4 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs @@ -23,20 +23,21 @@ */ using System; -using System.Linq; -using System.Collections.Generic; +using System.Text.Json; using System.ComponentModel.Design; +using VNLib.Utils; using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Plugins.Attributes; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Middleware; +using VNLib.Plugins.Essentials.Accounts.MFA; using VNLib.Plugins.Essentials.Accounts.Endpoints; +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.Plugins.Essentials.Accounts.SecurityProvider; namespace VNLib.Plugins.Essentials.Accounts { @@ -45,6 +46,8 @@ namespace VNLib.Plugins.Essentials.Accounts public override string PluginName => "Essentials.Accounts"; + private bool SetupMode => PluginConfig.TryGetProperty("setup_mode", out JsonElement el) && el.GetBoolean(); + private AccountSecProvider? _securityProvider; [ServiceConfigurator] @@ -83,7 +86,7 @@ namespace VNLib.Plugins.Essentials.Accounts if (this.HasConfigForType<PasswordChangeEndpoint>()) { this.Route<PasswordChangeEndpoint>(); - } + } if (this.HasConfigForType<MFAEndpoint>()) { @@ -104,6 +107,11 @@ namespace VNLib.Plugins.Essentials.Accounts Log.Information("Configuring the account security provider service"); } + if (SetupMode) + { + Log.Warn("Setup mode is enabled, this is not recommended for production use"); + } + //Write loaded to log Log.Information("Plugin loaded"); } @@ -118,90 +126,115 @@ namespace VNLib.Plugins.Essentials.Accounts protected override async void ProcessHostCommand(string cmd) { - //Only process commands if the plugin is in debug mode - if (!this.IsDebug()) + //Only process commands if the plugin is in setup mode + if (!SetupMode) { return; } try { + //Create argument parser + ArgumentList args = new(cmd.Split(' ')); + IUserManager Users = this.GetOrCreateSingleton<UserManager>(); IPasswordHashingProvider Passwords = this.GetOrCreateSingleton<ManagedPasswordHashing>(); - //get args as a list - List<string> args = cmd.Split(' ').ToList(); + string? username = args.GetArgument("-u"); + string? password = args.GetArgument("-p"); + if (args.Count < 3) { - Log.Warn("No command specified"); + Log.Warn("Not enough arguments, use the help command to view available commands"); + return; } - switch (args[2].ToLower()) + + switch (args[2].ToLower(null)) { + case "help": + const string help = @" + +Command help for {name} + +Usage: p {name} <command> [options] + +Commands: + create -u <username> -p <password> Create a new user + reset-password -u <username> -p <password> -l <priv level> Reset a user's password + delete -u <username> Delete a user + disable-mfa -u <username> Disable a user's MFA configuration + enable-totp -u <username> -s <base32 secret> Enable TOTP MFA for a user + set-privilege -u <username> -l <priv level> Set a user's privilege level + help Display this help message +"; + Log.Information(help, PluginName); + break; //Create new user - case "create": + case "create": { - int uid = args.IndexOf("-u"); - int pwd = args.IndexOf("-p"); - if (uid < 0 || pwd < 0) + if (username == null || password == null) { Log.Warn("You are missing required argument values. Format 'create -u <username> -p <password>'"); - return; + break; } - string username = args[uid + 1].Trim(); - string randomUserId = AccountUtil.GetRandomUserId(); - //Password as privatestring DANGEROUS to refs - using (PrivateString password = (PrivateString)args[pwd + 1].Trim()!) + + string? privilege = args.GetArgument("-l"); + + if(!ulong.TryParse(privilege, out ulong privLevel)) { - //Hash the password - using PrivateString passHash = Passwords.Hash(password); - //Create the user - using IUser user = await Users.CreateUserAsync(randomUserId, username, AccountUtil.MINIMUM_LEVEL, passHash); - //Set active flag - user.Status = UserStatus.Active; - //Set local account - user.SetAccountOrigin(AccountUtil.LOCAL_ACCOUNT_ORIGIN); - - await user.ReleaseAsync(); + privLevel = AccountUtil.MINIMUM_LEVEL; } - Log.Information("Successfully created user {id}", username); + //Hash the password + using PrivateString passHash = Passwords.Hash(password); + //Create the user + using IUser user = await Users.CreateUserAsync(username, passHash, privLevel); + + //Set active flag + user.Status = UserStatus.Active; + //Set local account + user.SetAccountOrigin(AccountUtil.LOCAL_ACCOUNT_ORIGIN); + + await user.ReleaseAsync(); + + Log.Information("Successfully created user {id}", username); } break; - case "reset": + case "reset-password": { - int uid = args.IndexOf("-u"); - int pwd = args.IndexOf("-p"); - if (uid < 0 || pwd < 0) + if (username == null || password == null) { - Log.Warn("You are missing required argument values. Format 'reset -u <username> -p <password>'"); - return; + Log.Warn("You are missing required argument values. Format 'create -u <username> -p <password>'"); + break; } - string username = args[uid + 1].Trim(); - //Password as privatestring DANGEROUS to refs - using (PrivateString password = (PrivateString)args[pwd + 1].Trim()!) + + //Hash the password + using PrivateString passHash = Passwords.Hash(password); + + //Get the user + using IUser? user = await Users.GetUserFromEmailAsync(username); + + if(user == null) { - //Hash the password - using PrivateString passHash = Passwords.Hash(password); - //Get the user - using IUser? user = await Users.GetUserFromEmailAsync(username); - - if(user == null) - { - Log.Warn("The specified user does not exist"); - break; - } - - //Set the password - await Users.UpdatePassAsync(user, passHash); + Log.Warn("The specified user does not exist"); + break; } + + //Set the password + await Users.UpdatePassAsync(user, passHash); + Log.Information("Successfully reset password for {id}", username); } break; case "delete": { - //get user-id - string userId = args[3].Trim(); + if(username == null) + { + Log.Warn("You are missing required argument values. Format 'delete -u <username>'"); + break; + } + //Get user - using IUser? user = await Users.GetUserFromEmailAsync(userId); + using IUser? user = await Users.GetUserFromEmailAsync(username); if (user == null) { @@ -213,10 +246,99 @@ namespace VNLib.Plugins.Essentials.Accounts user.Delete(); //Release user await user.ReleaseAsync(); + + Log.Information("Successfully deleted user {id}", username); + } + break; + case "disable-mfa": + { + if (username == null) + { + Log.Warn("You are missing required argument values. Format 'disable-mfa -u <username>'"); + break; + } + + //Get user + using IUser? user = await Users.GetUserFromEmailAsync(username); + + if (user == null) + { + Log.Warn("The specified user does not exist"); + break; + } + + user.MFADisable(); + await user.ReleaseAsync(); + + Log.Information("Successfully disabled MFA for {id}", username); + } + break; + case "enable-totp": + { + string? secret = args.GetArgument("-s"); + + if (username == null || secret == null) + { + Log.Warn("You are missing required argument values. Format 'enable-totp -u <username> -s <secret>'"); + break; + } + + //Get user + using IUser? user = await Users.GetUserFromEmailAsync(username); + + if (user == null) + { + Log.Warn("The specified user does not exist"); + break; + } + + try + { + byte[] sec = VnEncoding.FromBase32String(secret) ?? throw new Exception(""); + } + catch + { + Log.Error("Your TOTP secret is not valid base32"); + break; + } + + //Update the totp secret and flush changes + user.MFASetTOTPSecret(secret); + await user.ReleaseAsync(); + + Log.Information("Successfully set TOTP secret for {id}", username); + } + break; + case "set-privilege": + { + if (username == null) + { + Log.Warn("You are missing required argument values. Format 'set-privilege -u <username> -l <privilege level>'"); + break; + } + + string? privilege = args.GetArgument("-l"); + if (!ulong.TryParse(privilege, out ulong privLevel)) + { + Log.Warn("You are missing required argument values. Format 'set-privilege -u <username> -l <privilege level>'"); + break; + } + + //Get user + using IUser? user = await Users.GetUserFromEmailAsync(username); + if (user == null) + { + Log.Warn("The specified user does not exist"); + break; + } + + user.Privileges = privLevel; + await user.ReleaseAsync(); + Log.Information("Successfully set privilege level for {id}", username); } break; default: - Log.Warn("Uknown command"); + Log.Warn("Uknown command, use the help command"); break; } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs index 9c304cd..e5adb17 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs @@ -31,7 +31,7 @@ using VNLib.Plugins.Essentials.Endpoints; namespace VNLib.Plugins.Essentials.Accounts.Endpoints { [ConfigurationName("logout_endpoint")] - internal class LogoutEndpoint : ProtectedWebEndpoint + internal class LogoutEndpoint : UnprotectedWebEndpoint { public LogoutEndpoint(PluginBase pbase, IConfigScope config) @@ -43,9 +43,29 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints protected override VfReturnType Post(HttpEntity entity) { - entity.InvalidateLogin(); - entity.CloseResponse(HttpStatusCode.OK); - return VfReturnType.VirtualSkip; + /* + * If a connection is not properly authorized to modify the session + * we can invalidate the client by detaching the session. This + * should cause the session to remain in tact but the client will + * be detached. + * + * This prevents attacks where connection with just a stolen session + * id can cause the client's session to be invalidated. + */ + + if (entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) + { + entity.InvalidateLogin(); + entity.CloseResponse(HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } + else + { + //Detatch the session to cause client only invalidation + entity.Session.Detach(); + entity.CloseResponse(HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } } } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs index 0b52f54..bd434ae 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs @@ -57,6 +57,16 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA return !(string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]) && string.IsNullOrWhiteSpace(user[WEBAUTHN_KEY_ENTRY])); } + /// <summary> + /// Disables all forms of MFA for the current user + /// </summary> + /// <param name="user"></param> + public static void MFADisable(this IUser user) + { + user[TOTP_KEY_ENTRY] = null!; + user[WEBAUTHN_KEY_ENTRY] = null!; + } + #region totp /// <summary> diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs index 41c7e93..688d84d 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs @@ -42,8 +42,8 @@ using FluentValidation; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; -using VNLib.Utils; using VNLib.Net.Http; +using VNLib.Utils; using VNLib.Utils.Memory; using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; @@ -72,17 +72,20 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; private readonly AccountSecConfig _config; + private readonly CookieHandler _cookieHandler; public AccountSecProvider(PluginBase plugin) { //Setup default config _config = new(); + _cookieHandler = new(_config); } public AccountSecProvider(PluginBase pbase, IConfigScope config) { //Parse config if defined _config = config.DeserialzeAndValidate<AccountSecConfig>(); + _cookieHandler = new(_config); } /* @@ -92,8 +95,27 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider ///<inheritdoc/> public ValueTask<HttpMiddlewareResult> ProcessAsync(HttpEntity entity) { - //Reconcile cookies on every request we enabled - ReconcileCookies(entity); + //Session must be set and web based for checks + if (entity.Session.IsSet && entity.Session.SessionType == SessionType.Web) + { + //See if the session might be elevated + if (!string.IsNullOrWhiteSpace(entity.Session.LoginHash)) + { + //If the session stored a user-agent, make sure it matches the connection + if (entity.Session.UserAgent != null && !entity.Session.UserAgent.Equals(entity.Server.UserAgent, StringComparison.Ordinal)) + { + entity.CloseResponse(System.Net.HttpStatusCode.Forbidden); + return ValueTask.FromResult(HttpMiddlewareResult.Complete); + } + } + + //If the session is new, or not supposed to be logged in, clear the login cookies if they were set + if (entity.Session.IsNew || string.IsNullOrEmpty(entity.Session.LoginHash) || string.IsNullOrEmpty(entity.Session.Token)) + { + ExpireCookies(entity); + } + } + //Always continue return ValueTask.FromResult(HttpMiddlewareResult.Continue); } @@ -101,6 +123,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider #region Interface Impl + ///<inheritdoc/> IClientAuthorization IAccountSecurityProvider.AuthorizeClient(HttpEntity entity, IClientSecInfo clientInfo, IUser user) { //Validate client info @@ -124,7 +147,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider * status for the user cookie. This is not required if the user is already * logged in */ - string loginCookie = SetLoginCookie(entity, user.IsLocalAccount()); + + string loginCookie = SetLoginCookie(entity); + SetClientStatusCookie(entity, user.IsLocalAccount()); //Store the login hash in the user's session entity.Session.LoginHash = loginCookie; @@ -145,8 +170,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider LoginSecurityString = loginCookie, SecurityToken = authTokens, }; - } + } + ///<inheritdoc/> void IAccountSecurityProvider.InvalidateLogin(HttpEntity entity) { //Client should also destroy the session @@ -158,6 +184,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = null!; } + ///<inheritdoc/> bool IAccountSecurityProvider.IsClientAuthorized(HttpEntity entity, AuthorzationCheckLevel level) { //Session must be loaded and not-new for an authorization to exist @@ -177,6 +204,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider }; } + ///<inheritdoc/> IClientAuthorization IAccountSecurityProvider.ReAuthorizeClient(HttpEntity entity) { //Confirm session is configured @@ -214,12 +242,14 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider }; } + ///<inheritdoc/> ERRNO IAccountSecurityProvider.TryEncryptClientData(HttpEntity entity, ReadOnlySpan<byte> data, Span<byte> outputBuffer) { //Recover the signed public key, already does session checks return TryGetPublicKey(entity, out string? pubKey) ? TryEncryptClientData(pubKey, data, outputBuffer) : ERRNO.E_FAIL; } + ///<inheritdoc/> ERRNO IAccountSecurityProvider.TryEncryptClientData(IClientSecInfo entity, ReadOnlySpan<byte> data, Span<byte> outputBuffer) { //Use the public key supplied by the csecinfo @@ -357,21 +387,6 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider #endregion #region Cookies - - private void ReconcileCookies(HttpEntity entity) - { - //Only handle cookies if session is loaded and is a web based session - if (!entity.Session.IsSet || entity.Session.SessionType != SessionType.Web) - { - return; - } - - //If the session is new, or not supposed to be logged in, clear the login cookies if they were set - if (entity.Session.IsNew || string.IsNullOrEmpty(entity.Session.LoginHash) || string.IsNullOrEmpty(entity.Session.Token)) - { - ExpireCookies(entity); - } - } private bool VerifyLoginCookie(HttpEntity entity) { @@ -421,48 +436,19 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider //Expire login cookie if set if (entity.Server.RequestCookies.ContainsKey(_config.LoginCookieName)) { - HttpCookie pkCookie = new(_config.LoginCookieName, string.Empty) - { - Domain = _config.CookieDomain, - Path = _config.CookiePath, - ValidFor = TimeSpan.Zero, - SameSite = CookieSameSite.SameSite, - HttpOnly = true, - Secure = true - }; - - entity.Server.SetCookie(in pkCookie); + _cookieHandler.ExpireCookie(entity, _config.LoginCookieName); } + //Expire the LI cookie if set if (entity.Server.RequestCookies.ContainsKey(_config.ClientStatusCookieName)) { - HttpCookie pkCookie = new(_config.ClientStatusCookieName, string.Empty) - { - Domain = _config.CookieDomain, - Path = _config.CookiePath, - ValidFor = TimeSpan.Zero, - SameSite = CookieSameSite.SameSite, - HttpOnly = true, - Secure = true - }; - - entity.Server.SetCookie(in pkCookie); + _cookieHandler.ExpireCookie(entity, _config.ClientStatusCookieName); } + //Expire pupkey cookie if (entity.Server.RequestCookies.ContainsKey(_config.PubKeyCookieName)) { - //Init exipiration cookie - HttpCookie pkCookie = new(_config.PubKeyCookieName, string.Empty) - { - Domain = _config.CookieDomain, - Path = _config.CookiePath, - ValidFor = TimeSpan.Zero, - SameSite = CookieSameSite.SameSite, - HttpOnly = true, - Secure = true - }; - - entity.Server.SetCookie(in pkCookie); + _cookieHandler.ExpireCookie(entity, _config.PubKeyCookieName); } } @@ -535,45 +521,24 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider /// </summary>/ /// <param name="ev">The event to log-in</param> /// <param name="localAccount">Does the session belong to a local user account</param> - private string SetLoginCookie(HttpEntity ev, bool? localAccount = null) + private string SetLoginCookie(HttpEntity ev) { //Get the new random cookie value string loginString = RandomHash.GetRandomBase64(_config.LoginCookieSize); - //Configure the login cookie - HttpCookie loginCookie = new(_config.LoginCookieName, loginString) - { - Domain = _config.CookieDomain, - Path = _config.CookiePath, - ValidFor = _config.AuthorizationValidFor, - SameSite = CookieSameSite.SameSite, - HttpOnly = true, - Secure = true - }; + //Set the cookie for the login key + _cookieHandler.SetCookie(ev, _config.LoginCookieName, loginString, true); - //Set login cookie and session login hash - ev.Server.SetCookie(in loginCookie); + return loginString; + } + private void SetClientStatusCookie(HttpEntity entity, bool? localAccount = null) + { //If not set get from session storage - localAccount ??= ev.Session.HasLocalAccount(); - - //setup status cookie - HttpCookie statusCookie = new(_config.ClientStatusCookieName, localAccount.Value ? "1" : "2") - { - Domain = _config.CookieDomain, - Path = _config.CookiePath, - ValidFor = _config.AuthorizationValidFor, - SameSite = CookieSameSite.SameSite, - Secure = true, - - //Allowed to be http - HttpOnly = false - }; - - //Set the client identifier cookie to a value indicating a local account - ev.Server.SetCookie(in statusCookie); + localAccount ??= entity.Session.HasLocalAccount(); - return loginString; + //set client status cookie via handler + _cookieHandler.SetCookie(entity, _config.ClientStatusCookieName, localAccount.Value ? "1" : "2", false); } #region Client Encryption Key @@ -620,20 +585,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider //Compile the jwt for the cookie value string jwtValue = jwt.Compile(); - //Setup cookie the same as login cookies - HttpCookie cookie = new(_config.PubKeyCookieName, jwtValue) - { - Domain = _config.CookieDomain, - Path = _config.CookiePath, - SameSite = CookieSameSite.SameSite, - ValidFor = _config.AuthorizationValidFor, - - HttpOnly = true, - Secure = true, - }; - - //set the cookie - entity.Server.SetCookie(in cookie); + _cookieHandler.SetCookie(entity, _config.PubKeyCookieName, jwtValue, true); //Return the signing key return base32SigningKey; @@ -650,7 +602,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider } //Get the jwt cookie - if (!entity.Server.GetCookie(_config.PubKeyCookieName, out string? pubKeyJwt)) + string? pubKeyJwt = _cookieHandler.GetCookie(entity, _config.PubKeyCookieName); + + if (string.IsNullOrWhiteSpace(pubKeyJwt)) { return false; } @@ -693,7 +647,6 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider return true; } - #endregion @@ -840,8 +793,68 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider private sealed class Authorization : IClientAuthorization { + ///<inheritdoc/> public string? LoginSecurityString { get; init; } + + ///<inheritdoc/> public ClientSecurityToken SecurityToken { get; init; } } + + record class CookieHandler(AccountSecConfig Config) + { + + /// <summary> + /// Expires a cookie with the given name + /// </summary> + /// <param name="entity">The entity to expire the cookie on</param> + /// <param name="cookieName">The name of the cookie to expire</param> + public void ExpireCookie(HttpEntity entity, string cookieName) + { + HttpCookie cookie = new(cookieName, string.Empty) + { + Domain = Config.CookieDomain, + Path = Config.CookiePath, + ValidFor = TimeSpan.Zero, + SameSite = CookieSameSite.SameSite, + HttpOnly = true, + Secure = true + }; + + entity.Server.SetCookie(in cookie); + } + + /// <summary> + /// Sets a cookie with the given name and value + /// </summary> + /// <param name="entity">The entity to set the cookie on</param> + /// <param name="name">The name of the cookie to set</param> + /// <param name="value">The value of the cookie to set</param> + /// <param name="httpOnly">A value that indicates of the httponly flag should be set on the cookie</param> + public void SetCookie(HttpEntity entity, string name, string value, bool httpOnly) + { + HttpCookie cookie = new(name, value) + { + Domain = Config.CookieDomain, + Path = Config.CookiePath, + ValidFor = Config.AuthorizationValidFor, + SameSite = CookieSameSite.SameSite, + HttpOnly = httpOnly, + Secure = true + }; + entity.Server.SetCookie(in cookie); + } + + /// <summary> + /// Gets the value of a cookie with the given name + /// </summary> + /// <param name="entity">The entity to get the cookie from</param> + /// <param name="name">The name of the cooke to retrieve</param> + /// <returns>The cookie value if found, null otherwise</returns> + public string? GetCookie(HttpEntity entity, string name) + { + _ = entity.Server.GetCookie(name, out string? value); + return value; + } + } } } |