Auth Email Rework (#1567)

* Hooked up Send to for Series and volumes and fixed a bug where Email Service errors weren't propagating to the UI layer.

When performing actions on series detail, don't disable the button anymore.

* Added send to action to volumes

* Fixed a bug where .kavitaignore wasn't being applied at library root level

* Added a notification for when a device is being sent a file.

* Added a check in forgot password for users that do not have an email set or aren't confirmed.

* Added a new api for change email and moved change password directly into new Account tab (styling and logic needs testing)

* Save approx scroll position like with jump key, but on normal click of card.

* Implemented the ability to change your email address or set one. This requires a 2 step process using a confirmation token. This needs polishing and css.

* Removed an unused directive from codebase

* Fixed up some typos on publicly

* Updated query for Pending Invites to also check if the user account has not logged in at least once.

* Cleaned up the css for validate email change

* Hooked in an indicator to tell user that a user has an unconfirmed email

* Cleaned up code smells
This commit is contained in:
Joe Milazzo 2022-10-01 08:23:35 -05:00 committed by GitHub
parent 3792ac3421
commit 5f17c2fb73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 816 additions and 274 deletions

View file

@ -19,6 +19,7 @@ using API.Services;
using API.SignalR;
using AutoMapper;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -188,15 +189,6 @@ public class AccountController : BaseApiController
if (user == null) return Unauthorized("Invalid username");
// Check if the user has an email, if not, inform them so they can migrate
var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, loginDto.Password);
if (string.IsNullOrEmpty(user.Email) && !user.EmailConfirmed && validPassword)
{
_logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName);
return Unauthorized(
"You are missing an email on your account. Please wait while we migrate your account.");
}
var result = await _signInManager
.CheckPasswordSignInAsync(user, loginDto.Password, true);
@ -256,6 +248,7 @@ public class AccountController : BaseApiController
[HttpGet("roles")]
public ActionResult<IList<string>> GetRoles()
{
// TODO: This should be moved to ServerController
return typeof(PolicyConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(string))
@ -285,6 +278,86 @@ public class AccountController : BaseApiController
}
/// <summary>
/// Initiates the flow to update a user's email address. The email address is not changed in this API. A confirmation link is sent/dumped which will
/// validate the email. It must be confirmed for the email to update.
/// </summary>
/// <param name="dto"></param>
/// <returns>Returns just if the email was sent or server isn't reachable</returns>
[HttpPost("update/email")]
public async Task<ActionResult> UpdateEmail(UpdateEmailDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized("You do not have permission");
if (dto == null || string.IsNullOrEmpty(dto.Email)) return BadRequest("Invalid payload");
// Validate no other users exist with this email
if (user.Email.Equals(dto.Email)) return Ok("Nothing to do");
// Check if email is used by another user
var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (existingUserEmail != null)
{
return BadRequest("You cannot share emails across multiple accounts");
}
// All validations complete, generate a new token and email it to the user at the new address. Confirm email link will update the email
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token))
{
_logger.LogError("There was an issue generating a token for the email");
return BadRequest("There was an issue creating a confirmation email token. See logs.");
}
user.EmailConfirmed = false;
user.ConfirmationToken = token;
await _userManager.UpdateAsync(user);
// Send a confirmation email
try
{
var emailLink = GenerateEmailLink(user.ConfirmationToken, "confirm-email-update", dto.Email);
_logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
var accessible = await _emailService.CheckIfAccessible(host);
if (accessible)
{
try
{
// Email the old address of the update change
await _emailService.SendEmailChangeEmail(new ConfirmationEmailDto()
{
EmailAddress = string.IsNullOrEmpty(user.Email) ? dto.Email : user.Email,
InstallId = BuildInfo.Version.ToString(),
InvitingUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).First().UserName,
ServerConfirmationLink = emailLink
});
}
catch (Exception)
{
/* Swallow exception */
}
}
return Ok(new InviteUserResponse
{
EmailLink = string.Empty,
EmailSent = accessible
});
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error during invite user flow, unable to send an email");
}
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id);
return Ok();
}
/// <summary>
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
/// </summary>
@ -310,14 +383,6 @@ public class AccountController : BaseApiController
_unitOfWork.UserRepository.Update(user);
}
if (!user.Email.Equals(dto.Email))
{
// Validate username change
var errors = await _accountService.ValidateEmail(dto.Email);
if (errors.Any()) return BadRequest("Email already registered");
// NOTE: This needs to be handled differently, like save it in a temp variable in DB until email is validated. For now, I wont allow it
}
// Update roles
var existingRoles = await _userManager.GetRolesAsync(user);
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
@ -404,18 +469,22 @@ public class AccountController : BaseApiController
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
{
var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (adminUser == null) return Unauthorized("You need to login");
if (adminUser == null) return Unauthorized("You are not permitted");
_logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email);
// Check if there is an existing invite
dto.Email = dto.Email.Trim();
var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
if (emailValidationErrors.Any())
if (!string.IsNullOrEmpty(dto.Email))
{
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (await _userManager.IsEmailConfirmedAsync(invitedUser))
return BadRequest($"User is already registered as {invitedUser.UserName}");
return BadRequest("User is already invited under this email and has yet to accepted invite.");
dto.Email = dto.Email.Trim();
var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
if (emailValidationErrors.Any())
{
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (await _userManager.IsEmailConfirmedAsync(invitedUser))
return BadRequest($"User is already registered as {invitedUser.UserName}");
return BadRequest("User is already invited under this email and has yet to accepted invite.");
}
}
// Create a new user
@ -575,6 +644,44 @@ public class AccountController : BaseApiController
};
}
/// <summary>
/// Final step in email update change. Given a confirmation token and the email, this will finish the email change.
/// </summary>
/// <remarks>This will force connected clients to re-authenticate</remarks>
/// <param name="dto"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpPost("confirm-email-update")]
public async Task<ActionResult> ConfirmEmailUpdate(ConfirmEmailUpdateDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByConfirmationToken(dto.Token);
if (user == null)
{
return BadRequest("Invalid Email Token");
}
if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token");
_logger.LogInformation("User is updating email from {OldEmail} to {NewEmail}", user.Email, dto.Email);
var result = await _userManager.SetEmailAsync(user, dto.Email);
if (!result.Succeeded)
{
_logger.LogError("Unable to update email for users: {Errors}", result.Errors.Select(e => e.Description));
return BadRequest("Unable to update email for user. Check logs");
}
user.ConfirmationToken = null;
await _unitOfWork.CommitAsync();
// For the user's connected devices to pull the new information in
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate,
MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id);
// Perform Login code
return Ok();
}
[AllowAnonymous]
[HttpPost("confirm-password-reset")]
public async Task<ActionResult<string>> ConfirmForgotPassword(ConfirmPasswordResetDto dto)
@ -619,15 +726,15 @@ public class AccountController : BaseApiController
}
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole))
return Unauthorized("You are not permitted to this operation.");
if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed)
return BadRequest("You do not have an email on account or it has not been confirmed");
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email);
_logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
_logger.LogCritical("[Forgot Password]: Token {UserName}: {Token}", user.UserName, token);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
if (await _emailService.CheckIfAccessible(host))
{
@ -643,6 +750,15 @@ public class AccountController : BaseApiController
return Ok("Your server is not accessible. The Link to reset your password is in the logs.");
}
[HttpGet("email-confirmed")]
public async Task<ActionResult<bool>> IsEmailConfirmed()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
return Ok(user.EmailConfirmed);
}
[AllowAnonymous]
[HttpPost("confirm-migration-email")]
public async Task<ActionResult<UserDto>> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto)