Skip to content
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