using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// /// for managing s. /// [Route(Routes.User)] public sealed class UserController : ApiController { /// /// The for the /// readonly ISystemIdentityFactory systemIdentityFactory; /// /// The for the /// readonly ICryptographySuite cryptographySuite; /// /// The for the /// readonly GeneralConfiguration generalConfiguration; /// /// Construct a /// /// The for the /// The for the /// The value of /// The value of /// The for the . /// The containing the value of public UserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, ILogger logger, IOptions generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false, true) { this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } /// /// Check if a given has a valid specified. /// /// The to check. /// If this is a new . /// if is valid, a otherwise. BadRequestObjectResult CheckValidName(UserUpdate model, bool newUser) { var userInvalidWithNullName = newUser && model.Name == null && model.SystemIdentifier == null; if (userInvalidWithNullName || (model.Name != null && String.IsNullOrWhiteSpace(model.Name))) return BadRequest(new ErrorMessage(ErrorCode.UserMissingName)); model.Name = model.Name?.Trim(); if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture)) return BadRequest(new ErrorMessage(ErrorCode.UserColonInName)); return null; } /// /// Attempt to change the password of a given . /// /// The user to update. /// The new password. /// If this is for a new . /// on success, if is too short. BadRequestObjectResult TrySetPassword(Models.User dbUser, string newPassword, bool newUser) { newPassword ??= String.Empty; if (newPassword.Length < generalConfiguration.MinimumPasswordLength) return BadRequest(new ErrorMessage(ErrorCode.UserPasswordLength) { AdditionalData = $"Required password length: {generalConfiguration.MinimumPasswordLength}" }); cryptographySuite.SetUserPassword(dbUser, newPassword, newUser); return null; } /// /// Create a . /// /// The to create. /// The for the operation. /// A resulting in the of the operation. /// created successfully. /// The requested system identifier could not be found. [HttpPut] [TgsAuthorize(AdministrationRights.WriteUsers)] [ProducesResponseType(typeof(Api.Models.User), 201)] public async Task Create([FromBody] UserUpdate model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); if (!(model.Password == null ^ model.SystemIdentifier == null)) return BadRequest(new ErrorMessage(ErrorCode.UserMismatchPasswordSid)); model.Name = model.Name?.Trim(); if (model.Name?.Length == 0) model.Name = null; if (!(model.Name == null ^ model.SystemIdentifier == null)) return BadRequest(new ErrorMessage(ErrorCode.UserMismatchNameSid)); var fail = CheckValidName(model, true); if (fail != null) return fail; var dbUser = new Models.User { AdministrationRights = RightsHelper.Clamp(model.AdministrationRights ?? AdministrationRights.None), CreatedAt = DateTimeOffset.Now, CreatedBy = AuthenticationContext.User, Enabled = model.Enabled ?? false, InstanceManagerRights = RightsHelper.Clamp(model.InstanceManagerRights ?? InstanceManagerRights.None), Name = model.Name, SystemIdentifier = model.SystemIdentifier, InstanceUsers = new List() }; if (model.SystemIdentifier != null) try { using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken).ConfigureAwait(false); if (sysIdentity == null) return Gone(); dbUser.Name = sysIdentity.Username; dbUser.SystemIdentifier = sysIdentity.Uid; } catch (NotImplementedException) { return RequiresPosixSystemIdentity(); } else { var result = TrySetPassword(dbUser, model.Password, true); if (result != null) return result; } dbUser.CanonicalName = Models.User.CanonicalizeName(dbUser.Name); DatabaseContext.Users.Add(dbUser); await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); return StatusCode((int)HttpStatusCode.Created, dbUser.ToApi(true)); } /// /// Update a . /// /// The to update. /// The for the operation. /// A resulting in the of the operation. /// updated successfully. /// Requested does not exist. [HttpPost] [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] [ProducesResponseType(typeof(Api.Models.User), 200)] [ProducesResponseType(typeof(ErrorMessage), 404)] #pragma warning disable CA1502 // TODO: Decomplexify #pragma warning disable CA1506 public async Task Update([FromBody] UserUpdate model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); if (!model.Id.HasValue) return BadRequest(new ErrorMessage(ErrorCode.UserMissingId)); var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); var passwordEditOnly = !callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); var originalUser = passwordEditOnly ? AuthenticationContext.User : await DatabaseContext .Users .AsQueryable() .Where(x => x.Id == model.Id) .Include(x => x.CreatedBy) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); if (originalUser == default) return NotFound(); // Ensure they are only trying to edit password (system identity change will trigger a bad request) if (passwordEditOnly && (model.Id != originalUser.Id || model.InstanceManagerRights.HasValue || model.AdministrationRights.HasValue || model.Enabled.HasValue || model.Name != null)) return Forbid(); if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) return BadRequest(new ErrorMessage(ErrorCode.UserSidChange)); if (model.Password != null) { var result = TrySetPassword(originalUser, model.Password, false); if (result != null) return result; } if (model.Name != null && Models.User.CanonicalizeName(model.Name) != originalUser.CanonicalName) return BadRequest(new ErrorMessage(ErrorCode.UserNameChange)); originalUser.InstanceManagerRights = RightsHelper.Clamp(model.InstanceManagerRights ?? originalUser.InstanceManagerRights.Value); originalUser.AdministrationRights = RightsHelper.Clamp(model.AdministrationRights ?? originalUser.AdministrationRights.Value); if (model.Enabled.HasValue) { if (originalUser.Enabled.Value && !model.Enabled.Value) originalUser.LastPasswordUpdate = DateTimeOffset.Now; originalUser.Enabled = model.Enabled.Value; } var fail = CheckValidName(model, false); if (fail != null) return fail; originalUser.Name = model.Name ?? originalUser.Name; await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); // return id only if not a self update and cannot read users return Json( model.Id == originalUser.Id || callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers) ? originalUser.ToApi(true) : new Api.Models.User { Id = originalUser.Id }); } #pragma warning restore CA1506 #pragma warning restore CA1502 /// /// Get information about the current . /// /// The of the operation. /// The was retrieved successfully. [HttpGet] [TgsAuthorize] [ProducesResponseType(typeof(Api.Models.User), 200)] public IActionResult Read() => Json(AuthenticationContext.User.ToApi(true)); /// /// List all s in the server. /// /// The for the operation. /// A resulting in the of the operation. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize(AdministrationRights.ReadUsers)] [ProducesResponseType(typeof(IEnumerable), 200)] public async Task List(CancellationToken cancellationToken) { var users = await DatabaseContext.Users .Include(x => x.CreatedBy) .ToListAsync(cancellationToken).ConfigureAwait(false); return Json(users.Select(x => x.ToApi(true))); } /// /// Get a specific . /// /// The to retrieve. /// The for the operation. /// A resulting in the of the operation. /// The was retrieved successfully. /// The does not exist. [HttpGet("{id}")] [TgsAuthorize] [ProducesResponseType(typeof(Api.Models.User), 200)] [ProducesResponseType(typeof(ErrorMessage), 404)] public async Task GetId(long id, CancellationToken cancellationToken) { if (id == AuthenticationContext.User.Id) return Read(); if (!((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) return Forbid(); var user = await DatabaseContext.Users .AsQueryable() .Where(x => x.Id == id) .Include(x => x.CreatedBy) .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (user == default) return NotFound(); return Json(user.ToApi(true)); } } }