Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
407 changes: 407 additions & 0 deletions API.IntegrationTests/Tests/AutomationTokenTests.cs

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion API/Controller/Account/LoginV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
using OpenShock.Common.Errors;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.AutomationTokens;
using OpenShock.API.Errors;
using OpenShock.API.Models.Response;
using OpenShock.API.Services.Turnstile;
using Results = OpenShock.Common.Results;
Expand All @@ -28,11 +30,12 @@ public sealed partial class AccountController
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<LoginV2OkResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain, AutomationTokenNotAllowedForAccount
[MapToApiVersion("2")]
public async Task<IActionResult> LoginV2(
[FromBody] LoginV2 body,
[FromServices] ICloudflareTurnstileService turnstileService,
[FromServices] IAutomationTokenService automationTokens,
CancellationToken cancellationToken)
{
var cookieDomain = GetCurrentCookieDomain();
Expand All @@ -41,6 +44,11 @@ public async Task<IActionResult> LoginV2(
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
if (turnstileError is not null) return turnstileError;

// Checked before the password is: with Turnstile and rate limits lifted, an automation token must not
// be usable to guess passwords of privileged accounts.
if (!await automationTokens.CanUseForLoginAsync(body.UsernameOrEmail, cancellationToken))
return Problem(AutomationTokenError.NotAllowedForAccount);

var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken);
if (getAccountResult is not User account)
{
Expand All @@ -53,6 +61,9 @@ public async Task<IActionResult> LoginV2(
_ => throw new UnreachableException()
};
}

if (!await automationTokens.TryRecordUseAsync(account.Id, AutomationTokenFlow.Login, cancellationToken))
return Problem(AutomationTokenError.NotAllowedForAccount);

await CreateSession(account.Id, cookieDomain);

Expand Down
13 changes: 12 additions & 1 deletion API/Controller/Account/PasswordResetInitiateV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
using OpenShock.API.Models.Requests;
using OpenShock.API.Services.Turnstile;
using OpenShock.Common.Errors;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.AutomationTokens;

using OpenShock.Internal.Common.Problems;

Expand All @@ -32,11 +34,20 @@ public sealed partial class AccountController
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)]
[MapToApiVersion("2")]
public async Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
public async Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IAutomationTokenService automationTokens, CancellationToken cancellationToken)
{
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
if (turnstileError is not null) return turnstileError;

// Privileged accounts must never be reached through a bypassed flow. The lookup runs only on the
// bypass path, so the normal path keeps its timing profile, and the response stays the generic
// 200 so this does not become an admin-account oracle.
if (!await automationTokens.TryRecordUseByEmailAsync(body.Email, AutomationTokenFlow.PasswordReset, cancellationToken))
{
_logger.LogWarning("Refused a bypassed password reset for a privileged account");
return Ok();
}

await _accountService.CreatePasswordResetFlowAsync(body.Email);

return Ok();
Expand Down
18 changes: 12 additions & 6 deletions API/Controller/Account/SignupV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,18 @@ public async Task<IActionResult> SignUpV2(
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
if (turnstileError is not null) return turnstileError;

var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
return creationAction switch
// Created with an automation token, the account is linked to it and the use audited in the same transaction.
var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(
body.Email, body.Username, body.Password, cancellationToken);
if (creationAction is not User _)
{
User _ => Ok(),
AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists),
_ => throw new UnreachableException()
};
return creationAction switch
{
AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists),
_ => throw new UnreachableException()
};
}

return Ok();
}
}
63 changes: 63 additions & 0 deletions API/Controller/Admin/AutomationTokenCreate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Controller.Admin.DTOs;
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Audit;
using OpenShock.Common.Services.AutomationTokens;
using OpenShock.Common.Utils;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
/// <summary>
/// Creates an automation token. The secret is only returned once, in this response
/// </summary>
[HttpPost("automationTokens")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<CreatedAutomationTokenDto>(StatusCodes.Status200OK)]
public async Task<CreatedAutomationTokenDto> CreateAutomationToken(
[FromBody] CreateAutomationTokenDto body,
[FromServices] IAuditService auditService,
CancellationToken ct)
{
PedanticallyEnsureAdmin();

var secret = IAutomationTokenService.GenerateSecret();
var token = new AutomationToken
{
Id = Guid.CreateVersion7(),
Name = body.Name.Trim(),
TokenHash = HashingUtils.HashToken(secret),
Types = [.. body.Types.Distinct()],
AutoCleanupUsers = body.AutoCleanupUsers,
AutoCleanupAfter = body.AutoCleanupAfter,
};

await using var transaction = await _db.Database.BeginTransactionAsync(ct);

_db.AutomationTokens.Add(token);
await _db.SaveChangesAsync(ct);

await auditService.LogAsync(
CurrentUser.Id,
action: AuditAction.AutomationTokenCreated,
actorId: CurrentUser.Id,
metadata: new AutomationTokenCreatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter),
cancellationToken: ct);

await transaction.CommitAsync(ct);

return new CreatedAutomationTokenDto
{
Id = token.Id,
Name = token.Name,
Secret = secret,
Types = token.Types,
CreatedAt = token.CreatedAt,
AutoCleanupUsers = token.AutoCleanupUsers,
AutoCleanupAfter = token.AutoCleanupAfter,
};
}
}
79 changes: 79 additions & 0 deletions API/Controller/Admin/AutomationTokenDelete.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Audit;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
/// <summary>
/// Deletes an automation token together with every account it created. Refused while any of those
/// accounts holds a privileged role
/// </summary>
[HttpDelete("automationTokens/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeleteAutomationToken(
[FromRoute] Guid id,
[FromServices] IAuditService auditService,
CancellationToken ct)
{
PedanticallyEnsureAdmin();

var token = await _db.AutomationTokens.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id, ct);
if (token is null) return NotFound();

await using var transaction = await _db.Database.BeginTransactionAsync(ct);

var createdUsers = await _db.Users
.Where(u => u.CreatedByAutomationTokenId == id)
.Select(u => new { u.Id, u.Roles })
.ToListAsync(ct);

if (createdUsers.Any(u => PrivilegedRoles.Any(u.Roles))) return PrivilegedAccountConflict();

var userIds = createdUsers.Select(u => u.Id).ToArray();

// The roles are checked again as the rows are deleted, so an account promoted since it was read is kept.
await _db.Database.ExecuteSqlAsync(
$"DELETE FROM users WHERE id = ANY({userIds}) AND NOT (roles && {PrivilegedRoles.All})", ct);

// The foreign key restricts, so this fails while any account still references the token: a privileged
// one, or one created or promoted meanwhile. On success, exactly userIds went with it.
try
{
await _db.AutomationTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct);
}
catch (PostgresException e) when (e.SqlState == PostgresErrorCodes.ForeignKeyViolation)
{
return PrivilegedAccountConflict();
}

await auditService.LogAsync(
CurrentUser.Id,
action: AuditAction.AutomationTokenDeleted,
actorId: CurrentUser.Id,
metadata: new AutomationTokenDeletedMetadata(token.Id, token.Name, userIds.Length),
cancellationToken: ct);

await transaction.CommitAsync(ct);

// Each deleted account's own audit log goes with it, so the log line is the lasting record of what went.
foreach (var userId in userIds)
{
_logger.LogInformation(
"Deleted account {UserId} together with automation token {AutomationTokenId}",
userId, token.Id);
}

return Ok();

IActionResult PrivilegedAccountConflict() => Problem(
"This automation token created a privileged account; remove its privileged roles before deleting the token.",
statusCode: StatusCodes.Status409Conflict);
}
}
22 changes: 22 additions & 0 deletions API/Controller/Admin/AutomationTokenList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenShock.API.Controller.Admin.DTOs;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
/// <summary>
/// Lists all automation tokens
/// </summary>
[HttpGet("automationTokens")]
public async IAsyncEnumerable<AutomationTokenDto> ListAutomationTokens()
{
PedanticallyEnsureAdmin();

await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable())
{
yield return AutomationTokenDto.FromEntity(token);
}
}
}
60 changes: 60 additions & 0 deletions API/Controller/Admin/AutomationTokenPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenShock.API.Controller.Admin.DTOs;
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Audit;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
/// <summary>
/// Updates an automation token
/// </summary>
[HttpPatch("automationTokens/{id}")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<AutomationTokenDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> PatchAutomationToken(
[FromRoute] Guid id,
[FromBody] PatchAutomationTokenDto body,
[FromServices] IAuditService auditService,
CancellationToken ct)
{
PedanticallyEnsureAdmin();

var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
if (token is null) return NotFound();

var autoCleanupUsers = body.AutoCleanupUsers ?? token.AutoCleanupUsers;

// Switching cleanup off without naming a delay clears the delay, so it can be unset at all.
var autoCleanupAfter = body.AutoCleanupAfter ??
(body.AutoCleanupUsers == false ? null : token.AutoCleanupAfter);

if (autoCleanupUsers && autoCleanupAfter is null)
return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest);

if (body.Name is not null) token.Name = body.Name.Trim();
if (body.Types is not null) token.Types = [.. body.Types.Distinct()];
token.AutoCleanupUsers = autoCleanupUsers;
token.AutoCleanupAfter = autoCleanupAfter;

await using var transaction = await _db.Database.BeginTransactionAsync(ct);

await _db.SaveChangesAsync(ct);

await auditService.LogAsync(
CurrentUser.Id,
action: AuditAction.AutomationTokenUpdated,
actorId: CurrentUser.Id,
metadata: new AutomationTokenUpdatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter),
cancellationToken: ct);

await transaction.CommitAsync(ct);

return Ok(AutomationTokenDto.FromEntity(token));
}
}
64 changes: 64 additions & 0 deletions API/Controller/Admin/AutomationTokenRotate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenShock.API.Controller.Admin.DTOs;
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Audit;
using OpenShock.Common.Services.AutomationTokens;
using OpenShock.Common.Utils;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
/// <summary>
/// Rotates the secret of an automation token. The new secret is only returned once, in this response
/// </summary>
[HttpPost("automationTokens/{id}/rotate")]
[ProducesResponseType<CreatedAutomationTokenDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RotateAutomationToken(
[FromRoute] Guid id,
[FromServices] IAuditService auditService,
CancellationToken ct)
{
PedanticallyEnsureAdmin();

var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
if (token is null) return NotFound();

var secret = IAutomationTokenService.GenerateSecret();
token.TokenHash = HashingUtils.HashToken(secret);
token.LastUsedAt = null;
token.UseCount = 0;
token.LastRotatedAt = DateTime.UtcNow;
Comment thread
hhvrc marked this conversation as resolved.

await using var transaction = await _db.Database.BeginTransactionAsync(ct);

await _db.SaveChangesAsync(ct);

await auditService.LogAsync(
CurrentUser.Id,
action: AuditAction.AutomationTokenRotated,
actorId: CurrentUser.Id,
metadata: new AutomationTokenRotatedMetadata(token.Id, token.Name),
cancellationToken: ct);

await transaction.CommitAsync(ct);

// Accounts the token created stay linked to it (and to its cleanup schedule) across rotations,
// since the token id doesn't change.

return Ok(new CreatedAutomationTokenDto
{
Id = token.Id,
Name = token.Name,
Secret = secret,
Types = token.Types,
CreatedAt = token.CreatedAt,
LastRotatedAt = token.LastRotatedAt,
AutoCleanupUsers = token.AutoCleanupUsers,
AutoCleanupAfter = token.AutoCleanupAfter,
});
}
}
Loading
Loading