Skip to content

Commit dae24ca

Browse files
authored
feat: Account automation tokens (#309)
1 parent f6e57d3 commit dae24ca

42 files changed

Lines changed: 5381 additions & 1640 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎API.IntegrationTests/Tests/AutomationTokenTests.cs‎

Lines changed: 407 additions & 0 deletions
Large diffs are not rendered by default.

‎API/Controller/Account/LoginV2.cs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
using OpenShock.Common.Errors;
99
using OpenShock.Common.OpenShockDb;
1010
using OpenShock.Common.Problems;
11+
using OpenShock.Common.Services.AutomationTokens;
12+
using OpenShock.API.Errors;
1113
using OpenShock.API.Models.Response;
1214
using OpenShock.API.Services.Turnstile;
1315
using Results = OpenShock.Common.Results;
@@ -28,11 +30,12 @@ public sealed partial class AccountController
2830
[Consumes(MediaTypeNames.Application.Json)]
2931
[ProducesResponseType<LoginV2OkResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
3032
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials
31-
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain
33+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain, AutomationTokenNotAllowedForAccount
3234
[MapToApiVersion("2")]
3335
public async Task<IActionResult> LoginV2(
3436
[FromBody] LoginV2 body,
3537
[FromServices] ICloudflareTurnstileService turnstileService,
38+
[FromServices] IAutomationTokenService automationTokens,
3639
CancellationToken cancellationToken)
3740
{
3841
var cookieDomain = GetCurrentCookieDomain();
@@ -41,6 +44,11 @@ public async Task<IActionResult> LoginV2(
4144
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
4245
if (turnstileError is not null) return turnstileError;
4346

47+
// Checked before the password is: with Turnstile and rate limits lifted, an automation token must not
48+
// be usable to guess passwords of privileged accounts.
49+
if (!await automationTokens.CanUseForLoginAsync(body.UsernameOrEmail, cancellationToken))
50+
return Problem(AutomationTokenError.NotAllowedForAccount);
51+
4452
var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken);
4553
if (getAccountResult is not User account)
4654
{
@@ -53,6 +61,9 @@ public async Task<IActionResult> LoginV2(
5361
_ => throw new UnreachableException()
5462
};
5563
}
64+
65+
if (!await automationTokens.TryRecordUseAsync(account.Id, AutomationTokenFlow.Login, cancellationToken))
66+
return Problem(AutomationTokenError.NotAllowedForAccount);
5667

5768
await CreateSession(account.Id, cookieDomain);
5869

‎API/Controller/Account/PasswordResetInitiateV2.cs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
using OpenShock.API.Models.Requests;
66
using OpenShock.API.Services.Turnstile;
77
using OpenShock.Common.Errors;
8+
using OpenShock.Common.OpenShockDb;
89
using OpenShock.Common.Problems;
10+
using OpenShock.Common.Services.AutomationTokens;
911

1012
using OpenShock.Internal.Common.Problems;
1113

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

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

4253
return Ok();

‎API/Controller/Account/SignupV2.cs‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,18 @@ public async Task<IActionResult> SignUpV2(
4646
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
4747
if (turnstileError is not null) return turnstileError;
4848

49-
var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
50-
return creationAction switch
49+
// Created with an automation token, the account is linked to it and the use audited in the same transaction.
50+
var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(
51+
body.Email, body.Username, body.Password, cancellationToken);
52+
if (creationAction is not User _)
5153
{
52-
User _ => Ok(),
53-
AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists),
54-
_ => throw new UnreachableException()
55-
};
54+
return creationAction switch
55+
{
56+
AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists),
57+
_ => throw new UnreachableException()
58+
};
59+
}
60+
61+
return Ok();
5662
}
5763
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System.Net.Mime;
2+
using Microsoft.AspNetCore.Mvc;
3+
using OpenShock.API.Controller.Admin.DTOs;
4+
using OpenShock.Common.Models;
5+
using OpenShock.Common.OpenShockDb;
6+
using OpenShock.Common.Services.Audit;
7+
using OpenShock.Common.Services.AutomationTokens;
8+
using OpenShock.Common.Utils;
9+
10+
namespace OpenShock.API.Controller.Admin;
11+
12+
public sealed partial class AdminController
13+
{
14+
/// <summary>
15+
/// Creates an automation token. The secret is only returned once, in this response
16+
/// </summary>
17+
[HttpPost("automationTokens")]
18+
[Consumes(MediaTypeNames.Application.Json)]
19+
[ProducesResponseType<CreatedAutomationTokenDto>(StatusCodes.Status200OK)]
20+
public async Task<CreatedAutomationTokenDto> CreateAutomationToken(
21+
[FromBody] CreateAutomationTokenDto body,
22+
[FromServices] IAuditService auditService,
23+
CancellationToken ct)
24+
{
25+
PedanticallyEnsureAdmin();
26+
27+
var secret = IAutomationTokenService.GenerateSecret();
28+
var token = new AutomationToken
29+
{
30+
Id = Guid.CreateVersion7(),
31+
Name = body.Name.Trim(),
32+
TokenHash = HashingUtils.HashToken(secret),
33+
Types = [.. body.Types.Distinct()],
34+
AutoCleanupUsers = body.AutoCleanupUsers,
35+
AutoCleanupAfter = body.AutoCleanupAfter,
36+
};
37+
38+
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
39+
40+
_db.AutomationTokens.Add(token);
41+
await _db.SaveChangesAsync(ct);
42+
43+
await auditService.LogAsync(
44+
CurrentUser.Id,
45+
action: AuditAction.AutomationTokenCreated,
46+
actorId: CurrentUser.Id,
47+
metadata: new AutomationTokenCreatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter),
48+
cancellationToken: ct);
49+
50+
await transaction.CommitAsync(ct);
51+
52+
return new CreatedAutomationTokenDto
53+
{
54+
Id = token.Id,
55+
Name = token.Name,
56+
Secret = secret,
57+
Types = token.Types,
58+
CreatedAt = token.CreatedAt,
59+
AutoCleanupUsers = token.AutoCleanupUsers,
60+
AutoCleanupAfter = token.AutoCleanupAfter,
61+
};
62+
}
63+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.EntityFrameworkCore;
3+
using Npgsql;
4+
using OpenShock.Common.Models;
5+
using OpenShock.Common.OpenShockDb;
6+
using OpenShock.Common.Services.Audit;
7+
8+
namespace OpenShock.API.Controller.Admin;
9+
10+
public sealed partial class AdminController
11+
{
12+
/// <summary>
13+
/// Deletes an automation token together with every account it created. Refused while any of those
14+
/// accounts holds a privileged role
15+
/// </summary>
16+
[HttpDelete("automationTokens/{id}")]
17+
[ProducesResponseType(StatusCodes.Status200OK)]
18+
[ProducesResponseType(StatusCodes.Status404NotFound)]
19+
[ProducesResponseType(StatusCodes.Status409Conflict)]
20+
public async Task<IActionResult> DeleteAutomationToken(
21+
[FromRoute] Guid id,
22+
[FromServices] IAuditService auditService,
23+
CancellationToken ct)
24+
{
25+
PedanticallyEnsureAdmin();
26+
27+
var token = await _db.AutomationTokens.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id, ct);
28+
if (token is null) return NotFound();
29+
30+
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
31+
32+
var createdUsers = await _db.Users
33+
.Where(u => u.CreatedByAutomationTokenId == id)
34+
.Select(u => new { u.Id, u.Roles })
35+
.ToListAsync(ct);
36+
37+
if (createdUsers.Any(u => PrivilegedRoles.Any(u.Roles))) return PrivilegedAccountConflict();
38+
39+
var userIds = createdUsers.Select(u => u.Id).ToArray();
40+
41+
// The roles are checked again as the rows are deleted, so an account promoted since it was read is kept.
42+
await _db.Database.ExecuteSqlAsync(
43+
$"DELETE FROM users WHERE id = ANY({userIds}) AND NOT (roles && {PrivilegedRoles.All})", ct);
44+
45+
// The foreign key restricts, so this fails while any account still references the token: a privileged
46+
// one, or one created or promoted meanwhile. On success, exactly userIds went with it.
47+
try
48+
{
49+
await _db.AutomationTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct);
50+
}
51+
catch (PostgresException e) when (e.SqlState == PostgresErrorCodes.ForeignKeyViolation)
52+
{
53+
return PrivilegedAccountConflict();
54+
}
55+
56+
await auditService.LogAsync(
57+
CurrentUser.Id,
58+
action: AuditAction.AutomationTokenDeleted,
59+
actorId: CurrentUser.Id,
60+
metadata: new AutomationTokenDeletedMetadata(token.Id, token.Name, userIds.Length),
61+
cancellationToken: ct);
62+
63+
await transaction.CommitAsync(ct);
64+
65+
// Each deleted account's own audit log goes with it, so the log line is the lasting record of what went.
66+
foreach (var userId in userIds)
67+
{
68+
_logger.LogInformation(
69+
"Deleted account {UserId} together with automation token {AutomationTokenId}",
70+
userId, token.Id);
71+
}
72+
73+
return Ok();
74+
75+
IActionResult PrivilegedAccountConflict() => Problem(
76+
"This automation token created a privileged account; remove its privileged roles before deleting the token.",
77+
statusCode: StatusCodes.Status409Conflict);
78+
}
79+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.EntityFrameworkCore;
3+
using OpenShock.API.Controller.Admin.DTOs;
4+
5+
namespace OpenShock.API.Controller.Admin;
6+
7+
public sealed partial class AdminController
8+
{
9+
/// <summary>
10+
/// Lists all automation tokens
11+
/// </summary>
12+
[HttpGet("automationTokens")]
13+
public async IAsyncEnumerable<AutomationTokenDto> ListAutomationTokens()
14+
{
15+
PedanticallyEnsureAdmin();
16+
17+
await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable())
18+
{
19+
yield return AutomationTokenDto.FromEntity(token);
20+
}
21+
}
22+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Net.Mime;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.EntityFrameworkCore;
4+
using OpenShock.API.Controller.Admin.DTOs;
5+
using OpenShock.Common.Models;
6+
using OpenShock.Common.OpenShockDb;
7+
using OpenShock.Common.Services.Audit;
8+
9+
namespace OpenShock.API.Controller.Admin;
10+
11+
public sealed partial class AdminController
12+
{
13+
/// <summary>
14+
/// Updates an automation token
15+
/// </summary>
16+
[HttpPatch("automationTokens/{id}")]
17+
[Consumes(MediaTypeNames.Application.Json)]
18+
[ProducesResponseType<AutomationTokenDto>(StatusCodes.Status200OK)]
19+
[ProducesResponseType(StatusCodes.Status404NotFound)]
20+
public async Task<IActionResult> PatchAutomationToken(
21+
[FromRoute] Guid id,
22+
[FromBody] PatchAutomationTokenDto body,
23+
[FromServices] IAuditService auditService,
24+
CancellationToken ct)
25+
{
26+
PedanticallyEnsureAdmin();
27+
28+
var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
29+
if (token is null) return NotFound();
30+
31+
var autoCleanupUsers = body.AutoCleanupUsers ?? token.AutoCleanupUsers;
32+
33+
// Switching cleanup off without naming a delay clears the delay, so it can be unset at all.
34+
var autoCleanupAfter = body.AutoCleanupAfter ??
35+
(body.AutoCleanupUsers == false ? null : token.AutoCleanupAfter);
36+
37+
if (autoCleanupUsers && autoCleanupAfter is null)
38+
return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest);
39+
40+
if (body.Name is not null) token.Name = body.Name.Trim();
41+
if (body.Types is not null) token.Types = [.. body.Types.Distinct()];
42+
token.AutoCleanupUsers = autoCleanupUsers;
43+
token.AutoCleanupAfter = autoCleanupAfter;
44+
45+
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
46+
47+
await _db.SaveChangesAsync(ct);
48+
49+
await auditService.LogAsync(
50+
CurrentUser.Id,
51+
action: AuditAction.AutomationTokenUpdated,
52+
actorId: CurrentUser.Id,
53+
metadata: new AutomationTokenUpdatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter),
54+
cancellationToken: ct);
55+
56+
await transaction.CommitAsync(ct);
57+
58+
return Ok(AutomationTokenDto.FromEntity(token));
59+
}
60+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.EntityFrameworkCore;
3+
using OpenShock.API.Controller.Admin.DTOs;
4+
using OpenShock.Common.Models;
5+
using OpenShock.Common.OpenShockDb;
6+
using OpenShock.Common.Services.Audit;
7+
using OpenShock.Common.Services.AutomationTokens;
8+
using OpenShock.Common.Utils;
9+
10+
namespace OpenShock.API.Controller.Admin;
11+
12+
public sealed partial class AdminController
13+
{
14+
/// <summary>
15+
/// Rotates the secret of an automation token. The new secret is only returned once, in this response
16+
/// </summary>
17+
[HttpPost("automationTokens/{id}/rotate")]
18+
[ProducesResponseType<CreatedAutomationTokenDto>(StatusCodes.Status200OK)]
19+
[ProducesResponseType(StatusCodes.Status404NotFound)]
20+
public async Task<IActionResult> RotateAutomationToken(
21+
[FromRoute] Guid id,
22+
[FromServices] IAuditService auditService,
23+
CancellationToken ct)
24+
{
25+
PedanticallyEnsureAdmin();
26+
27+
var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
28+
if (token is null) return NotFound();
29+
30+
var secret = IAutomationTokenService.GenerateSecret();
31+
token.TokenHash = HashingUtils.HashToken(secret);
32+
token.LastUsedAt = null;
33+
token.UseCount = 0;
34+
token.LastRotatedAt = DateTime.UtcNow;
35+
36+
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
37+
38+
await _db.SaveChangesAsync(ct);
39+
40+
await auditService.LogAsync(
41+
CurrentUser.Id,
42+
action: AuditAction.AutomationTokenRotated,
43+
actorId: CurrentUser.Id,
44+
metadata: new AutomationTokenRotatedMetadata(token.Id, token.Name),
45+
cancellationToken: ct);
46+
47+
await transaction.CommitAsync(ct);
48+
49+
// Accounts the token created stay linked to it (and to its cleanup schedule) across rotations,
50+
// since the token id doesn't change.
51+
52+
return Ok(new CreatedAutomationTokenDto
53+
{
54+
Id = token.Id,
55+
Name = token.Name,
56+
Secret = secret,
57+
Types = token.Types,
58+
CreatedAt = token.CreatedAt,
59+
LastRotatedAt = token.LastRotatedAt,
60+
AutoCleanupUsers = token.AutoCleanupUsers,
61+
AutoCleanupAfter = token.AutoCleanupAfter,
62+
});
63+
}
64+
}

0 commit comments

Comments
 (0)