mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-29 08:00:19 +01:00
315 lines
13 KiB
C#
315 lines
13 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// <see cref="ApiController"/> for managing <see cref="User"/>s.
|
|
/// </summary>
|
|
[Route(Routes.User)]
|
|
public sealed class UserController : ApiController
|
|
{
|
|
/// <summary>
|
|
/// The <see cref="ISystemIdentityFactory"/> for the <see cref="UserController"/>
|
|
/// </summary>
|
|
readonly ISystemIdentityFactory systemIdentityFactory;
|
|
|
|
/// <summary>
|
|
/// The <see cref="ICryptographySuite"/> for the <see cref="UserController"/>
|
|
/// </summary>
|
|
readonly ICryptographySuite cryptographySuite;
|
|
|
|
/// <summary>
|
|
/// The <see cref="GeneralConfiguration"/> for the <see cref="UserController"/>
|
|
/// </summary>
|
|
readonly GeneralConfiguration generalConfiguration;
|
|
|
|
/// <summary>
|
|
/// Construct a <see cref="UserController"/>
|
|
/// </summary>
|
|
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/></param>
|
|
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
|
|
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/></param>
|
|
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/></param>
|
|
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
|
|
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/></param>
|
|
public UserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, ILogger<UserController> logger, IOptions<GeneralConfiguration> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if a given <paramref name="model"/> has a valid <see cref="Api.Models.Internal.User.Name"/> specified.
|
|
/// </summary>
|
|
/// <param name="model">The <see cref="UserUpdate"/> to check.</param>
|
|
/// <param name="newUser">If this is a new <see cref="Api.Models.User"/>.</param>
|
|
/// <returns><see langword="null"/> if <paramref name="model"/> is valid, a <see cref="BadRequestObjectResult"/> otherwise.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempt to change the password of a given <paramref name="dbUser"/>.
|
|
/// </summary>
|
|
/// <param name="dbUser">The user to update.</param>
|
|
/// <param name="newPassword">The new password.</param>
|
|
/// <param name="newUser">If this is for a new <see cref="Api.Models.User"/>.</param>
|
|
/// <returns><see langword="null"/> on success, <see cref="BadRequestObjectResult"/> if <paramref name="newPassword"/> is too short.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a <see cref="Api.Models.User"/>.
|
|
/// </summary>
|
|
/// <param name="model">The <see cref="Api.Models.User"/> to create.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
|
|
/// <response code="201"><see cref="Api.Models.User"/> created successfully.</response>
|
|
/// <response code="410">The requested system identifier could not be found.</response>
|
|
[HttpPut]
|
|
[TgsAuthorize(AdministrationRights.WriteUsers)]
|
|
[ProducesResponseType(typeof(Api.Models.User), 201)]
|
|
public async Task<IActionResult> 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<Models.InstanceUser>()
|
|
};
|
|
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update a <see cref="Api.Models.User"/>.
|
|
/// </summary>
|
|
/// <param name="model">The <see cref="Api.Models.User"/> to update.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
|
|
/// <response code="200"><see cref="Api.Models.User"/> updated successfully.</response>
|
|
/// <response code="404">Requested <see cref="Api.Models.Internal.User.Id"/> does not exist.</response>
|
|
[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<IActionResult> 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
|
|
|
|
/// <summary>
|
|
/// Get information about the current <see cref="Api.Models.User"/>.
|
|
/// </summary>
|
|
/// <returns>The <see cref="IActionResult"/> of the operation.</returns>
|
|
/// <response code="200">The <see cref="Api.Models.User"/> was retrieved successfully.</response>
|
|
[HttpGet]
|
|
[TgsAuthorize]
|
|
[ProducesResponseType(typeof(Api.Models.User), 200)]
|
|
public IActionResult Read() => Json(AuthenticationContext.User.ToApi(true));
|
|
|
|
/// <summary>
|
|
/// List all <see cref="Api.Models.User"/>s in the server.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
|
|
/// <response code="200">Retrieved <see cref="Api.Models.User"/>s successfully.</response>
|
|
[HttpGet(Routes.List)]
|
|
[TgsAuthorize(AdministrationRights.ReadUsers)]
|
|
[ProducesResponseType(typeof(IEnumerable<Api.Models.User>), 200)]
|
|
public async Task<IActionResult> List(CancellationToken cancellationToken)
|
|
{
|
|
var users = await DatabaseContext.Users
|
|
.Include(x => x.CreatedBy)
|
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
|
return Json(users.Select(x => x.ToApi(true)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a specific <see cref="Api.Models.User"/>.
|
|
/// </summary>
|
|
/// <param name="id">The <see cref="Api.Models.Internal.User.Id"/> to retrieve.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
|
|
/// <response code="200">The <see cref="Api.Models.User"/> was retrieved successfully.</response>
|
|
/// <response code="404">The <see cref="Api.Models.User"/> does not exist.</response>
|
|
[HttpGet("{id}")]
|
|
[TgsAuthorize]
|
|
[ProducesResponseType(typeof(Api.Models.User), 200)]
|
|
[ProducesResponseType(typeof(ErrorMessage), 404)]
|
|
public async Task<IActionResult> 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));
|
|
}
|
|
}
|
|
}
|