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));
}
}
}