Skip to content
Draft
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
2 changes: 1 addition & 1 deletion API.IntegrationTests/Helpers/TestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static async Task<AuthenticatedUser> CreateAndLoginUser(
// 2. Create session via ISessionService (stored in Redis)
await using var scope = factory.Services.CreateAsyncScope();
var sessionService = scope.ServiceProvider.GetRequiredService<ISessionService>();
var session = await sessionService.CreateSessionAsync(userId, "IntegrationTest", "127.0.0.1");
var session = await sessionService.CreateSessionAsync(userId, "IntegrationTest", "127.0.0.1", actorId: userId);

return new AuthenticatedUser(userId, username, email, session.Token);
}
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Account/Authenticated/ChangeEmail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body)
return Problem(AccountError.PasswordChangeInvalidPassword);
}

var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email);
var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id);

return result.Match<IActionResult>(
success => Ok(),
Expand Down
5 changes: 2 additions & 3 deletions API/Controller/Account/Authenticated/ChangePassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,12 @@ public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest
return Problem(AccountError.PasswordChangeInvalidPassword);
}

var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword);
var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id);

return result.Match<IActionResult>(
success => Ok(),
notActivated => throw new UnreachableException("Authenticated user is not activated"),
deactivated => throw new UnreachableException("Authenticated user is deactivated"),
notFound => throw new UnreachableException("Authenticated user not found in database"));

}
}
}
4 changes: 2 additions & 2 deletions API/Controller/Account/Authenticated/ChangeUsername.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest body)
{
var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username,
CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System));
var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username, actorId: CurrentUser.Id,
ignoreLimit: CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System));

return result.Match<IActionResult>(
success => Ok(),
Expand Down
49 changes: 49 additions & 0 deletions API/Controller/Account/Authenticated/GetAuditLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Models.Response;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Audit;
using OpenShock.Common.Utils.Pagination;

namespace OpenShock.API.Controller.Account.Authenticated;

public sealed partial class AuthenticatedAccountController
{
/// <summary>
/// Get the audit log for the current user's account.
/// </summary>
/// <param name="pagination">Page, sort, and search parameters.</param>
/// <param name="auditService"></param>
/// <param name="cancellationToken"></param>
/// <response code="200">A page of audit log entries.</response>
[HttpGet("audit-log")]
[ProducesResponseType<PagedResult<AuditLogEntryResponse>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<PagedResult<AuditLogEntryResponse>> GetAuditLog(
[FromQuery] PaginationQuery pagination,
[FromServices] IAuditService auditService,
CancellationToken cancellationToken)
{
var paged = await auditService.GetPagedForUserAsync(CurrentUser.Id, pagination, cancellationToken);
return MapPaged(paged);
}

internal static PagedResult<AuditLogEntryResponse> MapPaged(PagedResult<UserAuditLog> paged) => new()
{
Items = paged.Items.Select(MapEntry).ToArray(),
Page = paged.Page,
PageSize = paged.PageSize,
TotalCount = paged.TotalCount,
};

private static AuditLogEntryResponse MapEntry(UserAuditLog x) => new()
{
Id = x.Id,
UserId = x.UserId,
ActorId = x.ActorId,
Action = x.Action,
IpAddress = x.IpAddress,
UserAgent = x.UserAgent,
Metadata = x.Metadata,
CreatedAt = x.CreatedAt,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken)
{
var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken);
var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, actorId: CurrentUser.Id, cancellationToken);

if (!deleted)
return NotFound();
Expand Down
6 changes: 5 additions & 1 deletion API/Controller/Account/Logout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ public async Task<IActionResult> Logout([FromServices] ISessionService sessionSe
// Remove session if valid
if (HttpContext.TryGetUserSessionToken(out var sessionToken))
{
await sessionService.DeleteSessionByTokenAsync(sessionToken);
var session = await sessionService.GetSessionByTokenAsync(sessionToken);
if (session is not null)
{
await sessionService.LogoutSessionAsync(session);
}
}

// Make sure cookie is removed, no matter if authenticated or not
Expand Down
7 changes: 5 additions & 2 deletions API/Controller/Admin/DeactivateUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ public sealed partial class AdminController
/// <response code="401">Unauthorized</response>
[HttpPut("users/{userId}/deactivate")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> DeactivateUser([FromRoute] Guid userId, [FromQuery(Name="deleteLater")] bool deleteLater, [FromServices] IAccountService accountService)
public async Task<IActionResult> DeactivateUser(
[FromRoute] Guid userId,
[FromQuery(Name = "deleteLater")] bool deleteLater,
[FromServices] IAccountService accountService)
{
var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater);
return deactivationResult.Match(
return deactivationResult.Match<IActionResult>(
success => Ok("Account deactivated"),
cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount),
alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated),
Expand Down
6 changes: 4 additions & 2 deletions API/Controller/Admin/DeleteUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ public sealed partial class AdminController
/// <response code="401">Unauthorized</response>
[HttpDelete("users/{userId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> DeleteUser([FromRoute] Guid userId, [FromServices] IAccountService accountService)
public async Task<IActionResult> DeleteUser(
[FromRoute] Guid userId,
[FromServices] IAccountService accountService)
{
var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId);
return result.Match(
return result.Match<IActionResult>(
success => Ok("Account deleted"),
cannotDeletePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount),
unauthorized => Problem(AccountActivationError.Unauthorized),
Expand Down
33 changes: 33 additions & 0 deletions API/Controller/Admin/GetAuditLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Controller.Account.Authenticated;
using OpenShock.API.Models.Response;
using OpenShock.Common.Services.Audit;
using OpenShock.Common.Utils.Pagination;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
/// <summary>
/// Get audit log entries across all users. Optionally filter by subject user or actor.
/// </summary>
/// <param name="pagination">Page, sort, and search parameters.</param>
/// <param name="userId">Optional: filter by the subject user (the account that was affected).</param>
/// <param name="actorId">Optional: filter by the actor (who performed the action).</param>
/// <param name="auditService"></param>
/// <param name="cancellationToken"></param>
/// <response code="200">A page of audit log entries.</response>
[HttpGet("audit-log")]
[ProducesResponseType<PagedResult<AuditLogEntryResponse>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<PagedResult<AuditLogEntryResponse>> GetAdminAuditLog(
[FromQuery] PaginationQuery pagination,
[FromQuery] Guid? userId,
[FromQuery] Guid? actorId,
[FromServices] IAuditService auditService,
CancellationToken cancellationToken)
{
var paged = await auditService.GetPagedAsync(userId, actorId, pagination, cancellationToken);
return AuthenticatedAccountController.MapPaged(paged);
}
}
2 changes: 1 addition & 1 deletion API/Controller/Admin/PatchUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public async Task<IActionResult> ModifyUser([FromRoute] Guid userId, [FromBody]
{
if (body.Name is not null)
{
await accountService.ChangeUsernameAsync(userId, body.Name, ignoreLimit: true, ct);
await accountService.ChangeUsernameAsync(userId, body.Name, actorId: CurrentUser.Id, ignoreLimit: true, cancellationToken: ct);
}

return Ok();
Expand Down
6 changes: 4 additions & 2 deletions API/Controller/Admin/ReactivateUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ public sealed partial class AdminController
/// <response code="401">Unauthorized</response>
[HttpPut("users/{userId}/reactivate")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> ReactivateUser([FromRoute] Guid userId, [FromServices] IAccountService accountService)
public async Task<IActionResult> ReactivateUser(
[FromRoute] Guid userId,
[FromServices] IAccountService accountService)
{
var reactivationResult = await accountService.ReactivateAccountAsync(CurrentUser.Id, userId);
return reactivationResult.Match(
return reactivationResult.Match<IActionResult>(
success => Ok("Account reactivated"),
unauthorized => Problem(AccountActivationError.Unauthorized),
notFound => NotFound("User not found")
Expand Down
3 changes: 2 additions & 1 deletion API/Controller/OAuth/HandOff.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.API.Services.OAuthConnection;
using OpenShock.Common.Options;
using OpenShock.Common.OpenShockDb;
using OpenShock.API.OAuth;
using OpenShock.Common.Extensions;

Expand Down Expand Up @@ -108,7 +109,7 @@ await _accountService.IsEmailRegisteredAsync(externalEmail, cancellationToken))
return RedirectFrontendConnections(connection.UserId == userId ? "alreadyLinked" : "linkedToAnotherAccount");
}

var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, cancellationToken);
var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, actorId: userId, cancellationToken);
if (!ok)
{
await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
Expand Down
22 changes: 11 additions & 11 deletions API/Controller/Tokens/DeleteToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,37 +37,37 @@ public TokenDeleteController(IApiTokenService tokenService, ILogger<TokenDeleteC
[HttpDelete("{tokenId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound
public async Task<IActionResult> DeleteToken([FromRoute] Guid tokenId, CancellationToken cancellationToken)
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound
public async Task<IActionResult> DeleteToken(
[FromRoute] Guid tokenId,
CancellationToken cancellationToken)
{
// If a token tries to delete itself, let it
if (User.TryGetClaimValueAsGuid(OpenShockAuthClaims.ApiTokenId, out var currentApiTokenId) && currentApiTokenId == tokenId)
{
if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) return Ok();
if (await _tokenService.DeleteToken(tokenId, actorId: CurrentUser.Id, cancellationToken: cancellationToken))
return Ok();

// If we get here, it's a race-condition or something weird!
_logger.LogWarning("Token {TokenId} attempted self-deletion but no record was found (possible race-condition).", tokenId);

return Problem(ApiTokenError.ApiTokenNotFound);
}

var userIdentity = User.TryGetOpenShockUserIdentity();
if (userIdentity is null) return Problem(ApiTokenError.ApiTokenCanOnlyDeleteSelf); // If user is null then ApiToken must have been here, and it cant delete others
if (userIdentity is null) return Problem(ApiTokenError.ApiTokenCanOnlyDeleteSelf);

// If a privileged user is trying to delete the token, let them
// If a privileged user is trying to delete the token, let them (any owner)
if (userIdentity.IsAdminOrSystem())
{
if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) return Ok();
if (await _tokenService.DeleteToken(tokenId, actorId: CurrentUser.Id, cancellationToken: cancellationToken))
return Ok();

return Problem(ApiTokenError.ApiTokenNotFound);
}

// A normal user is trying to delete the token, delete it if they own it
var userId = userIdentity.GetClaimValueAsGuid(ClaimTypes.NameIdentifier);
if (await _tokenService.DeleteToken(tokenId, userId, cancellationToken))
{
if (await _tokenService.DeleteToken(tokenId, actorId: userId, ownerId: userId, cancellationToken))
return Ok();
}

return Problem(ApiTokenError.ApiTokenNotFound);
}
Expand Down
9 changes: 7 additions & 2 deletions API/Controller/Tokens/Tokens.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using OpenShock.API.Models.Response;
using OpenShock.API.Services.Token;
using OpenShock.Common.Errors;
using OpenShock.Common.Extensions;
using OpenShock.Common.Problems;
using OpenShock.Common.Utils;

Expand Down Expand Up @@ -82,7 +83,9 @@ public async Task<IActionResult> GetTokenByIdV2([FromRoute] Guid tokenId, [FromS
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
[MapToApiVersion("1")]
public Task<TokenCreatedResponse> CreateToken([FromBody] CreateTokenRequest body, [FromServices] IApiTokenService tokenService)
public Task<TokenCreatedResponse> CreateToken(
[FromBody] CreateTokenRequest body,
[FromServices] IApiTokenService tokenService)
{
return tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body);
}
Expand Down Expand Up @@ -117,7 +120,9 @@ public async Task<IActionResult> EditToken([FromRoute] Guid tokenId, [FromBody]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
[MapToApiVersion("2")]
public Task<TokenCreatedResponseV2> CreateTokenV2([FromBody] CreateTokenRequestV2 body, [FromServices] IApiTokenService tokenService)
public Task<TokenCreatedResponseV2> CreateTokenV2(
[FromBody] CreateTokenRequestV2 body,
[FromServices] IApiTokenService tokenService)
{
return tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body);
}
Expand Down
16 changes: 16 additions & 0 deletions API/Models/Response/AuditLogEntryResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;

namespace OpenShock.API.Models.Response;

public sealed class AuditLogEntryResponse
{
public required Guid Id { get; init; }
public required Guid UserId { get; init; }
public required Guid ActorId { get; init; }
public required AuditAction Action { get; init; }
public required string? IpAddress { get; init; }
public required string? UserAgent { get; init; }
public required AuditMetadata? Metadata { get; init; }
public required DateTime CreatedAt { get; init; }
}
Loading
Loading