From 6260dc793349b887767ae74f206a14bf4dc2062b Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 12 Jun 2026 16:07:37 +0200 Subject: [PATCH 01/11] Start working on auditlog service --- .../Account/Authenticated/ChangeEmail.cs | 26 +- .../Account/Authenticated/ChangePassword.cs | 19 +- .../Account/Authenticated/ChangeUsername.cs | 29 +- .../Account/Authenticated/Deactivate.cs | 25 +- .../Account/Authenticated/GetAuditLog.cs | 49 + .../Authenticated/OAuthConnectionRemove.cs | 18 +- API/Controller/Account/Logout.cs | 19 +- API/Controller/Account/VerifyEmail.cs | 31 +- API/Controller/Admin/DeactivateUser.cs | 32 +- API/Controller/Admin/DeleteUser.cs | 28 +- API/Controller/Admin/GetAuditLog.cs | 33 + API/Controller/Admin/ReactivateUser.cs | 26 +- API/Controller/OAuth/HandOff.cs | 11 + API/Controller/Tokens/DeleteToken.cs | 40 +- API/Controller/Tokens/Tokens.cs | 31 +- API/Models/Response/AuditLogEntryResponse.cs | 15 + API/Services/Account/AccountService.cs | 5 +- API/Services/Account/IAccountService.cs | 2 +- API/Services/Token/ApiTokenService.cs | 11 + API/Services/Token/IApiTokenService.cs | 5 + ...20260612135418_AddUserAuditLog.Designer.cs | 1594 +++++++++++++++++ .../20260612135418_AddUserAuditLog.cs | 113 ++ .../OpenShockContextModelSnapshot.cs | 76 + Common/Models/AuditAction.cs | 20 + Common/Models/AuditMetadata.cs | 33 + Common/OpenShockControllerBase.cs | 18 +- Common/OpenShockDb/OpenShockContext.cs | 56 +- Common/OpenShockDb/User.cs | 2 + Common/OpenShockDb/UserAuditLog.cs | 28 + Common/OpenShockServiceHelper.cs | 2 + Common/Services/Audit/AuditService.cs | 78 + Common/Services/Audit/IAuditService.cs | 32 + Cron/Jobs/ClearOldAuditLogsJob.cs | 30 + 33 files changed, 2466 insertions(+), 71 deletions(-) create mode 100644 API/Controller/Account/Authenticated/GetAuditLog.cs create mode 100644 API/Controller/Admin/GetAuditLog.cs create mode 100644 API/Models/Response/AuditLogEntryResponse.cs create mode 100644 Common/Migrations/20260612135418_AddUserAuditLog.Designer.cs create mode 100644 Common/Migrations/20260612135418_AddUserAuditLog.cs create mode 100644 Common/Models/AuditAction.cs create mode 100644 Common/Models/AuditMetadata.cs create mode 100644 Common/OpenShockDb/UserAuditLog.cs create mode 100644 Common/Services/Audit/AuditService.cs create mode 100644 Common/Services/Audit/IAuditService.cs create mode 100644 Cron/Jobs/ClearOldAuditLogsJob.cs diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index b2e480f6..3f07ab81 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -3,7 +3,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account.Authenticated; @@ -24,7 +27,9 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. - public async Task ChangeEmail([FromBody] ChangeEmailRequest body) + public async Task ChangeEmail( + [FromBody] ChangeEmailRequest body, + [FromServices] IAuditService auditService) { if (string.IsNullOrEmpty(CurrentUser.PasswordHash)) { @@ -38,11 +43,20 @@ public async Task ChangeEmail([FromBody] ChangeEmailRequest body) var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email); - return result.Match( - success => Ok(), - alreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse), - unchanged => Problem(AccountError.EmailChangeUnchanged), - tooMany => Problem(AccountError.EmailChangeTooMany), + return await result.Match>( + async success => + { + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.EmailChangeRequested, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new EmailChangeRequestedMetadata(body.Email)); + return Ok(); + }, + alreadyInUse => Task.FromResult(Problem(AccountError.EmailChangeAlreadyInUse)), + unchanged => Task.FromResult(Problem(AccountError.EmailChangeUnchanged)), + tooMany => Task.FromResult(Problem(AccountError.EmailChangeTooMany)), 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")); diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index a42e772f..0d5652d1 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -3,7 +3,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account.Authenticated; @@ -22,7 +25,9 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // PasswordNotSet // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. - public async Task ChangePassword([FromBody] ChangePasswordRequest body) + public async Task ChangePassword( + [FromBody] ChangePasswordRequest body, + [FromServices] IAuditService auditService) { // OAuth-only accounts that have never set a password must go through the email-confirmed // /password/set flow rather than silently setting one through this endpoint. @@ -38,8 +43,16 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword); - return result.Match( - success => Ok(), + return await result.Match>( + async success => + { + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.PasswordChanged, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent()); + return 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")); diff --git a/API/Controller/Account/Authenticated/ChangeUsername.cs b/API/Controller/Account/Authenticated/ChangeUsername.cs index 759fa11c..1f8405fb 100644 --- a/API/Controller/Account/Authenticated/ChangeUsername.cs +++ b/API/Controller/Account/Authenticated/ChangeUsername.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using System.Net.Mime; namespace OpenShock.API.Controller.Account.Authenticated; @@ -21,17 +24,29 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // UsernameTaken [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // UsernameInvalid [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged - public async Task ChangeUsername([FromBody] ChangeUsernameRequest body) + public async Task ChangeUsername( + [FromBody] ChangeUsernameRequest body, + [FromServices] IAuditService auditService) { + var oldUsername = CurrentUser.Name; var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username, CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System)); - return result.Match( - success => Ok(), - usernametaken => Problem(AccountError.UsernameTaken), - usernameerror => Problem(AccountError.UsernameInvalid(usernameerror)), - recentlychanged => Problem(AccountError.UsernameRecentlyChanged), - accountdeactivated => Problem(AccountError.AccountDeactivated), + return await result.Match>( + async success => + { + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.UsernameChanged, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new UsernameChangedMetadata(oldUsername, body.Username)); + return Ok(); + }, + usernametaken => Task.FromResult(Problem(AccountError.UsernameTaken)), + usernameerror => Task.FromResult(Problem(AccountError.UsernameInvalid(usernameerror))), + recentlychanged => Task.FromResult(Problem(AccountError.UsernameRecentlyChanged)), + accountdeactivated => Task.FromResult(Problem(AccountError.AccountDeactivated)), notfound => throw new Exception("Unexpected result, apparently our current user does not exist...")); } } \ No newline at end of file diff --git a/API/Controller/Account/Authenticated/Deactivate.cs b/API/Controller/Account/Authenticated/Deactivate.cs index 6e7e8efc..105bbd90 100644 --- a/API/Controller/Account/Authenticated/Deactivate.cs +++ b/API/Controller/Account/Authenticated/Deactivate.cs @@ -1,7 +1,11 @@ using Microsoft.AspNetCore.Mvc; using System.Net.Mime; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account.Authenticated; @@ -14,14 +18,23 @@ public sealed partial class AuthenticatedAccountController [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // CannotDeactivatePrivledgedAccount - public async Task Deactivate() + public async Task Deactivate([FromServices] IAuditService auditService) { var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true); - return deactivationResult.Match( - success => NoContent(), - cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated), - unauthorized => Problem(AccountActivationError.Unauthorized), + return await deactivationResult.Match>( + async success => + { + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.AccountDeactivated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new AccountDeactivatedMetadata(DeleteLater: true)); + return NoContent(); + }, + cannotDeactivatePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), + alreadyDeactivated => Task.FromResult(Problem(AccountActivationError.AlreadyDeactivated)), + unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), notFound => throw new Exception("This is not supposed to happen, wtf?") ); } diff --git a/API/Controller/Account/Authenticated/GetAuditLog.cs b/API/Controller/Account/Authenticated/GetAuditLog.cs new file mode 100644 index 00000000..beaee914 --- /dev/null +++ b/API/Controller/Account/Authenticated/GetAuditLog.cs @@ -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 +{ + /// + /// Get the audit log for the current user's account. + /// + /// Page, sort, and search parameters. + /// + /// + /// A page of audit log entries. + [HttpGet("audit-log")] + [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task> GetAuditLog( + [FromQuery] PaginationQuery pagination, + [FromServices] IAuditService auditService, + CancellationToken cancellationToken) + { + var paged = await auditService.GetPagedForUserAsync(CurrentUser.Id, pagination, cancellationToken); + return MapPaged(paged); + } + + internal static PagedResult MapPaged(PagedResult 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, + }; +} diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index 8e4be621..c29477b2 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -1,5 +1,9 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account.Authenticated; @@ -10,19 +14,31 @@ public sealed partial class AuthenticatedAccountController /// /// Provider key (e.g. discord). /// + /// /// /// Connection removed. /// No connection found for this provider. [HttpDelete("connections/{provider}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken) + public async Task RemoveOAuthConnection( + [FromRoute] string provider, + [FromServices] IOAuthConnectionService connectionService, + [FromServices] IAuditService auditService, + CancellationToken cancellationToken) { var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken); if (!deleted) return NotFound(); + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.OAuthDisconnected, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new OAuthDisconnectedMetadata(provider)); + return NoContent(); } } \ No newline at end of file diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs index 44a565d6..c559351b 100644 --- a/API/Controller/Account/Logout.cs +++ b/API/Controller/Account/Logout.cs @@ -1,6 +1,9 @@ using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.Session; namespace OpenShock.API.Controller.Account; @@ -10,12 +13,22 @@ public sealed partial class AccountController [HttpPost("logout")] [ProducesResponseType(StatusCodes.Status200OK)] [MapToApiVersion("1")] - public async Task Logout([FromServices] ISessionService sessionService) + public async Task Logout( + [FromServices] ISessionService sessionService, + [FromServices] IAuditService auditService) { - // 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.DeleteSessionAsync(session); + await auditService.LogAsync( + session.UserId, + AuditAction.Logout, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent()); + } } // Make sure cookie is removed, no matter if authenticated or not diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index 93b840d0..c378c6af 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -3,7 +3,11 @@ using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account; @@ -20,8 +24,8 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] - public Task EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) - => VerifyPendingEmailChange(token, cancellationToken); + public Task EmailVerify([FromQuery(Name = "token")] string token, [FromServices] IAuditService auditService, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, auditService, cancellationToken); /// /// Verify a pending email change. Deprecated: use POST /email-change/verify instead. @@ -35,16 +39,25 @@ public Task EmailVerify([FromQuery(Name = "token")] string token, [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] - public Task EmailVerifyLegacy([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) - => VerifyPendingEmailChange(token, cancellationToken); + public Task EmailVerifyLegacy([FromQuery(Name = "token")] string token, [FromServices] IAuditService auditService, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, auditService, cancellationToken); - private async Task VerifyPendingEmailChange(string token, CancellationToken cancellationToken) + private async Task VerifyPendingEmailChange(string token, IAuditService auditService, CancellationToken cancellationToken) { var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken); - return result.Match( - success => Ok(), - notFound => Problem(AccountError.EmailChangeNotFound), - emailTaken => Problem(AccountError.EmailChangeAlreadyInUse)); + return await result.Match>( + async success => + { + await auditService.LogAsync( + success.Value.UserId, + AuditAction.EmailChanged, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new EmailChangedMetadata(success.Value.OldEmail, success.Value.NewEmail)); + return Ok(); + }, + notFound => Task.FromResult(Problem(AccountError.EmailChangeNotFound)), + emailTaken => Task.FromResult(Problem(AccountError.EmailChangeAlreadyInUse))); } } \ No newline at end of file diff --git a/API/Controller/Admin/DeactivateUser.cs b/API/Controller/Admin/DeactivateUser.cs index 210c14dc..9c1d6cfb 100644 --- a/API/Controller/Admin/DeactivateUser.cs +++ b/API/Controller/Admin/DeactivateUser.cs @@ -1,6 +1,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -13,15 +17,29 @@ public sealed partial class AdminController /// Unauthorized [HttpPut("users/{userId}/deactivate")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task DeactivateUser([FromRoute] Guid userId, [FromQuery(Name="deleteLater")] bool deleteLater, [FromServices] IAccountService accountService) + public async Task DeactivateUser( + [FromRoute] Guid userId, + [FromQuery(Name = "deleteLater")] bool deleteLater, + [FromServices] IAccountService accountService, + [FromServices] IAuditService auditService) { var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater); - return deactivationResult.Match( - success => Ok("Account deactivated"), - cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") + return await deactivationResult.Match>( + async success => + { + await auditService.LogAsync( + userId, + AuditAction.AccountDeactivated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new AccountDeactivatedMetadata(deleteLater), + actorId: CurrentUser.Id); + return Ok("Account deactivated"); + }, + cannotDeactivatePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), + alreadyDeactivated => Task.FromResult(Problem(AccountActivationError.AlreadyDeactivated)), + unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), + notFound => Task.FromResult(NotFound("User not found")) ); } } \ No newline at end of file diff --git a/API/Controller/Admin/DeleteUser.cs b/API/Controller/Admin/DeleteUser.cs index 85a22f6a..20e422dd 100644 --- a/API/Controller/Admin/DeleteUser.cs +++ b/API/Controller/Admin/DeleteUser.cs @@ -1,6 +1,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -13,14 +17,26 @@ public sealed partial class AdminController /// Unauthorized [HttpDelete("users/{userId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task DeleteUser([FromRoute] Guid userId, [FromServices] IAccountService accountService) + public async Task DeleteUser( + [FromRoute] Guid userId, + [FromServices] IAccountService accountService, + [FromServices] IAuditService auditService) { var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId); - return result.Match( - success => Ok("Account deleted"), - cannotDeletePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") + return await result.Match>( + async success => + { + await auditService.LogAsync( + userId, + AuditAction.AccountDeleted, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + actorId: CurrentUser.Id); + return Ok("Account deleted"); + }, + cannotDeletePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), + unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), + notFound => Task.FromResult(NotFound("User not found")) ); } } \ No newline at end of file diff --git a/API/Controller/Admin/GetAuditLog.cs b/API/Controller/Admin/GetAuditLog.cs new file mode 100644 index 00000000..b3facc99 --- /dev/null +++ b/API/Controller/Admin/GetAuditLog.cs @@ -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 +{ + /// + /// Get audit log entries across all users. Optionally filter by subject user or actor. + /// + /// Page, sort, and search parameters. + /// Optional: filter by the subject user (the account that was affected). + /// Optional: filter by the actor (who performed the action). + /// + /// + /// A page of audit log entries. + [HttpGet("audit-log")] + [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task> 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); + } +} diff --git a/API/Controller/Admin/ReactivateUser.cs b/API/Controller/Admin/ReactivateUser.cs index 2a0596ca..c6674a0a 100644 --- a/API/Controller/Admin/ReactivateUser.cs +++ b/API/Controller/Admin/ReactivateUser.cs @@ -1,6 +1,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -13,13 +17,25 @@ public sealed partial class AdminController /// Unauthorized [HttpPut("users/{userId}/reactivate")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task ReactivateUser([FromRoute] Guid userId, [FromServices] IAccountService accountService) + public async Task ReactivateUser( + [FromRoute] Guid userId, + [FromServices] IAccountService accountService, + [FromServices] IAuditService auditService) { var reactivationResult = await accountService.ReactivateAccountAsync(CurrentUser.Id, userId); - return reactivationResult.Match( - success => Ok("Account reactivated"), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") + return await reactivationResult.Match>( + async success => + { + await auditService.LogAsync( + userId, + AuditAction.AccountReactivated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + actorId: CurrentUser.Id); + return Ok("Account reactivated"); + }, + unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), + notFound => Task.FromResult(NotFound("User not found")) ); } } \ No newline at end of file diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 52f38bf6..81140734 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -6,6 +6,9 @@ using OpenShock.Common.Options; using OpenShock.API.OAuth; using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.OAuth; @@ -115,6 +118,14 @@ await _accountService.IsEmailRegisteredAsync(externalEmail, cancellationToken)) return RedirectFrontendConnections("linkFailed"); } + var auditService = HttpContext.RequestServices.GetRequiredService(); + await auditService.LogAsync( + userId, + AuditAction.OAuthConnected, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new OAuthConnectedMetadata(provider)); + // Direct sign-in var domain = GetCurrentCookieDomain(); if (string.IsNullOrEmpty(domain)) diff --git a/API/Controller/Tokens/DeleteToken.cs b/API/Controller/Tokens/DeleteToken.cs index a109e80b..ae652978 100644 --- a/API/Controller/Tokens/DeleteToken.cs +++ b/API/Controller/Tokens/DeleteToken.cs @@ -7,7 +7,10 @@ using OpenShock.Common.Authentication.ControllerBase; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Tokens; @@ -38,34 +41,57 @@ public TokenDeleteController(IApiTokenService tokenService, ILogger(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound - public async Task DeleteToken([FromRoute] Guid tokenId, CancellationToken cancellationToken) + public async Task DeleteToken( + [FromRoute] Guid tokenId, + [FromServices] IAuditService auditService, + 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(); + var info = await _tokenService.GetTokenAuditInfoAsync(tokenId, CurrentUser.Id, cancellationToken); + if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) + { + if (info is not null) + await auditService.LogAsync(CurrentUser.Id, AuditAction.ApiTokenDeleted, + HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), + new ApiTokenDeletedMetadata(tokenId, info.Value.Name)); + 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(); + var info = await _tokenService.GetTokenAuditInfoAsync(tokenId, cancellationToken: cancellationToken); + if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) + { + if (info is not null) + await auditService.LogAsync(info.Value.OwnerId, AuditAction.ApiTokenDeleted, + HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), + new ApiTokenDeletedMetadata(tokenId, info.Value.Name), + actorId: CurrentUser.Id); + 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); + var ownedInfo = await _tokenService.GetTokenAuditInfoAsync(tokenId, userId, cancellationToken); if (await _tokenService.DeleteToken(tokenId, userId, cancellationToken)) { + if (ownedInfo is not null) + await auditService.LogAsync(userId, AuditAction.ApiTokenDeleted, + HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), + new ApiTokenDeletedMetadata(tokenId, ownedInfo.Value.Name)); return Ok(); } diff --git a/API/Controller/Tokens/Tokens.cs b/API/Controller/Tokens/Tokens.cs index 5f3786c7..67fae8bb 100644 --- a/API/Controller/Tokens/Tokens.cs +++ b/API/Controller/Tokens/Tokens.cs @@ -5,7 +5,10 @@ using OpenShock.API.Models.Response; using OpenShock.API.Services.Token; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Tokens; @@ -82,9 +85,19 @@ public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromS [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [MapToApiVersion("1")] - public Task CreateToken([FromBody] CreateTokenRequest body, [FromServices] IApiTokenService tokenService) + public async Task CreateToken( + [FromBody] CreateTokenRequest body, + [FromServices] IApiTokenService tokenService, + [FromServices] IAuditService auditService) { - return tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + var result = await tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.ApiTokenCreated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new ApiTokenCreatedMetadata(result.Id, result.Name, result.Permissions.Select(p => p.ToString()).ToList())); + return result; } /// @@ -117,9 +130,19 @@ public async Task EditToken([FromRoute] Guid tokenId, [FromBody] [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [MapToApiVersion("2")] - public Task CreateTokenV2([FromBody] CreateTokenRequestV2 body, [FromServices] IApiTokenService tokenService) + public async Task CreateTokenV2( + [FromBody] CreateTokenRequestV2 body, + [FromServices] IApiTokenService tokenService, + [FromServices] IAuditService auditService) { - return tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + var result = await tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.ApiTokenCreated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new ApiTokenCreatedMetadata(result.Id, result.Name, result.Permissions.Select(p => p.ToString()).ToList())); + return result; } /// diff --git a/API/Models/Response/AuditLogEntryResponse.cs b/API/Models/Response/AuditLogEntryResponse.cs new file mode 100644 index 00000000..78b60eb8 --- /dev/null +++ b/API/Models/Response/AuditLogEntryResponse.cs @@ -0,0 +1,15 @@ +using OpenShock.Common.Models; + +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; } +} diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 398df273..14e93301 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -616,7 +616,7 @@ await _emailService.VerifyEmail(new Contact(lowerCaseEmail, data.User.Name), return new Success(); } - public async Task> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) + public async Task, NotFound, EmailAlreadyInUse>> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(token); var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; @@ -629,6 +629,7 @@ public async Task> TryVerifyEmailAsy { ChangeId = x.Id, UserId = x.UserId, + OldEmail = x.User.Email, x.NewEmail, x.SecurityStampAtCreate }) @@ -660,7 +661,7 @@ await _db.UserEmailChanges .Where(c => c.Id == change.ChangeId && c.UsedAt == null) .ExecuteUpdateAsync(s => s.SetProperty(c => c.UsedAt, now), cancellationToken); - return new Success(); + return new Success<(Guid, string, string)>((change.UserId, change.OldEmail, change.NewEmail)); } private async Task CheckPassword(string password, User user) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 64c4a1ae..077f146c 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -139,7 +139,7 @@ public interface IAccountService /// /// /// - Task> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); + Task, NotFound, EmailAlreadyInUse>> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); } public readonly struct AccountIsOAuthOnly; diff --git a/API/Services/Token/ApiTokenService.cs b/API/Services/Token/ApiTokenService.cs index 5fc944ad..61527315 100644 --- a/API/Services/Token/ApiTokenService.cs +++ b/API/Services/Token/ApiTokenService.cs @@ -205,4 +205,15 @@ public async Task DeleteToken(Guid tokenId, Guid? ownerId = null, Cancella var nDeleted = await Tokens(ownerId).Where(x => x.Id == tokenId).ExecuteDeleteAsync(cancellationToken); return nDeleted > 0; } + + /// + public async Task<(Guid OwnerId, string Name)?> GetTokenAuditInfoAsync(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default) + { + var result = await Tokens(ownerId) + .Where(x => x.Id == tokenId) + .Select(x => new { x.UserId, x.Name }) + .FirstOrDefaultAsync(cancellationToken); + + return result is null ? null : (result.UserId, result.Name); + } } diff --git a/API/Services/Token/IApiTokenService.cs b/API/Services/Token/IApiTokenService.cs index 032dcdf7..04a15020 100644 --- a/API/Services/Token/IApiTokenService.cs +++ b/API/Services/Token/IApiTokenService.cs @@ -56,4 +56,9 @@ public interface IApiTokenService /// Delete a token by id (optionally restricted to an owner). Returns false if no token was deleted. /// Task DeleteToken(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); + + /// + /// Returns the minimum info needed to write an audit log entry for a token deletion, or null if the token does not exist. + /// + Task<(Guid OwnerId, string Name)?> GetTokenAuditInfoAsync(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Common/Migrations/20260612135418_AddUserAuditLog.Designer.cs b/Common/Migrations/20260612135418_AddUserAuditLog.Designer.cs new file mode 100644 index 00000000..6e57b0f1 --- /dev/null +++ b/Common/Migrations/20260612135418_AddUserAuditLog.Designer.cs @@ -0,0 +1,1594 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260612135418_AddUserAuditLog")] + partial class AddUserAuditLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("ShockerControlDurationMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(65535) + .HasColumnName("shocker_control_duration_max"); + + b.Property("ShockerControlDurationMin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(300) + .HasColumnName("shocker_control_duration_min"); + + b.Property("ShockerControlDurationMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + + b.Property("ShockerControlIntensityMax") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)100) + .HasColumnName("shocker_control_intensity_max"); + + b.Property("ShockerControlIntensityMin") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)0) + .HasColumnName("shocker_control_intensity_min"); + + b.Property("ShockerControlIntensityMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + + b.Property("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("audit_action") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_audit_logs_pkey"); + + b.HasIndex("ActorId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_audit_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") + .WithMany("ActorAuditLogs") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_actor_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_user_id"); + + b.Navigation("Actor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ActorAuditLogs"); + + b.Navigation("ApiTokens"); + + b.Navigation("AuditLogs"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20260612135418_AddUserAuditLog.cs b/Common/Migrations/20260612135418_AddUserAuditLog.cs new file mode 100644 index 00000000..6f14e307 --- /dev/null +++ b/Common/Migrations/20260612135418_AddUserAuditLog.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddUserAuditLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + + migrationBuilder.CreateTable( + name: "user_audit_logs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + actor_id = table.Column(type: "uuid", nullable: false), + action = table.Column(type: "audit_action", nullable: false), + ip_address = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + user_agent = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + metadata = table.Column(type: "jsonb", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("user_audit_logs_pkey", x => x.id); + table.ForeignKey( + name: "fk_user_audit_logs_actor_id", + column: x => x.actor_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_user_audit_logs_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_user_audit_logs_actor_id", + table: "user_audit_logs", + column: "actor_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_audit_logs_created_at", + table: "user_audit_logs", + column: "created_at"); + + migrationBuilder.CreateIndex( + name: "IX_user_audit_logs_user_id", + table: "user_audit_logs", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "user_audit_logs"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 81f0c618..e7a665d4 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -24,6 +24,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); @@ -801,6 +802,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_activation_requests", (string)null); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("audit_action") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_audit_logs_pkey"); + + b.HasIndex("ActorId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_audit_logs", (string)null); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => { b.Property("DeactivatedUserId") @@ -1318,6 +1369,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") + .WithMany("ActorAuditLogs") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_actor_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_user_id"); + + b.Navigation("Actor"); + + b.Navigation("User"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") @@ -1476,8 +1548,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => { + b.Navigation("ActorAuditLogs"); + b.Navigation("ApiTokens"); + b.Navigation("AuditLogs"); + b.Navigation("Devices"); b.Navigation("EmailChanges"); diff --git a/Common/Models/AuditAction.cs b/Common/Models/AuditAction.cs new file mode 100644 index 00000000..1ea86f23 --- /dev/null +++ b/Common/Models/AuditAction.cs @@ -0,0 +1,20 @@ +using NpgsqlTypes; + +namespace OpenShock.Common.Models; + +public enum AuditAction +{ + [PgName("login")] Login, + [PgName("logout")] Logout, + [PgName("password_changed")] PasswordChanged, + [PgName("email_change_requested")] EmailChangeRequested, + [PgName("email_changed")] EmailChanged, + [PgName("username_changed")] UsernameChanged, + [PgName("api_token_created")] ApiTokenCreated, + [PgName("api_token_deleted")] ApiTokenDeleted, + [PgName("oauth_connected")] OAuthConnected, + [PgName("oauth_disconnected")] OAuthDisconnected, + [PgName("account_deactivated")] AccountDeactivated, + [PgName("account_reactivated")] AccountReactivated, + [PgName("account_deleted")] AccountDeleted, +} diff --git a/Common/Models/AuditMetadata.cs b/Common/Models/AuditMetadata.cs new file mode 100644 index 00000000..1aa651f5 --- /dev/null +++ b/Common/Models/AuditMetadata.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace OpenShock.Common.Models; + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "t")] +[JsonDerivedType(typeof(LoginMetadata), "login")] +[JsonDerivedType(typeof(UsernameChangedMetadata), "usernameChanged")] +[JsonDerivedType(typeof(EmailChangeRequestedMetadata), "emailChangeRequested")] +[JsonDerivedType(typeof(EmailChangedMetadata), "emailChanged")] +[JsonDerivedType(typeof(ApiTokenCreatedMetadata), "apiTokenCreated")] +[JsonDerivedType(typeof(ApiTokenDeletedMetadata), "apiTokenDeleted")] +[JsonDerivedType(typeof(OAuthConnectedMetadata), "oauthConnected")] +[JsonDerivedType(typeof(OAuthDisconnectedMetadata), "oauthDisconnected")] +[JsonDerivedType(typeof(AccountDeactivatedMetadata), "accountDeactivated")] +public abstract record AuditMetadata; + +public sealed record LoginMetadata(Guid SessionId) : AuditMetadata; + +public sealed record UsernameChangedMetadata(string Old, string New) : AuditMetadata; + +public sealed record EmailChangeRequestedMetadata(string NewEmail) : AuditMetadata; + +public sealed record EmailChangedMetadata(string Old, string New) : AuditMetadata; + +public sealed record ApiTokenCreatedMetadata(Guid TokenId, string Name, IReadOnlyList Permissions) : AuditMetadata; + +public sealed record ApiTokenDeletedMetadata(Guid TokenId, string Name) : AuditMetadata; + +public sealed record OAuthConnectedMetadata(string Provider) : AuditMetadata; + +public sealed record OAuthDisconnectedMetadata(string Provider) : AuditMetadata; + +public sealed record AccountDeactivatedMetadata(bool DeleteLater) : AuditMetadata; diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 4e11c2cb..8438ea8a 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -3,6 +3,7 @@ using OpenShock.Common.Models; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -42,9 +43,20 @@ protected OkObjectResult LegacyEmptyOk(string message = "") protected async Task CreateSession(Guid accountId, string domain) { var sessionService = HttpContext.RequestServices.GetRequiredService(); - - var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); - + var auditService = HttpContext.RequestServices.GetRequiredService(); + + var remoteIp = HttpContext.GetRemoteIP(); + var userAgent = HttpContext.GetUserAgent(); + + var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp.ToString()); + + await auditService.LogAsync( + accountId, + AuditAction.Login, + ipAddress: remoteIp, + userAgent: userAgent, + metadata: new LoginMetadata(session.Id)); + HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, new CookieOptions { Expires = DateTimeOffset.UtcNow.Add(Duration.LoginSessionLifetime), diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 66de70f0..20f5fc5d 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; using OpenShock.Common.Extensions; @@ -130,6 +131,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet DataProtectionKeys { get; set; } + public DbSet UserAuditLogs { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) @@ -151,6 +154,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("shocker_model_type", ["caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330"]) .HasPostgresEnum("match_type_enum", ["exact", "contains"]) .HasPostgresEnum("configuration_value_type", ["string", "bool", "int", "float", "json"]) + .HasPostgresEnum("audit_action", [ + "login", "logout", "password_changed", "email_change_requested", "email_changed", + "username_changed", "api_token_created", "api_token_deleted", + "oauth_connected", "oauth_disconnected", + "account_deactivated", "account_reactivated", "account_deleted" + ]) .HasCollation("public", "ndcoll", "und-u-ks-level2", "icu", false); // Add case-insensitive, accent-sensitive comparison collation modelBuilder.Entity(entity => @@ -920,5 +929,50 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.ShockerControlLogCount) .HasColumnName("shocker_control_log_count"); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("user_audit_logs_pkey"); + + entity.ToTable("user_audit_logs"); + + entity.HasIndex(e => e.UserId); + entity.HasIndex(e => e.ActorId); + entity.HasIndex(e => e.CreatedAt); + + entity.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("id"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + entity.Property(e => e.ActorId).HasColumnName("actor_id"); + entity.Property(e => e.Action) + .HasColumnType("audit_action") + .HasColumnName("action"); + entity.Property(e => e.IpAddress) + .VarCharWithLength(HardLimits.IpAddressMaxLength) + .HasColumnName("ip_address"); + entity.Property(e => e.UserAgent) + .VarCharWithLength(HardLimits.UserAgentMaxLength) + .HasColumnName("user_agent"); + entity.Property(e => e.Metadata) + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("created_at"); + + entity.HasOne(e => e.User).WithMany(u => u.AuditLogs) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_audit_logs_user_id"); + + entity.HasOne(e => e.Actor).WithMany(u => u.ActorAuditLogs) + .HasForeignKey(e => e.ActorId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_audit_logs_actor_id"); + }); } } diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index bf5a2990..24a72892 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -41,4 +41,6 @@ public sealed class User public ICollection NameChanges { get; } = []; public ICollection EmailChanges { get; } = []; public ICollection PasswordResets { get; } = []; + public ICollection AuditLogs { get; } = []; + public ICollection ActorAuditLogs { get; } = []; } diff --git a/Common/OpenShockDb/UserAuditLog.cs b/Common/OpenShockDb/UserAuditLog.cs new file mode 100644 index 00000000..9ba3b91b --- /dev/null +++ b/Common/OpenShockDb/UserAuditLog.cs @@ -0,0 +1,28 @@ +using OpenShock.Common.Models; + +namespace OpenShock.Common.OpenShockDb; + +public sealed class UserAuditLog +{ + public required Guid Id { get; set; } + + /// The account being affected. + public required Guid UserId { get; set; } + + /// Who performed the action. Equal to UserId for self-service actions; an admin's Id for admin-initiated actions. + public required Guid ActorId { get; set; } + + public required AuditAction Action { get; set; } + + public string? IpAddress { get; set; } + + public string? UserAgent { get; set; } + + public AuditMetadata? Metadata { get; set; } + + public required DateTime CreatedAt { get; set; } + + // Navigations + public User User { get; set; } = null!; + public User Actor { get; set; } = null!; +} diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 232a2202..1e08cb08 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -16,6 +16,7 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.BatchUpdate; using OpenShock.Common.Services.Configuration; using OpenShock.Common.Services.RedisPubSub; @@ -199,6 +200,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(30); diff --git a/Common/Services/Audit/AuditService.cs b/Common/Services/Audit/AuditService.cs new file mode 100644 index 00000000..b9c661ee --- /dev/null +++ b/Common/Services/Audit/AuditService.cs @@ -0,0 +1,78 @@ +using System.Net; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils.Pagination; + +namespace OpenShock.Common.Services.Audit; + +public sealed class AuditService : IAuditService +{ + private static readonly IReadOnlyDictionary> Sorters = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["createdAt"] = (q, desc) => desc ? q.OrderByDescending(x => x.CreatedAt) : q.OrderBy(x => x.CreatedAt), + ["action"] = (q, desc) => desc ? q.OrderByDescending(x => x.Action) : q.OrderBy(x => x.Action), + }; + + private const string DefaultSort = "createdAt"; + + private readonly OpenShockContext _db; + + public AuditService(OpenShockContext db) + { + _db = db; + } + + public async Task LogAsync( + Guid userId, + AuditAction action, + IPAddress? ipAddress = null, + string? userAgent = null, + AuditMetadata? metadata = null, + Guid? actorId = null, + CancellationToken cancellationToken = default) + { + _db.UserAuditLogs.Add(new UserAuditLog + { + Id = Guid.CreateVersion7(), + UserId = userId, + ActorId = actorId ?? userId, + Action = action, + IpAddress = ipAddress?.ToString(), + UserAgent = userAgent, + Metadata = metadata, + CreatedAt = DateTime.UtcNow, + }); + + await _db.SaveChangesAsync(cancellationToken); + } + + public Task> GetPagedForUserAsync( + Guid userId, + PaginationQuery pagination, + CancellationToken cancellationToken = default) + { + return _db.UserAuditLogs + .Where(x => x.UserId == userId) + .ApplySort(pagination, Sorters, DefaultSort) + .ThenBy(x => x.Id) + .ToPagedResultAsync(pagination, cancellationToken); + } + + public Task> GetPagedAsync( + Guid? userId, + Guid? actorId, + PaginationQuery pagination, + CancellationToken cancellationToken = default) + { + var query = _db.UserAuditLogs.AsQueryable(); + + if (userId.HasValue) query = query.Where(x => x.UserId == userId.Value); + if (actorId.HasValue) query = query.Where(x => x.ActorId == actorId.Value); + + return query + .ApplySort(pagination, Sorters, DefaultSort) + .ThenBy(x => x.Id) + .ToPagedResultAsync(pagination, cancellationToken); + } +} diff --git a/Common/Services/Audit/IAuditService.cs b/Common/Services/Audit/IAuditService.cs new file mode 100644 index 00000000..e2d93369 --- /dev/null +++ b/Common/Services/Audit/IAuditService.cs @@ -0,0 +1,32 @@ +using System.Net; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils.Pagination; + +namespace OpenShock.Common.Services.Audit; + +public interface IAuditService +{ + /// + /// Appends an audit log entry. ActorId defaults to UserId (self-service action). + /// + Task LogAsync( + Guid userId, + AuditAction action, + IPAddress? ipAddress = null, + string? userAgent = null, + AuditMetadata? metadata = null, + Guid? actorId = null, + CancellationToken cancellationToken = default); + + Task> GetPagedForUserAsync( + Guid userId, + PaginationQuery pagination, + CancellationToken cancellationToken = default); + + Task> GetPagedAsync( + Guid? userId, + Guid? actorId, + PaginationQuery pagination, + CancellationToken cancellationToken = default); +} diff --git a/Cron/Jobs/ClearOldAuditLogsJob.cs b/Cron/Jobs/ClearOldAuditLogsJob.cs new file mode 100644 index 00000000..ff3a7f70 --- /dev/null +++ b/Cron/Jobs/ClearOldAuditLogsJob.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; +using OpenShock.Cron.Attributes; + +namespace OpenShock.Cron.Jobs; + +[CronJob("0 0 * * *")] +public sealed class ClearOldAuditLogsJob +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public ClearOldAuditLogsJob(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task Execute() + { + var cutoff = DateTime.UtcNow - Duration.AuditRetentionTime; + var deleted = await _db.UserAuditLogs + .Where(x => x.CreatedAt < cutoff) + .ExecuteDeleteAsync(); + + _logger.LogInformation("Deleted {Count} audit log entries older than {Cutoff}", deleted, cutoff); + return deleted; + } +} From b858692ddcb884e4c7be2d4793c83509cd7a6a91 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 3 Jul 2026 16:49:29 +0200 Subject: [PATCH 02/11] fix: add missing XML param tags for auditService parameters The audit-log controller methods gained an auditService parameter but lacked matching doc tags, failing the build under warnings-as-errors (CS1573). Also removes duplicate tokenId/cancellationToken tags in DeleteToken. --- API/Controller/Account/Authenticated/ChangeEmail.cs | 1 + API/Controller/Account/Authenticated/ChangePassword.cs | 1 + API/Controller/Account/Authenticated/ChangeUsername.cs | 1 + API/Controller/Tokens/DeleteToken.cs | 1 + API/Controller/Tokens/Tokens.cs | 2 ++ 5 files changed, 6 insertions(+) diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index 319a6681..d6c8e81e 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -19,6 +19,7 @@ public sealed partial class AuthenticatedAccountController /// address; the change is not applied until that link is opened. /// /// + /// [HttpPost("email-change")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index fff5297d..f2d45a06 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -18,6 +18,7 @@ public sealed partial class AuthenticatedAccountController /// Change the password of the current user /// /// + /// /// /// [HttpPost("password")] diff --git a/API/Controller/Account/Authenticated/ChangeUsername.cs b/API/Controller/Account/Authenticated/ChangeUsername.cs index 3d757992..8010011a 100644 --- a/API/Controller/Account/Authenticated/ChangeUsername.cs +++ b/API/Controller/Account/Authenticated/ChangeUsername.cs @@ -17,6 +17,7 @@ public sealed partial class AuthenticatedAccountController /// Change the username of the current user /// /// + /// /// /// [HttpPost("username")] diff --git a/API/Controller/Tokens/DeleteToken.cs b/API/Controller/Tokens/DeleteToken.cs index 4821730c..45fb7d0d 100644 --- a/API/Controller/Tokens/DeleteToken.cs +++ b/API/Controller/Tokens/DeleteToken.cs @@ -34,6 +34,7 @@ public TokenDeleteController(IApiTokenService tokenService, ILogger /// + /// /// /// Successfully deleted token /// Api Token is not allowed to delete other Api Tokens diff --git a/API/Controller/Tokens/Tokens.cs b/API/Controller/Tokens/Tokens.cs index cddbbcb3..3e266279 100644 --- a/API/Controller/Tokens/Tokens.cs +++ b/API/Controller/Tokens/Tokens.cs @@ -81,6 +81,7 @@ public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromS /// /// /// + /// /// The created token [HttpPost] [Consumes(MediaTypeNames.Application.Json)] @@ -126,6 +127,7 @@ public async Task EditToken([FromRoute] Guid tokenId, [FromBody] /// /// /// + /// /// The created token [HttpPost] [Consumes(MediaTypeNames.Application.Json)] From 6da4a853fe40ab41ca28e0fca89f5ff0a5b334b2 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 3 Jul 2026 23:28:53 +0200 Subject: [PATCH 03/11] Change AuditService structure --- Common/Services/Audit/AuditService.cs | 22 ++++++++++++---------- Common/Services/Audit/IAuditService.cs | 2 -- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Common/Services/Audit/AuditService.cs b/Common/Services/Audit/AuditService.cs index b9c661ee..497a1191 100644 --- a/Common/Services/Audit/AuditService.cs +++ b/Common/Services/Audit/AuditService.cs @@ -17,29 +17,31 @@ public sealed class AuditService : IAuditService private const string DefaultSort = "createdAt"; private readonly OpenShockContext _db; + private readonly IHttpContextAccessor _httpContextAccessor; - public AuditService(OpenShockContext db) + public AuditService(OpenShockContext db, IHttpContextAccessor httpContextAccessor) { _db = db; + _httpContextAccessor = httpContextAccessor; } public async Task LogAsync( Guid userId, AuditAction action, - IPAddress? ipAddress = null, - string? userAgent = null, - AuditMetadata? metadata = null, - Guid? actorId = null, - CancellationToken cancellationToken = default) + AuditMetadata? metadata, + Guid? actorId, + CancellationToken cancellationToken) { + var httpContext = _httpContextAccessor.HttpContext; + _db.UserAuditLogs.Add(new UserAuditLog { Id = Guid.CreateVersion7(), UserId = userId, ActorId = actorId ?? userId, Action = action, - IpAddress = ipAddress?.ToString(), - UserAgent = userAgent, + IpAddress = httpContext?.Connection.RemoteIpAddress?.ToString(), + UserAgent = httpContext?.Request.Headers.UserAgent, Metadata = metadata, CreatedAt = DateTime.UtcNow, }); @@ -50,7 +52,7 @@ public async Task LogAsync( public Task> GetPagedForUserAsync( Guid userId, PaginationQuery pagination, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { return _db.UserAuditLogs .Where(x => x.UserId == userId) @@ -63,7 +65,7 @@ public Task> GetPagedAsync( Guid? userId, Guid? actorId, PaginationQuery pagination, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { var query = _db.UserAuditLogs.AsQueryable(); diff --git a/Common/Services/Audit/IAuditService.cs b/Common/Services/Audit/IAuditService.cs index e2d93369..45eeaa7a 100644 --- a/Common/Services/Audit/IAuditService.cs +++ b/Common/Services/Audit/IAuditService.cs @@ -13,8 +13,6 @@ public interface IAuditService Task LogAsync( Guid userId, AuditAction action, - IPAddress? ipAddress = null, - string? userAgent = null, AuditMetadata? metadata = null, Guid? actorId = null, CancellationToken cancellationToken = default); From 67fcce003e4951b90d4430c5222c82fdeccf5074 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 3 Jul 2026 23:29:11 +0200 Subject: [PATCH 04/11] Migrate ChangeEmail --- .../Account/Authenticated/ChangeEmail.cs | 29 +++---------- API/Services/Account/AccountService.cs | 43 +++++++++++++------ API/Services/Account/IAccountService.cs | 3 +- 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index d6c8e81e..f8447b58 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -3,11 +3,7 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Extensions; -using OpenShock.Common.Models; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account.Authenticated; @@ -29,9 +25,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. - public async Task ChangeEmail( - [FromBody] ChangeEmailRequest body, - [FromServices] IAuditService auditService) + public async Task ChangeEmail([FromBody] ChangeEmailRequest body) { if (string.IsNullOrEmpty(CurrentUser.PasswordHash)) { @@ -43,22 +37,13 @@ public async Task ChangeEmail( 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 await result.Match>( - async success => - { - await auditService.LogAsync( - CurrentUser.Id, - AuditAction.EmailChangeRequested, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new EmailChangeRequestedMetadata(body.Email)); - return Ok(); - }, - alreadyInUse => Task.FromResult(Problem(AccountError.EmailChangeAlreadyInUse)), - unchanged => Task.FromResult(Problem(AccountError.EmailChangeUnchanged)), - tooMany => Task.FromResult(Problem(AccountError.EmailChangeTooMany)), + return result.Match( + success => Ok(), + alreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse), + unchanged => Problem(AccountError.EmailChangeUnchanged), + tooMany => Problem(AccountError.EmailChangeTooMany), 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")); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 3988b979..2e5b7158 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -6,6 +6,7 @@ using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -21,6 +22,7 @@ public sealed class AccountService : IAccountService private readonly OpenShockContext _db; private readonly IRedisPubService _redisPubService; private readonly ISessionService _sessionService; + private readonly IAuditService _auditService; private readonly ILogger _logger; /// @@ -29,14 +31,15 @@ public sealed class AccountService : IAccountService /// /// Used to notify the email outbox delivery job that mail was enqueued. /// + /// /// - public AccountService(OpenShockContext db, IRedisPubService redisPubService, - ISessionService sessionService, ILogger logger) + public AccountService(OpenShockContext db, IRedisPubService redisPubService, ISessionService sessionService, IAuditService auditService, ILogger logger) { _db = db; _redisPubService = redisPubService; - _logger = logger; _sessionService = sessionService; + _auditService = auditService; + _logger = logger; } /// @@ -567,7 +570,7 @@ public async Task - public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail) + public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail, Guid? actorId) { var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; @@ -604,17 +607,31 @@ public async Task /// Id of the user whose email is being changed. /// Requested new email address. + /// User that called this /// - public Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail); + public Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail, Guid? actorId); /// /// Verifies a pending email change using the supplied token. On success the user's email is updated. From 932553ca15336f3378d217fff232094193919adb Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 3 Jul 2026 23:32:51 +0200 Subject: [PATCH 05/11] Migrate RemoveOAuthConnection --- .../Authenticated/OAuthConnectionRemove.cs | 22 +++---------------- .../IOAuthConnectionService.cs | 2 +- .../OAuthConnection/OAuthConnectionService.cs | 21 ++++++++++++++++-- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index c71437bc..bfbce8e7 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -1,10 +1,5 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.OAuthConnection; -using OpenShock.Common.Extensions; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; -using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account.Authenticated; @@ -22,24 +17,13 @@ public sealed partial class AuthenticatedAccountController [HttpDelete("connections/{provider}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task RemoveOAuthConnection( - [FromRoute] string provider, - [FromServices] IOAuthConnectionService connectionService, - [FromServices] IAuditService auditService, - CancellationToken cancellationToken) + public async Task 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(); - await auditService.LogAsync( - CurrentUser.Id, - AuditAction.OAuthDisconnected, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new OAuthDisconnectedMetadata(provider)); - return NoContent(); } -} +} \ No newline at end of file diff --git a/API/Services/OAuthConnection/IOAuthConnectionService.cs b/API/Services/OAuthConnection/IOAuthConnectionService.cs index e2d75560..a44cd48e 100644 --- a/API/Services/OAuthConnection/IOAuthConnectionService.cs +++ b/API/Services/OAuthConnection/IOAuthConnectionService.cs @@ -12,5 +12,5 @@ public interface IOAuthConnectionService Task ConnectionExistsAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default); Task HasConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default); Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken = default); - Task TryRemoveConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default); + Task TryRemoveConnectionAsync(Guid userId, string provider, Guid? actorId, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/API/Services/OAuthConnection/OAuthConnectionService.cs b/API/Services/OAuthConnection/OAuthConnectionService.cs index 17b012e6..f20394a3 100644 --- a/API/Services/OAuthConnection/OAuthConnectionService.cs +++ b/API/Services/OAuthConnection/OAuthConnectionService.cs @@ -1,17 +1,21 @@ using Microsoft.EntityFrameworkCore; using Npgsql; +using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Services.OAuthConnection; public sealed class OAuthConnectionService : IOAuthConnectionService { private readonly OpenShockContext _db; + private readonly IAuditService _auditService; private readonly ILogger _logger; - public OAuthConnectionService(OpenShockContext db, ILogger logger) + public OAuthConnectionService(OpenShockContext db, IAuditService auditService, ILogger logger) { _db = db; + _auditService = auditService; _logger = logger; } @@ -63,12 +67,25 @@ public async Task TryAddConnectionAsync(Guid userId, string provider, stri } } - public async Task TryRemoveConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken) + public async Task TryRemoveConnectionAsync(Guid userId, string provider, Guid? actorId, CancellationToken cancellationToken) { var p = provider.ToLowerInvariant(); + + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + var nDeleted = await _db.UserOAuthConnections .Where(c => c.UserId == userId && c.ProviderKey == p) .ExecuteDeleteAsync(cancellationToken); + + await _auditService.LogAsync( + userId, + action: AuditAction.OAuthDisconnected, + metadata: new OAuthDisconnectedMetadata(provider), + actorId, + cancellationToken + ); + + await transaction.CommitAsync(cancellationToken); return nDeleted > 0; } From 7fc2cf84a51387e89abb7fdd4754903c29c9196a Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 3 Jul 2026 23:34:16 +0200 Subject: [PATCH 06/11] forgot these --- API/Controller/Account/Authenticated/ChangeEmail.cs | 1 - API/Controller/Account/Authenticated/OAuthConnectionRemove.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index f8447b58..4538b769 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -15,7 +15,6 @@ public sealed partial class AuthenticatedAccountController /// address; the change is not applied until that link is opened. /// /// - /// [HttpPost("email-change")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index bfbce8e7..eb9abcf3 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -10,7 +10,6 @@ public sealed partial class AuthenticatedAccountController /// /// Provider key (e.g. discord). /// - /// /// /// Connection removed. /// No connection found for this provider. From 844ce63655223c3101b52ddd37f23935c903499a Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 4 Jul 2026 00:43:25 +0200 Subject: [PATCH 07/11] more stuff --- API.IntegrationTests/Helpers/TestHelper.cs | 2 +- .../Account/Authenticated/ChangePassword.cs | 24 +----- .../Account/Authenticated/ChangeUsername.cs | 39 +++------ .../Account/Authenticated/Deactivate.cs | 28 ++----- API/Controller/Account/Logout.cs | 14 +--- API/Controller/Account/VerifyEmail.cs | 34 +++----- API/Controller/Admin/DeactivateUser.cs | 32 ++------ API/Controller/Admin/DeleteUser.cs | 29 ++----- API/Controller/Admin/PatchUser.cs | 2 +- API/Controller/Admin/ReactivateUser.cs | 27 ++----- API/Controller/OAuth/HandOff.cs | 13 +-- API/Controller/Tokens/DeleteToken.cs | 38 ++------- API/Controller/Tokens/Tokens.cs | 33 ++------ API/Services/Account/AccountService.cs | 80 +++++++++++++++++-- API/Services/Account/IAccountService.cs | 8 +- .../IOAuthConnectionService.cs | 2 +- .../OAuthConnection/OAuthConnectionService.cs | 17 +++- API/Services/Token/ApiTokenService.cs | 75 ++++++++++++----- API/Services/Token/IApiTokenService.cs | 10 +-- Common/OpenShockControllerBase.cs | 11 +-- Common/Services/Session/ISessionService.cs | 4 +- Common/Services/Session/SessionService.cs | 27 ++++++- 22 files changed, 250 insertions(+), 299 deletions(-) diff --git a/API.IntegrationTests/Helpers/TestHelper.cs b/API.IntegrationTests/Helpers/TestHelper.cs index 49b53e35..8017adb2 100644 --- a/API.IntegrationTests/Helpers/TestHelper.cs +++ b/API.IntegrationTests/Helpers/TestHelper.cs @@ -41,7 +41,7 @@ public static async Task CreateAndLoginUser( // 2. Create session via ISessionService (stored in Redis) await using var scope = factory.Services.CreateAsyncScope(); var sessionService = scope.ServiceProvider.GetRequiredService(); - 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); } diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index f2d45a06..bdd4ae61 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -3,11 +3,7 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Extensions; -using OpenShock.Common.Models; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account.Authenticated; @@ -18,7 +14,6 @@ public sealed partial class AuthenticatedAccountController /// Change the password of the current user /// /// - /// /// /// [HttpPost("password")] @@ -27,9 +22,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // PasswordNotSet // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. - public async Task ChangePassword( - [FromBody] ChangePasswordRequest body, - [FromServices] IAuditService auditService) + public async Task ChangePassword([FromBody] ChangePasswordRequest body) { // OAuth-only accounts that have never set a password must go through the email-confirmed // /password/set flow rather than silently setting one through this endpoint. @@ -43,21 +36,12 @@ public async Task ChangePassword( 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 await result.Match>( - async success => - { - await auditService.LogAsync( - CurrentUser.Id, - AuditAction.PasswordChanged, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent()); - return Ok(); - }, + return result.Match( + 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")); - } } diff --git a/API/Controller/Account/Authenticated/ChangeUsername.cs b/API/Controller/Account/Authenticated/ChangeUsername.cs index 8010011a..486f1d47 100644 --- a/API/Controller/Account/Authenticated/ChangeUsername.cs +++ b/API/Controller/Account/Authenticated/ChangeUsername.cs @@ -1,13 +1,9 @@ -using System.Net.Mime; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; -using OpenShock.Common.Extensions; -using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Audit; -using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account.Authenticated; @@ -17,7 +13,6 @@ public sealed partial class AuthenticatedAccountController /// Change the username of the current user /// /// - /// /// /// [HttpPost("username")] @@ -26,29 +21,17 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // UsernameTaken [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // UsernameInvalid [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged - public async Task ChangeUsername( - [FromBody] ChangeUsernameRequest body, - [FromServices] IAuditService auditService) + public async Task ChangeUsername([FromBody] ChangeUsernameRequest body) { - var oldUsername = CurrentUser.Name; - 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 await result.Match>( - async success => - { - await auditService.LogAsync( - CurrentUser.Id, - AuditAction.UsernameChanged, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new UsernameChangedMetadata(oldUsername, body.Username)); - return Ok(); - }, - usernametaken => Task.FromResult(Problem(AccountError.UsernameTaken)), - usernameerror => Task.FromResult(Problem(AccountError.UsernameInvalid(usernameerror))), - recentlychanged => Task.FromResult(Problem(AccountError.UsernameRecentlyChanged)), - accountdeactivated => Task.FromResult(Problem(AccountError.AccountDeactivated)), + return result.Match( + success => Ok(), + usernametaken => Problem(AccountError.UsernameTaken), + usernameerror => Problem(AccountError.UsernameInvalid(usernameerror)), + recentlychanged => Problem(AccountError.UsernameRecentlyChanged), + accountdeactivated => Problem(AccountError.AccountDeactivated), notfound => throw new Exception("Unexpected result, apparently our current user does not exist...")); } -} \ No newline at end of file +} diff --git a/API/Controller/Account/Authenticated/Deactivate.cs b/API/Controller/Account/Authenticated/Deactivate.cs index 7e8d4440..984897d3 100644 --- a/API/Controller/Account/Authenticated/Deactivate.cs +++ b/API/Controller/Account/Authenticated/Deactivate.cs @@ -1,12 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using System.Net.Mime; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Extensions; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account.Authenticated; @@ -19,23 +14,14 @@ public sealed partial class AuthenticatedAccountController [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // CannotDeactivatePrivledgedAccount - public async Task Deactivate([FromServices] IAuditService auditService) + public async Task Deactivate() { var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true); - return await deactivationResult.Match>( - async success => - { - await auditService.LogAsync( - CurrentUser.Id, - AuditAction.AccountDeactivated, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new AccountDeactivatedMetadata(DeleteLater: true)); - return NoContent(); - }, - cannotDeactivatePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), - alreadyDeactivated => Task.FromResult(Problem(AccountActivationError.AlreadyDeactivated)), - unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), + return deactivationResult.Match( + success => NoContent(), + cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), + alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated), + unauthorized => Problem(AccountActivationError.Unauthorized), notFound => throw new Exception("This is not supposed to happen, wtf?") ); } diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs index ba75678a..8a6af483 100644 --- a/API/Controller/Account/Logout.cs +++ b/API/Controller/Account/Logout.cs @@ -1,10 +1,6 @@ using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using OpenShock.Common.Extensions; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; -using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.Session; namespace OpenShock.API.Controller.Account; @@ -15,20 +11,14 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK)] [MapToApiVersion("1")] public async Task Logout( - [FromServices] ISessionService sessionService, - [FromServices] IAuditService auditService) + [FromServices] ISessionService sessionService) { if (HttpContext.TryGetUserSessionToken(out var sessionToken)) { var session = await sessionService.GetSessionByTokenAsync(sessionToken); if (session is not null) { - await sessionService.DeleteSessionAsync(session); - await auditService.LogAsync( - session.UserId, - AuditAction.Logout, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent()); + await sessionService.LogoutSessionAsync(session); } } diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index 07164d96..1aef17fc 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -1,14 +1,9 @@ -using System; +using System; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Extensions; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account; @@ -25,8 +20,8 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] - public Task EmailVerify([FromQuery(Name = "token")] string token, [FromServices] IAuditService auditService, CancellationToken cancellationToken) - => VerifyPendingEmailChange(token, auditService, cancellationToken); + public Task EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, cancellationToken); /// /// Verify a pending email change. Deprecated: use POST /email-change/verify instead. @@ -40,25 +35,16 @@ public Task EmailVerify([FromQuery(Name = "token")] string token, [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] - public Task EmailVerifyLegacy([FromQuery(Name = "token")] string token, [FromServices] IAuditService auditService, CancellationToken cancellationToken) - => VerifyPendingEmailChange(token, auditService, cancellationToken); + public Task EmailVerifyLegacy([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, cancellationToken); - private async Task VerifyPendingEmailChange(string token, IAuditService auditService, CancellationToken cancellationToken) + private async Task VerifyPendingEmailChange(string token, CancellationToken cancellationToken) { var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken); - return await result.Match>( - async success => - { - await auditService.LogAsync( - success.Value.UserId, - AuditAction.EmailChanged, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new EmailChangedMetadata(success.Value.OldEmail, success.Value.NewEmail)); - return Ok(); - }, - notFound => Task.FromResult(Problem(AccountError.EmailChangeNotFound)), - emailTaken => Task.FromResult(Problem(AccountError.EmailChangeAlreadyInUse))); + return result.Match( + success => Ok(), + notFound => Problem(AccountError.EmailChangeNotFound), + emailTaken => Problem(AccountError.EmailChangeAlreadyInUse)); } } diff --git a/API/Controller/Admin/DeactivateUser.cs b/API/Controller/Admin/DeactivateUser.cs index 2673e33f..d74753b5 100644 --- a/API/Controller/Admin/DeactivateUser.cs +++ b/API/Controller/Admin/DeactivateUser.cs @@ -1,11 +1,6 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Extensions; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; -using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -21,26 +16,15 @@ public sealed partial class AdminController public async Task DeactivateUser( [FromRoute] Guid userId, [FromQuery(Name = "deleteLater")] bool deleteLater, - [FromServices] IAccountService accountService, - [FromServices] IAuditService auditService) + [FromServices] IAccountService accountService) { var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater); - return await deactivationResult.Match>( - async success => - { - await auditService.LogAsync( - userId, - AuditAction.AccountDeactivated, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new AccountDeactivatedMetadata(deleteLater), - actorId: CurrentUser.Id); - return Ok("Account deactivated"); - }, - cannotDeactivatePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), - alreadyDeactivated => Task.FromResult(Problem(AccountActivationError.AlreadyDeactivated)), - unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), - notFound => Task.FromResult(NotFound("User not found")) + return deactivationResult.Match( + success => Ok("Account deactivated"), + cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), + alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated), + unauthorized => Problem(AccountActivationError.Unauthorized), + notFound => NotFound("User not found") ); } } diff --git a/API/Controller/Admin/DeleteUser.cs b/API/Controller/Admin/DeleteUser.cs index d26db52c..096f942e 100644 --- a/API/Controller/Admin/DeleteUser.cs +++ b/API/Controller/Admin/DeleteUser.cs @@ -1,11 +1,6 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Extensions; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; -using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -20,24 +15,14 @@ public sealed partial class AdminController [ProducesResponseType(StatusCodes.Status200OK)] public async Task DeleteUser( [FromRoute] Guid userId, - [FromServices] IAccountService accountService, - [FromServices] IAuditService auditService) + [FromServices] IAccountService accountService) { var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId); - return await result.Match>( - async success => - { - await auditService.LogAsync( - userId, - AuditAction.AccountDeleted, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - actorId: CurrentUser.Id); - return Ok("Account deleted"); - }, - cannotDeletePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), - unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), - notFound => Task.FromResult(NotFound("User not found")) + return result.Match( + success => Ok("Account deleted"), + cannotDeletePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), + unauthorized => Problem(AccountActivationError.Unauthorized), + notFound => NotFound("User not found") ); } } diff --git a/API/Controller/Admin/PatchUser.cs b/API/Controller/Admin/PatchUser.cs index 1678d20f..22140f39 100644 --- a/API/Controller/Admin/PatchUser.cs +++ b/API/Controller/Admin/PatchUser.cs @@ -20,7 +20,7 @@ public async Task 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(); diff --git a/API/Controller/Admin/ReactivateUser.cs b/API/Controller/Admin/ReactivateUser.cs index 502ef083..ca6cc215 100644 --- a/API/Controller/Admin/ReactivateUser.cs +++ b/API/Controller/Admin/ReactivateUser.cs @@ -1,11 +1,6 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Extensions; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; -using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -20,23 +15,13 @@ public sealed partial class AdminController [ProducesResponseType(StatusCodes.Status200OK)] public async Task ReactivateUser( [FromRoute] Guid userId, - [FromServices] IAccountService accountService, - [FromServices] IAuditService auditService) + [FromServices] IAccountService accountService) { var reactivationResult = await accountService.ReactivateAccountAsync(CurrentUser.Id, userId); - return await reactivationResult.Match>( - async success => - { - await auditService.LogAsync( - userId, - AuditAction.AccountReactivated, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - actorId: CurrentUser.Id); - return Ok("Account reactivated"); - }, - unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), - notFound => Task.FromResult(NotFound("User not found")) + return reactivationResult.Match( + success => Ok("Account reactivated"), + unauthorized => Problem(AccountActivationError.Unauthorized), + notFound => NotFound("User not found") ); } } diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index d448d990..60e197c1 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -7,9 +7,6 @@ using OpenShock.Common.OpenShockDb; using OpenShock.API.OAuth; using OpenShock.Common.Extensions; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; -using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.OAuth; @@ -112,21 +109,13 @@ 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); return RedirectFrontendConnections("linkFailed"); } - var auditService = HttpContext.RequestServices.GetRequiredService(); - await auditService.LogAsync( - userId, - AuditAction.OAuthConnected, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new OAuthConnectedMetadata(provider)); - // Direct sign-in var domain = GetCurrentCookieDomain(); if (string.IsNullOrEmpty(domain)) diff --git a/API/Controller/Tokens/DeleteToken.cs b/API/Controller/Tokens/DeleteToken.cs index 45fb7d0d..ad8fbfcf 100644 --- a/API/Controller/Tokens/DeleteToken.cs +++ b/API/Controller/Tokens/DeleteToken.cs @@ -1,17 +1,13 @@ -using System.Net.Mime; +using System.Net.Mime; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Token; using OpenShock.Common.Authentication; -using OpenShock.Common.OpenShockDb; using OpenShock.Common.Authentication.ControllerBase; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; -using OpenShock.Common.Utils; -using OpenShock.Common.Models; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Tokens; @@ -34,7 +30,6 @@ public TokenDeleteController(IApiTokenService tokenService, ILogger /// - /// /// /// Successfully deleted token /// Api Token is not allowed to delete other Api Tokens @@ -42,24 +37,16 @@ public TokenDeleteController(IApiTokenService tokenService, ILogger(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound public async Task DeleteToken( [FromRoute] Guid tokenId, - [FromServices] IAuditService auditService, CancellationToken cancellationToken) { // If a token tries to delete itself, let it if (User.TryGetClaimValueAsGuid(OpenShockAuthClaims.ApiTokenId, out var currentApiTokenId) && currentApiTokenId == tokenId) { - var info = await _tokenService.GetTokenAuditInfoAsync(tokenId, CurrentUser.Id, cancellationToken); - if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) - { - if (info is not null) - await auditService.LogAsync(CurrentUser.Id, AuditAction.ApiTokenDeleted, - HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), - new ApiTokenDeletedMetadata(tokenId, info.Value.Name)); + if (await _tokenService.DeleteToken(tokenId, actorId: CurrentUser.Id, cancellationToken: cancellationToken)) return Ok(); - } _logger.LogWarning("Token {TokenId} attempted self-deletion but no record was found (possible race-condition).", tokenId); return Problem(ApiTokenError.ApiTokenNotFound); @@ -71,31 +58,16 @@ await auditService.LogAsync(CurrentUser.Id, AuditAction.ApiTokenDeleted, // If a privileged user is trying to delete the token, let them (any owner) if (userIdentity.IsAdminOrSystem()) { - var info = await _tokenService.GetTokenAuditInfoAsync(tokenId, cancellationToken: cancellationToken); - if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) - { - if (info is not null) - await auditService.LogAsync(info.Value.OwnerId, AuditAction.ApiTokenDeleted, - HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), - new ApiTokenDeletedMetadata(tokenId, info.Value.Name), - actorId: CurrentUser.Id); + 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); - var ownedInfo = await _tokenService.GetTokenAuditInfoAsync(tokenId, userId, cancellationToken); - if (await _tokenService.DeleteToken(tokenId, userId, cancellationToken)) - { - if (ownedInfo is not null) - await auditService.LogAsync(userId, AuditAction.ApiTokenDeleted, - HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), - new ApiTokenDeletedMetadata(tokenId, ownedInfo.Value.Name)); + if (await _tokenService.DeleteToken(tokenId, actorId: userId, ownerId: userId, cancellationToken)) return Ok(); - } return Problem(ApiTokenError.ApiTokenNotFound); } diff --git a/API/Controller/Tokens/Tokens.cs b/API/Controller/Tokens/Tokens.cs index 3e266279..2f5b19aa 100644 --- a/API/Controller/Tokens/Tokens.cs +++ b/API/Controller/Tokens/Tokens.cs @@ -5,11 +5,8 @@ using OpenShock.API.Models.Response; using OpenShock.API.Services.Token; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; using OpenShock.Common.Extensions; -using OpenShock.Common.Models; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Tokens; @@ -81,25 +78,16 @@ public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromS /// /// /// - /// /// The created token [HttpPost] [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [MapToApiVersion("1")] - public async Task CreateToken( + public Task CreateToken( [FromBody] CreateTokenRequest body, - [FromServices] IApiTokenService tokenService, - [FromServices] IAuditService auditService) + [FromServices] IApiTokenService tokenService) { - var result = await tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body); - await auditService.LogAsync( - CurrentUser.Id, - AuditAction.ApiTokenCreated, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new ApiTokenCreatedMetadata(result.Id, result.Name, result.Permissions.Select(p => PermissionTypeBindings.PermissionTypeToName[p].Name).ToList())); - return result; + return tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body); } /// @@ -127,25 +115,16 @@ public async Task EditToken([FromRoute] Guid tokenId, [FromBody] /// /// /// - /// /// The created token [HttpPost] [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [MapToApiVersion("2")] - public async Task CreateTokenV2( + public Task CreateTokenV2( [FromBody] CreateTokenRequestV2 body, - [FromServices] IApiTokenService tokenService, - [FromServices] IAuditService auditService) + [FromServices] IApiTokenService tokenService) { - var result = await tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body); - await auditService.LogAsync( - CurrentUser.Id, - AuditAction.ApiTokenCreated, - ipAddress: HttpContext.GetRemoteIP(), - userAgent: HttpContext.GetUserAgent(), - metadata: new ApiTokenCreatedMetadata(result.Id, result.Name, result.Permissions.Select(p => PermissionTypeBindings.PermissionTypeToName[p].Name).ToList())); - return result; + return tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body); } /// diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 2e5b7158..f2fcbf36 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -280,14 +280,26 @@ public async Task> ReactivateAccountAsync } } + await using var transaction = await _db.Database.BeginTransactionAsync(); + _db.Remove(deactivation); + await _db.SaveChangesAsync(); + await _auditService.LogAsync( + userId, + action: AuditAction.AccountReactivated, + actorId: executingUserId + ); + + await transaction.CommitAsync(); + return new Success(); } @@ -352,9 +375,20 @@ public async Task> CheckUsernameAva } /// - public async Task> ChangeUsernameAsync(Guid userId, string username, bool ignoreLimit = false, CancellationToken cancellationToken = default) + public async Task> ChangeUsernameAsync(Guid userId, string username, Guid? actorId, bool ignoreLimit = false, CancellationToken cancellationToken = default) { if (!ignoreLimit) { @@ -547,6 +581,14 @@ public async Task - public async Task> ChangePasswordAsync(Guid userId, string newPassword) + public async Task> ChangePasswordAsync(Guid userId, string newPassword, Guid? actorId) { var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(x => x.Id == userId); if (user is null) return new NotFound(); if (user.ActivatedAt is null) return new AccountNotActivated(); if (user.UserDeactivation is not null) return new AccountDeactivated(); + await using var transaction = await _db.Database.BeginTransactionAsync(); + user.PasswordHash = HashingUtils.HashPassword(newPassword); user.SecurityStamp = Guid.CreateVersion7(); // Any outstanding reset/email-change row for this user has a stale SecurityStampAtCreate after this; predicate handles invalidation. await _db.SaveChangesAsync(); + await _auditService.LogAsync( + userId, + action: AuditAction.PasswordChanged, + actorId: actorId + ); + + await transaction.CommitAsync(); + return new Success(); } @@ -660,6 +712,8 @@ await _auditService.LogAsync( if (change is null) return new NotFound(); + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + // Race-safe consume + apply: only updates if SecurityStamp still matches the snapshot, so // sibling email changes / password resets that completed since the read above cleanly lose. var newStamp = Guid.CreateVersion7(); @@ -684,6 +738,16 @@ await _db.UserEmailChanges .Where(c => c.Id == change.ChangeId && c.UsedAt == null) .ExecuteUpdateAsync(s => s.SetProperty(c => c.UsedAt, now), cancellationToken); + await _auditService.LogAsync( + change.UserId, + action: AuditAction.EmailChanged, + metadata: new EmailChangedMetadata(change.OldEmail, change.NewEmail), + actorId: null, + cancellationToken + ); + + await transaction.CommitAsync(cancellationToken); + return new Success<(Guid, string, string)>((change.UserId, change.OldEmail, change.NewEmail)); } diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 69604488..7d0d8b0c 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -99,18 +99,20 @@ public interface IAccountService /// /// /// + /// User that performed this change /// Ignore the username change limit, set this to true when an admin is changing the username /// /// only returns when the result is != Available - public Task> ChangeUsernameAsync(Guid userId, string username, bool ignoreLimit = false, CancellationToken cancellationToken = default); - + public Task> ChangeUsernameAsync(Guid userId, string username, Guid? actorId, bool ignoreLimit = false, CancellationToken cancellationToken = default); + /// /// Change the password of a user /// /// /// + /// User that performed this change /// - public Task> ChangePasswordAsync(Guid userId, string newPassword); + public Task> ChangePasswordAsync(Guid userId, string newPassword, Guid? actorId); /// /// Creates a new email change request and sends a verification email to the new address. diff --git a/API/Services/OAuthConnection/IOAuthConnectionService.cs b/API/Services/OAuthConnection/IOAuthConnectionService.cs index a44cd48e..c80af427 100644 --- a/API/Services/OAuthConnection/IOAuthConnectionService.cs +++ b/API/Services/OAuthConnection/IOAuthConnectionService.cs @@ -11,6 +11,6 @@ public interface IOAuthConnectionService Task GetByProviderExternalIdAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default); Task ConnectionExistsAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default); Task HasConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default); - Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken = default); + Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, Guid? actorId, CancellationToken cancellationToken = default); Task TryRemoveConnectionAsync(Guid userId, string provider, Guid? actorId, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/API/Services/OAuthConnection/OAuthConnectionService.cs b/API/Services/OAuthConnection/OAuthConnectionService.cs index f20394a3..2c4748f7 100644 --- a/API/Services/OAuthConnection/OAuthConnectionService.cs +++ b/API/Services/OAuthConnection/OAuthConnectionService.cs @@ -45,8 +45,10 @@ public async Task HasConnectionAsync(Guid userId, string provider, Cancell return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == p, cancellationToken); } - public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken) + public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, Guid? actorId, CancellationToken cancellationToken) { + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + try { _db.UserOAuthConnections.Add(new UserOAuthConnection @@ -57,7 +59,6 @@ public async Task TryAddConnectionAsync(Guid userId, string provider, stri DisplayName = providerAccountName }); await _db.SaveChangesAsync(cancellationToken); - return true; } catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) { @@ -65,6 +66,18 @@ public async Task TryAddConnectionAsync(Guid userId, string provider, stri _logger.LogDebug(ex, "Duplicate OAuth link for {Provider}:{ExternalId}", provider, providerAccountId); return false; } + + await _auditService.LogAsync( + userId, + action: AuditAction.OAuthConnected, + metadata: new OAuthConnectedMetadata(provider), + actorId, + cancellationToken + ); + + await transaction.CommitAsync(cancellationToken); + + return true; } public async Task TryRemoveConnectionAsync(Guid userId, string provider, Guid? actorId, CancellationToken cancellationToken) diff --git a/API/Services/Token/ApiTokenService.cs b/API/Services/Token/ApiTokenService.cs index bf544ae1..60b868ae 100644 --- a/API/Services/Token/ApiTokenService.cs +++ b/API/Services/Token/ApiTokenService.cs @@ -5,7 +5,9 @@ using OpenShock.API.Models.Requests; using OpenShock.API.Models.Response; using OpenShock.Common.Constants; +using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; @@ -18,16 +20,19 @@ public sealed class ApiTokenService : IApiTokenService { private readonly OpenShockContext _db; private readonly IRedisPubService _redisPubService; + private readonly IAuditService _auditService; /// /// DI Constructor /// /// /// - public ApiTokenService(OpenShockContext db, IRedisPubService redisPubService) + /// + public ApiTokenService(OpenShockContext db, IRedisPubService redisPubService, IAuditService auditService) { _db = db; _redisPubService = redisPubService; + _auditService = auditService; } private static readonly Expression> ToTokenResponse = x => new TokenResponse @@ -119,8 +124,20 @@ public async Task CreateTokenV1(Guid userId, IPAddress cre Permissions = body.Permissions.Distinct().ToList(), ValidUntil = body.ValidUntil?.ToUniversalTime() }; - _db.ApiTokens.Add(tokenDto); - await _db.SaveChangesAsync(); + + await using (var transaction = await _db.Database.BeginTransactionAsync()) + { + _db.ApiTokens.Add(tokenDto); + await _db.SaveChangesAsync(); + + await _auditService.LogAsync( + userId, + action: AuditAction.ApiTokenCreated, + metadata: new ApiTokenCreatedMetadata(tokenDto.Id, tokenDto.Name, tokenDto.Permissions.Select(p => PermissionTypeBindings.PermissionTypeToName[p].Name).ToList()) + ); + + await transaction.CommitAsync(); + } return new TokenCreatedResponse { @@ -151,8 +168,19 @@ public async Task CreateTokenV2(Guid userId, IPAddress c }; body.ShockerControl.ApplyTo(tokenDto); - _db.ApiTokens.Add(tokenDto); - await _db.SaveChangesAsync(); + await using (var transaction = await _db.Database.BeginTransactionAsync()) + { + _db.ApiTokens.Add(tokenDto); + await _db.SaveChangesAsync(); + + await _auditService.LogAsync( + userId, + action: AuditAction.ApiTokenCreated, + metadata: new ApiTokenCreatedMetadata(tokenDto.Id, tokenDto.Name, tokenDto.Permissions.Select(p => PermissionTypeBindings.PermissionTypeToName[p].Name).ToList()) + ); + + await transaction.CommitAsync(); + } return new TokenCreatedResponseV2 { @@ -212,25 +240,34 @@ public async Task EditTokenV2(Guid tokenId, EditTokenRequestV2 body, Guid? } /// - public async Task DeleteToken(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default) + public async Task DeleteToken(Guid tokenId, Guid? actorId, Guid? ownerId = null, CancellationToken cancellationToken = default) { - var nDeleted = await Tokens(ownerId).Where(x => x.Id == tokenId).ExecuteDeleteAsync(cancellationToken); - if (nDeleted <= 0) return false; + // Capture the owner and name before deletion so the audit entry can record what was revoked. + var info = await Tokens(ownerId) + .Where(x => x.Id == tokenId) + .Select(x => new { x.UserId, x.Name }) + .FirstOrDefaultAsync(cancellationToken); + if (info is null) return false; + + await using (var transaction = await _db.Database.BeginTransactionAsync(cancellationToken)) + { + var nDeleted = await Tokens(ownerId).Where(x => x.Id == tokenId).ExecuteDeleteAsync(cancellationToken); + if (nDeleted <= 0) return false; + + await _auditService.LogAsync( + info.UserId, + action: AuditAction.ApiTokenDeleted, + metadata: new ApiTokenDeletedMetadata(tokenId, info.Name), + actorId, + cancellationToken + ); + + await transaction.CommitAsync(cancellationToken); + } // Revoked tokens must stop controlling any open live control connection. await _redisPubService.SendApiTokenUpdate(tokenId); return true; } - - /// - public async Task<(Guid OwnerId, string Name)?> GetTokenAuditInfoAsync(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default) - { - var result = await Tokens(ownerId) - .Where(x => x.Id == tokenId) - .Select(x => new { x.UserId, x.Name }) - .FirstOrDefaultAsync(cancellationToken); - - return result is null ? null : (result.UserId, result.Name); - } } diff --git a/API/Services/Token/IApiTokenService.cs b/API/Services/Token/IApiTokenService.cs index 04a15020..375d7322 100644 --- a/API/Services/Token/IApiTokenService.cs +++ b/API/Services/Token/IApiTokenService.cs @@ -53,12 +53,8 @@ public interface IApiTokenService Task SetTokenPaused(Guid tokenId, bool paused, Guid? ownerId = null); /// - /// Delete a token by id (optionally restricted to an owner). Returns false if no token was deleted. + /// Delete a token by id (optionally restricted to an owner) and write the deletion audit log entry. + /// Returns false if no token was deleted. /// - Task DeleteToken(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); - - /// - /// Returns the minimum info needed to write an audit log entry for a token deletion, or null if the token does not exist. - /// - Task<(Guid OwnerId, string Name)?> GetTokenAuditInfoAsync(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); + Task DeleteToken(Guid tokenId, Guid? actorId, Guid? ownerId = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 2472e4c5..36896c69 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -4,7 +4,6 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -44,19 +43,11 @@ protected OkObjectResult LegacyEmptyOk(string message = "") protected async Task CreateSession(Guid accountId, string domain) { var sessionService = HttpContext.RequestServices.GetRequiredService(); - var auditService = HttpContext.RequestServices.GetRequiredService(); var remoteIp = HttpContext.GetRemoteIP(); var userAgent = HttpContext.GetUserAgent(); - var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp.ToString()); - - await auditService.LogAsync( - accountId, - AuditAction.Login, - ipAddress: remoteIp, - userAgent: userAgent, - metadata: new LoginMetadata(session.Id)); + var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp.ToString(), actorId: accountId); HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, new CookieOptions { diff --git a/Common/Services/Session/ISessionService.cs b/Common/Services/Session/ISessionService.cs index 6538356e..474a42a4 100644 --- a/Common/Services/Session/ISessionService.cs +++ b/Common/Services/Session/ISessionService.cs @@ -4,7 +4,7 @@ namespace OpenShock.Common.Services.Session; public interface ISessionService { - public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress); + public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId); public IAsyncEnumerable ListSessionsByUserIdAsync(Guid userId); @@ -21,6 +21,8 @@ public interface ISessionService public Task DeleteSessionsByUserIdAsync(Guid userId); public Task DeleteSessionAsync(LoginSession loginSession); + + public Task LogoutSessionAsync(LoginSession loginSession); } public sealed record CreateSessionResult(Guid Id, string Token); \ No newline at end of file diff --git a/Common/Services/Session/SessionService.cs b/Common/Services/Session/SessionService.cs index f506640f..df85b185 100644 --- a/Common/Services/Session/SessionService.cs +++ b/Common/Services/Session/SessionService.cs @@ -1,6 +1,9 @@ using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; using Redis.OM; using Redis.OM.Contracts; @@ -14,17 +17,20 @@ namespace OpenShock.Common.Services.Session; public sealed class SessionService : ISessionService { private readonly IRedisCollection _loginSessions; + private readonly IAuditService _auditService; /// /// DI constructor /// /// - public SessionService(IRedisConnectionProvider redisConnectionProvider) + /// + public SessionService(IRedisConnectionProvider redisConnectionProvider, IAuditService auditService) { _loginSessions = redisConnectionProvider.RedisCollection(false); + _auditService = auditService; } - public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress) + public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId) { Guid id = Guid.CreateVersion7(); string token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength); @@ -40,6 +46,13 @@ await _loginSessions.InsertAsync(new LoginSession Expires = DateTime.UtcNow.Add(Duration.LoginSessionLifetime), }, Duration.LoginSessionLifetime); + await _auditService.LogAsync( + userId, + action: AuditAction.Login, + metadata: new LoginMetadata(id), + actorId + ); + return new CreateSessionResult(id, token); } @@ -106,4 +119,14 @@ public async Task DeleteSessionAsync(LoginSession loginSession) { await _loginSessions.DeleteAsync(loginSession); } + + public async Task LogoutSessionAsync(LoginSession loginSession) + { + await _loginSessions.DeleteAsync(loginSession); + + await _auditService.LogAsync( + loginSession.UserId, + action: AuditAction.Logout + ); + } } \ No newline at end of file From 39b02e401eff9724389d6ad60f9abac411372158 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 4 Jul 2026 00:46:31 +0200 Subject: [PATCH 08/11] Update ChangeUsername.cs --- API/Controller/Account/Authenticated/ChangeUsername.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/API/Controller/Account/Authenticated/ChangeUsername.cs b/API/Controller/Account/Authenticated/ChangeUsername.cs index 486f1d47..da212791 100644 --- a/API/Controller/Account/Authenticated/ChangeUsername.cs +++ b/API/Controller/Account/Authenticated/ChangeUsername.cs @@ -1,4 +1,4 @@ -using System.Net.Mime; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; @@ -34,4 +34,4 @@ public async Task ChangeUsername([FromBody] ChangeUsernameRequest accountdeactivated => Problem(AccountError.AccountDeactivated), notfound => throw new Exception("Unexpected result, apparently our current user does not exist...")); } -} +} \ No newline at end of file From 60f71c1bc20d57c8b58e986408ebe5c26c290001 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 4 Jul 2026 00:47:07 +0200 Subject: [PATCH 09/11] Update Deactivate.cs --- API/Controller/Account/Authenticated/Deactivate.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/API/Controller/Account/Authenticated/Deactivate.cs b/API/Controller/Account/Authenticated/Deactivate.cs index 984897d3..6e7e8efc 100644 --- a/API/Controller/Account/Authenticated/Deactivate.cs +++ b/API/Controller/Account/Authenticated/Deactivate.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using System.Net.Mime; using OpenShock.Common.Errors; using OpenShock.Common.Problems; @@ -25,4 +25,4 @@ public async Task Deactivate() notFound => throw new Exception("This is not supposed to happen, wtf?") ); } -} +} \ No newline at end of file From a0ac270cc72985bf456c7aef77d99edde1303056 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 4 Jul 2026 00:52:21 +0200 Subject: [PATCH 10/11] yeah --- API/Controller/Account/Logout.cs | 6 +++--- API/Controller/Account/VerifyEmail.cs | 5 ++--- API/Controller/Admin/DeactivateUser.cs | 4 ++-- API/Controller/Admin/DeleteUser.cs | 4 ++-- API/Controller/Admin/ReactivateUser.cs | 4 ++-- API/Controller/Tokens/DeleteToken.cs | 4 ++-- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs index 8a6af483..7a530793 100644 --- a/API/Controller/Account/Logout.cs +++ b/API/Controller/Account/Logout.cs @@ -10,9 +10,9 @@ public sealed partial class AccountController [HttpPost("logout")] [ProducesResponseType(StatusCodes.Status200OK)] [MapToApiVersion("1")] - public async Task Logout( - [FromServices] ISessionService sessionService) + public async Task Logout([FromServices] ISessionService sessionService) { + // Remove session if valid if (HttpContext.TryGetUserSessionToken(out var sessionToken)) { var session = await sessionService.GetSessionByTokenAsync(sessionToken); @@ -28,4 +28,4 @@ public async Task Logout( // its always a success, logout endpoints should be idempotent return Ok(); } -} +} \ No newline at end of file diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index 1aef17fc..d8f8a245 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -1,5 +1,4 @@ -using System; -using System.Net.Mime; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using OpenShock.Common.Errors; @@ -47,4 +46,4 @@ private async Task VerifyPendingEmailChange(string token, Cancell notFound => Problem(AccountError.EmailChangeNotFound), emailTaken => Problem(AccountError.EmailChangeAlreadyInUse)); } -} +} \ No newline at end of file diff --git a/API/Controller/Admin/DeactivateUser.cs b/API/Controller/Admin/DeactivateUser.cs index d74753b5..f62e5399 100644 --- a/API/Controller/Admin/DeactivateUser.cs +++ b/API/Controller/Admin/DeactivateUser.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; @@ -27,4 +27,4 @@ public async Task DeactivateUser( notFound => NotFound("User not found") ); } -} +} \ No newline at end of file diff --git a/API/Controller/Admin/DeleteUser.cs b/API/Controller/Admin/DeleteUser.cs index 096f942e..9506b08e 100644 --- a/API/Controller/Admin/DeleteUser.cs +++ b/API/Controller/Admin/DeleteUser.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; @@ -25,4 +25,4 @@ public async Task DeleteUser( notFound => NotFound("User not found") ); } -} +} \ No newline at end of file diff --git a/API/Controller/Admin/ReactivateUser.cs b/API/Controller/Admin/ReactivateUser.cs index ca6cc215..acb93e06 100644 --- a/API/Controller/Admin/ReactivateUser.cs +++ b/API/Controller/Admin/ReactivateUser.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; @@ -24,4 +24,4 @@ public async Task ReactivateUser( notFound => NotFound("User not found") ); } -} +} \ No newline at end of file diff --git a/API/Controller/Tokens/DeleteToken.cs b/API/Controller/Tokens/DeleteToken.cs index ad8fbfcf..0b3aa966 100644 --- a/API/Controller/Tokens/DeleteToken.cs +++ b/API/Controller/Tokens/DeleteToken.cs @@ -1,4 +1,4 @@ -using System.Net.Mime; +using System.Net.Mime; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -71,4 +71,4 @@ public async Task DeleteToken( return Problem(ApiTokenError.ApiTokenNotFound); } -} +} \ No newline at end of file From eeefd76f6d1341358887bb152e0a5d78fb36a2f7 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 4 Jul 2026 00:53:46 +0200 Subject: [PATCH 11/11] Update VerifyEmail.cs --- API/Controller/Account/VerifyEmail.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index d8f8a245..93b840d0 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -1,4 +1,5 @@ -using System.Net.Mime; +using System; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using OpenShock.Common.Errors;