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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions API/Controller/Account/Authenticated/ChangeEmail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,7 +27,9 @@
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany
// notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller.
public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body)
public async Task<IActionResult> ChangeEmail(
[FromBody] ChangeEmailRequest body,
[FromServices] IAuditService auditService)

Check warning on line 32 in API/Controller/Account/Authenticated/ChangeEmail.cs

View workflow job for this annotation

GitHub Actions / Tests

Parameter 'auditService' has no matching param tag in the XML comment for 'AuthenticatedAccountController.ChangeEmail(ChangeEmailRequest, IAuditService)' (but other parameters do)

Check warning on line 32 in API/Controller/Account/Authenticated/ChangeEmail.cs

View workflow job for this annotation

GitHub Actions / Tests

Parameter 'auditService' has no matching param tag in the XML comment for 'AuthenticatedAccountController.ChangeEmail(ChangeEmailRequest, IAuditService)' (but other parameters do)
{
if (string.IsNullOrEmpty(CurrentUser.PasswordHash))
{
Expand All @@ -38,11 +43,20 @@

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

return result.Match<IActionResult>(
success => Ok(),
alreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse),
unchanged => Problem(AccountError.EmailChangeUnchanged),
tooMany => Problem(AccountError.EmailChangeTooMany),
return await result.Match<Task<IActionResult>>(
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<IActionResult>(Problem(AccountError.EmailChangeAlreadyInUse)),
unchanged => Task.FromResult<IActionResult>(Problem(AccountError.EmailChangeUnchanged)),
tooMany => Task.FromResult<IActionResult>(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"));
Expand Down
19 changes: 16 additions & 3 deletions API/Controller/Account/Authenticated/ChangePassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,7 +25,9 @@
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // PasswordNotSet
// notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller.
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest body)
public async Task<IActionResult> ChangePassword(
[FromBody] ChangePasswordRequest body,
[FromServices] IAuditService auditService)

Check warning on line 30 in API/Controller/Account/Authenticated/ChangePassword.cs

View workflow job for this annotation

GitHub Actions / Tests

Parameter 'auditService' has no matching param tag in the XML comment for 'AuthenticatedAccountController.ChangePassword(ChangePasswordRequest, IAuditService)' (but other parameters do)

Check warning on line 30 in API/Controller/Account/Authenticated/ChangePassword.cs

View workflow job for this annotation

GitHub Actions / Tests

Parameter 'auditService' has no matching param tag in the XML comment for 'AuthenticatedAccountController.ChangePassword(ChangePasswordRequest, IAuditService)' (but other parameters do)
{
// 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.
Expand All @@ -38,8 +43,16 @@

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

return result.Match<IActionResult>(
success => Ok(),
return await result.Match<Task<IActionResult>>(
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"));
Expand Down
29 changes: 22 additions & 7 deletions API/Controller/Account/Authenticated/ChangeUsername.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,17 +24,29 @@
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // UsernameTaken
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // UsernameInvalid
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest body)
public async Task<IActionResult> ChangeUsername(
[FromBody] ChangeUsernameRequest body,
[FromServices] IAuditService auditService)

Check warning on line 29 in API/Controller/Account/Authenticated/ChangeUsername.cs

View workflow job for this annotation

GitHub Actions / Tests

Parameter 'auditService' has no matching param tag in the XML comment for 'AuthenticatedAccountController.ChangeUsername(ChangeUsernameRequest, IAuditService)' (but other parameters do)

Check warning on line 29 in API/Controller/Account/Authenticated/ChangeUsername.cs

View workflow job for this annotation

GitHub Actions / Tests

Parameter 'auditService' has no matching param tag in the XML comment for 'AuthenticatedAccountController.ChangeUsername(ChangeUsernameRequest, IAuditService)' (but other parameters do)
{
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<IActionResult>(
success => Ok(),
usernametaken => Problem(AccountError.UsernameTaken),
usernameerror => Problem(AccountError.UsernameInvalid(usernameerror)),
recentlychanged => Problem(AccountError.UsernameRecentlyChanged),
accountdeactivated => Problem(AccountError.AccountDeactivated),
return await result.Match<Task<IActionResult>>(
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<IActionResult>(Problem(AccountError.UsernameTaken)),
usernameerror => Task.FromResult<IActionResult>(Problem(AccountError.UsernameInvalid(usernameerror))),
recentlychanged => Task.FromResult<IActionResult>(Problem(AccountError.UsernameRecentlyChanged)),
accountdeactivated => Task.FromResult<IActionResult>(Problem(AccountError.AccountDeactivated)),
notfound => throw new Exception("Unexpected result, apparently our current user does not exist..."));
}
}
25 changes: 19 additions & 6 deletions API/Controller/Account/Authenticated/Deactivate.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -14,14 +18,23 @@ public sealed partial class AuthenticatedAccountController
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // CannotDeactivatePrivledgedAccount
public async Task<IActionResult> Deactivate()
public async Task<IActionResult> Deactivate([FromServices] IAuditService auditService)
{
var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true);
return deactivationResult.Match<IActionResult>(
success => NoContent(),
cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount),
alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated),
unauthorized => Problem(AccountActivationError.Unauthorized),
return await deactivationResult.Match<Task<IActionResult>>(
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<IActionResult>(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)),
alreadyDeactivated => Task.FromResult<IActionResult>(Problem(AccountActivationError.AlreadyDeactivated)),
unauthorized => Task.FromResult<IActionResult>(Problem(AccountActivationError.Unauthorized)),
notFound => throw new Exception("This is not supposed to happen, wtf?")
);
}
Expand Down
49 changes: 49 additions & 0 deletions API/Controller/Account/Authenticated/GetAuditLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Models.Response;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Audit;
using OpenShock.Common.Utils.Pagination;

namespace OpenShock.API.Controller.Account.Authenticated;

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

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

private static AuditLogEntryResponse MapEntry(UserAuditLog x) => new()
{
Id = x.Id,
UserId = x.UserId,
ActorId = x.ActorId,
Action = x.Action,
IpAddress = x.IpAddress,
UserAgent = x.UserAgent,
Metadata = x.Metadata,
CreatedAt = x.CreatedAt,
};
}
18 changes: 17 additions & 1 deletion API/Controller/Account/Authenticated/OAuthConnectionRemove.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -10,19 +14,31 @@ public sealed partial class AuthenticatedAccountController
/// </summary>
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
/// <param name="connectionService"></param>
/// <param name="auditService"></param>
/// <param name="cancellationToken"></param>
/// <response code="204">Connection removed.</response>
/// <response code="404">No connection found for this provider.</response>
[HttpDelete("connections/{provider}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken)
public async Task<IActionResult> 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();
}
}
19 changes: 16 additions & 3 deletions API/Controller/Account/Logout.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,12 +13,22 @@ public sealed partial class AccountController
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status200OK)]
[MapToApiVersion("1")]
public async Task<IActionResult> Logout([FromServices] ISessionService sessionService)
public async Task<IActionResult> 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
Expand Down
31 changes: 22 additions & 9 deletions API/Controller/Account/VerifyEmail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,8 +24,8 @@ public sealed partial class AccountController
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)]
[MapToApiVersion("1")]
public Task<IActionResult> EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken)
=> VerifyPendingEmailChange(token, cancellationToken);
public Task<IActionResult> EmailVerify([FromQuery(Name = "token")] string token, [FromServices] IAuditService auditService, CancellationToken cancellationToken)
=> VerifyPendingEmailChange(token, auditService, cancellationToken);

/// <summary>
/// Verify a pending email change. Deprecated: use POST /email-change/verify instead.
Expand All @@ -35,16 +39,25 @@ public Task<IActionResult> EmailVerify([FromQuery(Name = "token")] string token,
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)]
[MapToApiVersion("1")]
public Task<IActionResult> EmailVerifyLegacy([FromQuery(Name = "token")] string token, CancellationToken cancellationToken)
=> VerifyPendingEmailChange(token, cancellationToken);
public Task<IActionResult> EmailVerifyLegacy([FromQuery(Name = "token")] string token, [FromServices] IAuditService auditService, CancellationToken cancellationToken)
=> VerifyPendingEmailChange(token, auditService, cancellationToken);

private async Task<IActionResult> VerifyPendingEmailChange(string token, CancellationToken cancellationToken)
private async Task<IActionResult> VerifyPendingEmailChange(string token, IAuditService auditService, CancellationToken cancellationToken)
{
var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken);

return result.Match<IActionResult>(
success => Ok(),
notFound => Problem(AccountError.EmailChangeNotFound),
emailTaken => Problem(AccountError.EmailChangeAlreadyInUse));
return await result.Match<Task<IActionResult>>(
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<IActionResult>(Problem(AccountError.EmailChangeNotFound)),
emailTaken => Task.FromResult<IActionResult>(Problem(AccountError.EmailChangeAlreadyInUse)));
}
}
Loading
Loading