using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading.Tasks; using System.Web; using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Account; using API.DTOs.Email; using API.Entities; using API.Entities.Enums; using API.Errors; using API.Extensions; using API.Services; using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Controllers { /// /// All Account matters /// public class AccountController : BaseApiController { private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly ITokenService _tokenService; private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IMapper _mapper; private readonly IAccountService _accountService; private readonly IEmailService _emailService; private readonly IHostEnvironment _environment; /// public AccountController(UserManager userManager, SignInManager signInManager, ITokenService tokenService, IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, IAccountService accountService, IEmailService emailService, IHostEnvironment environment) { _userManager = userManager; _signInManager = signInManager; _tokenService = tokenService; _unitOfWork = unitOfWork; _logger = logger; _mapper = mapper; _accountService = accountService; _emailService = emailService; _environment = environment; } /// /// Update a user's password /// /// /// [HttpPost("reset-password")] public async Task UpdatePassword(ResetPasswordDto resetPasswordDto) { _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName); var user = await _userManager.Users.SingleAsync(x => x.UserName == resetPasswordDto.UserName); if (resetPasswordDto.UserName != User.GetUsername() && !(User.IsInRole(PolicyConstants.AdminRole) || User.IsInRole(PolicyConstants.ChangePasswordRole))) return Unauthorized("You are not permitted to this operation."); var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password); if (errors.Any()) { return BadRequest(errors); } _logger.LogInformation("{User}'s Password has been reset", resetPasswordDto.UserName); return Ok(); } /// /// Register the first user (admin) on the server. Will not do anything if an admin is already confirmed /// /// /// [HttpPost("register")] public async Task> RegisterFirstUser(RegisterDto registerDto) { var admins = await _userManager.GetUsersInRoleAsync("Admin"); if (admins.Count > 0) return BadRequest("Not allowed"); try { var usernameValidation = await _accountService.ValidateUsername(registerDto.Username); if (usernameValidation.Any()) { return BadRequest(usernameValidation); } var user = new AppUser() { UserName = registerDto.Username, Email = registerDto.Email, UserPreferences = new AppUserPreferences { Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() }, ApiKey = HashUtil.ApiKey() }; var result = await _userManager.CreateAsync(user, registerDto.Password); if (!result.Succeeded) return BadRequest(result.Errors); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue generating a confirmation token."); if (!await ConfirmEmailToken(token, user)) return BadRequest($"There was an issue validating your email: {token}"); var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole); if (!roleResult.Succeeded) return BadRequest(result.Errors); return new UserDto { Username = user.UserName, Email = user.Email, Token = await _tokenService.CreateToken(user), RefreshToken = await _tokenService.CreateRefreshToken(user), ApiKey = user.ApiKey, Preferences = _mapper.Map(user.UserPreferences) }; } catch (Exception ex) { _logger.LogError(ex, "Something went wrong when registering user"); await _unitOfWork.RollbackAsync(); } return BadRequest("Something went wrong when registering user"); } /// /// Perform a login. Will send JWT Token of the logged in user back. /// /// /// [HttpPost("login")] public async Task> Login(LoginDto loginDto) { var user = await _userManager.Users .Include(u => u.UserPreferences) .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper()); 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."); } if (!validPassword) { return Unauthorized("Your credentials are not correct"); } var result = await _signInManager .CheckPasswordSignInAsync(user, loginDto.Password, false); if (!result.Succeeded) { return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct."); } // Update LastActive on account user.LastActive = DateTime.Now; user.UserPreferences ??= new AppUserPreferences { Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() }; _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); _logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive); var dto = _mapper.Map(user); dto.Token = await _tokenService.CreateToken(user); dto.RefreshToken = await _tokenService.CreateRefreshToken(user); var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName); pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); dto.Preferences = _mapper.Map(pref); return dto; } [HttpPost("refresh-token")] public async Task> RefreshToken([FromBody] TokenRequestDto tokenRequestDto) { var token = await _tokenService.ValidateRefreshToken(tokenRequestDto); if (token == null) { return Unauthorized(new { message = "Invalid token" }); } return Ok(token); } /// /// Get All Roles back. See /// /// [HttpGet("roles")] public ActionResult> GetRoles() { return typeof(PolicyConstants) .GetFields(BindingFlags.Public | BindingFlags.Static) .Where(f => f.FieldType == typeof(string)) .ToDictionary(f => f.Name, f => (string) f.GetValue(null)).Values.ToList(); } /// /// Resets the API Key assigned with a user /// /// [HttpPost("reset-api-key")] public async Task> ResetApiKey() { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); user.ApiKey = HashUtil.ApiKey(); if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) { return Ok(user.ApiKey); } await _unitOfWork.RollbackAsync(); return BadRequest("Something went wrong, unable to reset key"); } /// /// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access. /// /// /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] public async Task UpdateAccount(UpdateUserDto dto) { var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized("You do not have permission"); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId); if (user == null) return BadRequest("User does not exist"); // Check if username is changing if (!user.UserName.Equals(dto.Username)) { // Validate username change var errors = await _accountService.ValidateUsername(dto.Username); if (errors.Any()) return BadRequest("Username already taken"); user.UserName = dto.Username; _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); if (!hasAdminRole) { dto.Roles.Add(PolicyConstants.PlebRole); } if (existingRoles.Except(dto.Roles).Any() || dto.Roles.Except(existingRoles).Any()) { var roles = dto.Roles; var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles); if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); roleResult = await _userManager.AddToRolesAsync(user, roles); if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); } var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); List libraries; if (hasAdminRole) { _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", user.UserName); libraries = allLibraries; } else { // Remove user from all libraries foreach (var lib in allLibraries) { lib.AppUsers ??= new List(); lib.AppUsers.Remove(user); } libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList(); } foreach (var lib in libraries) { lib.AppUsers ??= new List(); lib.AppUsers.Add(user); } if (!_unitOfWork.HasChanges()) return Ok(); if (await _unitOfWork.CommitAsync()) { return Ok(); } await _unitOfWork.RollbackAsync(); return BadRequest("There was an exception when updating the user"); } [Authorize(Policy = "RequireAdminRole")] [HttpPost("invite")] public async Task> InviteUser(InviteUserDto dto) { var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (adminUser == null) return Unauthorized("You need to login"); _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); // Check if there is an existing invite 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 var user = new AppUser() { UserName = dto.Email, Email = dto.Email, ApiKey = HashUtil.ApiKey(), UserPreferences = new AppUserPreferences { Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() } }; try { var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword); if (!result.Succeeded) return BadRequest(result.Errors); // Assign Roles var roles = dto.Roles; var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); if (!hasAdminRole) { roles.Add(PolicyConstants.PlebRole); } foreach (var role in roles) { if (!PolicyConstants.ValidRoles.Contains(role)) continue; var roleResult = await _userManager.AddToRoleAsync(user, role); if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); } // Grant access to libraries List libraries; if (hasAdminRole) { _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", user.UserName); libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); } else { libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList(); } foreach (var lib in libraries) { lib.AppUsers ??= new List(); lib.AppUsers.Add(user); } await _unitOfWork.CommitAsync(); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email"); var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email); _logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); if (dto.SendEmail) { await _emailService.SendConfirmationEmail(new ConfirmationEmailDto() { EmailAddress = dto.Email, InvitingUser = adminUser.UserName, ServerConfirmationLink = emailLink }); } return Ok(emailLink); } catch (Exception) { _unitOfWork.UserRepository.Delete(user); await _unitOfWork.CommitAsync(); } return BadRequest("There was an error setting up your account. Please check the logs"); } [HttpPost("confirm-email")] public async Task> ConfirmEmail(ConfirmEmailDto dto) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); // Validate Password and Username var validationErrors = new List(); validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username)); validationErrors.AddRange(await _accountService.ValidatePassword(user, dto.Password)); if (validationErrors.Any()) { return BadRequest(validationErrors); } if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); user.UserName = dto.Username; var errors = await _accountService.ChangeUserPassword(user, dto.Password); if (errors.Any()) { return BadRequest(errors); } await _unitOfWork.CommitAsync(); user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, AppUserIncludes.UserPreferences); // Perform Login code return new UserDto { Username = user.UserName, Email = user.Email, Token = await _tokenService.CreateToken(user), RefreshToken = await _tokenService.CreateRefreshToken(user), ApiKey = user.ApiKey, Preferences = _mapper.Map(user.UserPreferences) }; } [AllowAnonymous] [HttpPost("confirm-password-reset")] public async Task> ConfirmForgotPassword(ConfirmPasswordResetDto dto) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (user == null) { return BadRequest("Invalid Details"); } var result = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", dto.Token); if (!result) return BadRequest("Unable to reset password, your email token is not correct."); var errors = await _accountService.ChangeUserPassword(user, dto.Password); return errors.Any() ? BadRequest(errors) : Ok("Password updated"); } /// /// Will send user a link to update their password to their email or prompt them if not accessible /// /// /// [AllowAnonymous] [HttpPost("forgot-password")] public async Task> ForgotPassword([FromQuery] string email) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); if (user == null) { _logger.LogError("There are no users with email: {Email} but user is requesting password reset", email); return Ok("An email will be sent to the email if it exists in our database"); } var emailLink = GenerateEmailLink(await _userManager.GeneratePasswordResetTokenAsync(user), "confirm-reset-password", user.Email); _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); if (await _emailService.CheckIfAccessible(host)) { await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto() { EmailAddress = user.Email, ServerConfirmationLink = emailLink, InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value }); return Ok("Email sent"); } return Ok("Your server is not accessible. The Link to reset your password is in the logs."); } [AllowAnonymous] [HttpPost("confirm-migration-email")] public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (user == null) return BadRequest("This email is not on system"); if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); await _unitOfWork.CommitAsync(); user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, AppUserIncludes.UserPreferences); // Perform Login code return new UserDto { Username = user.UserName, Email = user.Email, Token = await _tokenService.CreateToken(user), RefreshToken = await _tokenService.CreateRefreshToken(user), ApiKey = user.ApiKey, Preferences = _mapper.Map(user.UserPreferences) }; } [HttpPost("resend-confirmation-email")] public async Task> ResendConfirmationSendEmail([FromQuery] int userId) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return BadRequest("User does not exist"); if (string.IsNullOrEmpty(user.Email)) return BadRequest( "This user needs to migrate. Have them log out and login to trigger a migration flow"); if (user.EmailConfirmed) return BadRequest("User already confirmed"); var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-email", user.Email); _logger.LogCritical("[Email Migration]: Email Link: {Link}", emailLink); await _emailService.SendMigrationEmail(new EmailMigrationDto() { EmailAddress = user.Email, Username = user.UserName, ServerConfirmationLink = emailLink, InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value }); return Ok(emailLink); } private string GenerateEmailLink(string token, string routePart, string email) { var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); var emailLink = $"{Request.Scheme}://{host}{Request.PathBase}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; return emailLink; } /// /// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow /// /// /// [AllowAnonymous] [HttpPost("migrate-email")] public async Task> MigrateEmail(MigrateUserEmailDto dto) { // Check if there is an existing invite 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}"); _logger.LogInformation("A user is attempting to login, but hasn't accepted email invite"); return BadRequest("User is already invited under this email and has yet to accepted invite."); } var user = await _userManager.Users .Include(u => u.UserPreferences) .SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper()); if (user == null) return BadRequest("Invalid username"); var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password); if (!validPassword) return BadRequest("Your credentials are not correct"); try { var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); //if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email"); user.Email = dto.Email; if (!await ConfirmEmailToken(token, user)) return BadRequest("There was a critical error during migration"); _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); //var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email); // _logger.LogCritical("[Email Migration]: Email Link for {UserName}: {Link}", dto.Username, emailLink); // // Always send an email, even if the user can't click it just to get them conformable with the system // await _emailService.SendMigrationEmail(new EmailMigrationDto() // { // EmailAddress = dto.Email, // Username = user.UserName, // ServerConfirmationLink = emailLink // }); return Ok(); } catch (Exception ex) { _logger.LogError(ex, "There was an issue during email migration. Contact support"); _unitOfWork.UserRepository.Delete(user); await _unitOfWork.CommitAsync(); } return BadRequest("There was an error setting up your account. Please check the logs"); } private async Task ConfirmEmailToken(string token, AppUser user) { var result = await _userManager.ConfirmEmailAsync(user, token); if (!result.Succeeded) { _logger.LogCritical("Email validation failed"); if (result.Errors.Any()) { foreach (var error in result.Errors) { _logger.LogCritical("Email validation error: {Message}", error.Description); } } return false; } return true; } } }