diff --git a/server/AyaNova/Controllers/AuthController.cs b/server/AyaNova/Controllers/AuthController.cs index fd5d3318..1dce387f 100644 --- a/server/AyaNova/Controllers/AuthController.cs +++ b/server/AyaNova/Controllers/AuthController.cs @@ -71,10 +71,10 @@ namespace AyaNova.Api.Controllers return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); } - + #if (DEBUG) - + #region TESTING @@ -330,6 +330,53 @@ namespace AyaNova.Api.Controllers //return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } + + /// + /// Change Password via reset token + /// + /// + /// + [HttpPost("resetpassword")] + public async Task ResetPassword([FromBody] AuthController.ResetPasswordParam resetcreds) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + int nFailDelay = 3000; + if (string.IsNullOrWhiteSpace(resetcreds.PasswordResetCode) || string.IsNullOrWhiteSpace(resetcreds.Password)) + { + //Make a fail wait + await Task.Delay(nFailDelay); + return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); + } + + //look for user with this reset code + var user = await ct.User.AsNoTracking().Where(z => z.PasswordResetCode == resetcreds.PasswordResetCode).FirstOrDefaultAsync(); + if (user == null) + { + //Make a fail wait + await Task.Delay(nFailDelay); + return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); + } + + //vet the expiry + var utcNow = new DateTimeOffset(DateTime.Now.ToUniversalTime(), TimeSpan.Zero); + if (user.PasswordResetCodeExpire < utcNow.DateTime) + {//if reset code expired before now + //Make a fail wait + await Task.Delay(nFailDelay); + return StatusCode(401, new ApiErrorResponse(ApiErrorCode.NOT_AUTHORIZED, "PasswordResetCodeExpire", "Reset code has expired")); + } + //Ok, were in, it's all good, accept the new password and update the user record + UserBiz biz = UserBiz.GetBiz(ct, HttpContext); + await biz.ChangePasswordAsync(user.Id, resetcreds.Password); + return NoContent(); + } + //------------------------------------------------------ public class CredentialsParam @@ -356,5 +403,14 @@ namespace AyaNova.Api.Controllers } + public class ResetPasswordParam + { + [System.ComponentModel.DataAnnotations.Required] + public string PasswordResetCode { get; set; } + [System.ComponentModel.DataAnnotations.Required] + public string Password { get; set; } + + } + }//eoc }//eons \ No newline at end of file diff --git a/server/AyaNova/Controllers/UserController.cs b/server/AyaNova/Controllers/UserController.cs index e56b7e73..3b3a7ab1 100644 --- a/server/AyaNova/Controllers/UserController.cs +++ b/server/AyaNova/Controllers/UserController.cs @@ -111,65 +111,6 @@ namespace AyaNova.Api.Controllers } - // /// - // /// Put (update) User - // /// (Login and / or Password are not changed if set to null / omitted) - // /// - // /// - // /// - // /// - // [HttpPut("{id}")] - // public async Task PutUser([FromRoute] long id, [FromBody] User inObj) - // { - // if (!serverState.IsOpen) - // return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); - - // if (!ModelState.IsValid) - // { - // return BadRequest(new ApiErrorResponse(ModelState)); - // } - - // var o = await ct.User.SingleOrDefaultAsync(z => z.Id == id); - - // if (o == null) - // { - // return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); - // } - - // //Instantiate the business object handler - // UserBiz biz = UserBiz.GetBiz(ct, HttpContext); - - // if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType)) - // { - // return StatusCode(403, new ApiNotAuthorizedResponse()); - // } - - - - // try - // { - // if (!await biz.PutAsync(o, inObj)) - // return BadRequest(new ApiErrorResponse(biz.Errors)); - // } - // catch (DbUpdateConcurrencyException) - // { - // if (!UserExists(id)) - // { - // return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); - // } - // else - // { - // //exists but was changed by another user - // //I considered returning new and old record, but where would it end? - // //Better to let the client decide what to do than to send extra data that is not required - // return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT)); - // } - // } - // return Ok(ApiOkResponse.Response(new { Concurrency = o.Concurrency })); - // } - - - /// /// Create User /// @@ -376,16 +317,16 @@ namespace AyaNova.Api.Controllers return Ok(ApiOkResponse.Response(u.UserType != UserType.Customer && u.UserType != UserType.HeadOffice)); } - /// - /// Generate new random credentials for User - /// and email them to the user + /// + /// Generate time limited password reset code for User + /// and email to them /// /// /// User id /// From route path /// NoContent - [HttpPost("generate-creds-email/{id}")] - public async Task GenerateCredsAndEmailUser([FromRoute] long id, ApiVersion apiVersion) + [HttpPost("send-reset-code/{id}")] + public async Task SendPasswordResetCode([FromRoute] long id, ApiVersion apiVersion) { if (!serverState.IsOpen) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); @@ -394,7 +335,7 @@ namespace AyaNova.Api.Controllers return StatusCode(403, new ApiNotAuthorizedResponse()); if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); - bool successfulOperation=await biz.GenerateCredsAndEmailUser(id); + bool successfulOperation = await biz.SendPasswordResetCode(id); if (successfulOperation == false) return BadRequest(new ApiErrorResponse(biz.Errors)); else diff --git a/server/AyaNova/biz/UserBiz.cs b/server/AyaNova/biz/UserBiz.cs index 4c9c175b..263b479c 100644 --- a/server/AyaNova/biz/UserBiz.cs +++ b/server/AyaNova/biz/UserBiz.cs @@ -311,9 +311,9 @@ namespace AyaNova.Biz ///////////////////////////////////////////// - // GENERATE AND EMAIL CREDS + // GENERATE AND EMAIL Password reset code // - internal async Task GenerateCredsAndEmailUser(long userId) + internal async Task SendPasswordResetCode(long userId) { User dbObject = await ct.User.Include(o => o.UserOptions).FirstOrDefaultAsync(z => z.Id == userId); if (dbObject == null) @@ -329,40 +329,35 @@ namespace AyaNova.Biz var ServerUrl = ServerGlobalOpsSettingsCache.Notify.AyaNovaServerURL; if (string.IsNullOrWhiteSpace(ServerUrl)) { - await NotifyEventProcessor.AddOpsProblemEvent("User::GenerateCredsAndEmailUser - The OPS Notification setting is empty for AyaNova Server URL. This prevents Notification system from linking events to openable objects."); + await NotifyEventProcessor.AddOpsProblemEvent("User::SendPasswordResetCode - The OPS Notification setting is empty for AyaNova Server URL. This prevents Notification system from linking events to openable objects."); AddError(ApiErrorCode.VALIDATION_REQUIRED, "ServerUrl", "Error: no server url configured in notification settings. Can't direct user to server for login. Set server URL and try again."); return false; } - - var newPassword = Hasher.GetRandomAlphanumericString(32); - var newLogin = Hasher.GetRandomAlphanumericString(32); - dbObject.Password = Hasher.hash(dbObject.Salt, newPassword); - dbObject.Login = newLogin; + var ResetCode = Hasher.GetRandomAlphanumericString(32); + dbObject.PasswordResetCode = ResetCode; + dbObject.PasswordResetCodeExpire = DateTime.UtcNow.AddHours(67);//This is enough time to issue a reset code on a friday at 5pm and use it Monday before noon await ct.SaveChangesAsync(); //send message ServerUrl = ServerUrl.Trim().TrimEnd('/'); - + //Translations List TransKeysRequired = new List(); - TransKeysRequired.Add("UserLogin"); - TransKeysRequired.Add("UserPassword"); - TransKeysRequired.Add("NewCredsMessageBody"); - TransKeysRequired.Add("NewCredsMessageTitle"); + TransKeysRequired.Add("PasswordResetMessageBody"); + TransKeysRequired.Add("PasswordResetMessageTitle"); long EffectiveTranslationId = dbObject.UserOptions.TranslationId; if (EffectiveTranslationId == 0) EffectiveTranslationId = ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID; var TransDict = await TranslationBiz.GetSubsetStaticAsync(TransKeysRequired, EffectiveTranslationId); - var Title = TransDict["NewCredsMessageTitle"]; - var NewCredsMessage = TransDict["NewCredsMessageBody"]; - var Creds = $"{TransDict["UserLogin"]}:\n{newLogin}\n{TransDict["UserPassword"]}:\n{newPassword}\n"; + var Title = TransDict["PasswordResetMessageTitle"]; + var MessageBody = TransDict["PasswordResetMessageBody"]; IMailer m = AyaNova.Util.ServiceProviderProvider.Mailer; - await m.SendEmailAsync(dbObject.UserOptions.EmailAddress, Title, $"{NewCredsMessage}{Creds}{ServerUrl}/home-user-settings", ServerGlobalOpsSettingsCache.Notify); + await m.SendEmailAsync(dbObject.UserOptions.EmailAddress, Title, $"{MessageBody}{ServerUrl}/reset?{ResetCode}", ServerGlobalOpsSettingsCache.Notify); //Log modification and save context - await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified, "GeneratedNewCredentialsAndEmailedToUser"), ct); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified, "SendPasswordResetCode"), ct); return true; } diff --git a/server/AyaNova/models/User.cs b/server/AyaNova/models/User.cs index e3cf44d9..7352da28 100644 --- a/server/AyaNova/models/User.cs +++ b/server/AyaNova/models/User.cs @@ -60,6 +60,11 @@ namespace AyaNova.Models [JsonIgnore] public DateTime? DlKeyExpire { get; set; } + [JsonIgnore] + public string PasswordResetCode { get; set; } + [JsonIgnore] + public DateTime? PasswordResetCodeExpire { get; set; } + [Required] public AuthorizationRoles Roles { get; set; } [Required] diff --git a/server/AyaNova/resource/en.json b/server/AyaNova/resource/en.json index 5f87dc6d..fa52bbb8 100644 --- a/server/AyaNova/resource/en.json +++ b/server/AyaNova/resource/en.json @@ -1951,5 +1951,8 @@ "GeoCapture": "Set to current location", "GeoView": "View on map", "MapUrlTemplate": "Map URL template", - "Contacts": "Contacts" + "Contacts": "Contacts", + "PasswordResetMessageTitle": "PasswordResetMessageTitle", + "PasswordResetMessageBody":"PasswordResetMessageBody" + } \ No newline at end of file diff --git a/server/AyaNova/util/AySchema.cs b/server/AyaNova/util/AySchema.cs index 7089484e..42a192e8 100644 --- a/server/AyaNova/util/AySchema.cs +++ b/server/AyaNova/util/AySchema.cs @@ -22,7 +22,7 @@ namespace AyaNova.Util //!!!!WARNING: BE SURE TO UPDATE THE DbUtil::EmptyBizDataFromDatabaseForSeedingOrImporting WHEN NEW TABLES ADDED!!!! private const int DESIRED_SCHEMA_LEVEL = 15; - internal const long EXPECTED_COLUMN_COUNT = 448; + internal const long EXPECTED_COLUMN_COUNT = 450; internal const long EXPECTED_INDEX_COUNT = 144; //!!!!WARNING: BE SURE TO UPDATE THE DbUtil::EmptyBizDataFromDatabaseForSeedingOrImporting WHEN NEW TABLES ADDED!!!! @@ -329,7 +329,7 @@ $BODY$; //Add user table await ExecQueryAsync("CREATE TABLE auser (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, active bool not null, name text not null unique, " + "lastlogin timestamp, login text not null unique, password text not null, salt text not null, roles integer not null, currentauthtoken text, " + - "dlkey text, dlkeyexpire timestamp, usertype integer not null, employeenumber text, notes text, customerid bigint, " + + "dlkey text, dlkeyexpire timestamp, passwordresetcode text, passwordresetcodeexpire timestamp, usertype integer not null, employeenumber text, notes text, customerid bigint, " + "headofficeid bigint, vendorid bigint, wiki text, customfields text, tags varchar(255) ARRAY)"); //Index for name fetching