FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat: More admin endpoints (#371) · OpenShock/API@1742e06 · GitHub

Repository navigation

Commit 1742e06

Browse files
authored
feat: More admin endpoints (#371)
Adds admin endpoints for inspecting a single user and for changing their credentials: - GET /admin/users/{userId} detailed user view: hubs, API tokens, password resets, activation and deactivation state, email/name change history, control log count - PUT /admin/users/{userId}/name set username (ignores the change rate limit) - PUT /admin/users/{userId}/email set email - PUT /admin/users/{userId}/password set password - DELETE /admin/apitokens/{tokenId} revoke an API token Every action calls PedanticallyEnsureAdmin() first, like the other admin actions. The request bodies use the same Username/EmailAddress/Password validation attributes as the matching self-service endpoints. Token deletion goes through IApiTokenService.DeleteToken, so the deletion is audited and any open live control connection using the token is closed. AccountService gains CheckEmailAvailability and ChangeEmail. ChangeEmail lowercases the address, rotates the SecurityStamp so outstanding reset and email-change requests are invalidated, and writes the update and an EmailChanged audit entry (with the admin as actor) in one transaction. If another account claims the address between the availability check and the write, it returns EmailTaken instead of a 500. The detailed view reads the password algorithm from the hash prefix via HashingUtils, so password accounts no longer all report Unknown. New AdminError problems: Email/Username Taken (409), EmailInvalid (400), and UserNotActivated/UserDeactivated (409) for when the target account's state blocks the change.
1 parent 76e2772 commit 1742e06

8 files changed

Lines changed: 525 additions & 1 deletion

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using OpenShock.API.Services.Token;
3+
4+
namespace OpenShock.API.Controller.Admin;
5+
6+
public sealed partial class AdminController
7+
{
8+
/// <summary>
9+
/// Deletes an API token
10+
/// </summary>
11+
/// <response code="200">OK</response>
12+
/// <response code="401">Unauthorized</response>
13+
[HttpDelete("apitokens/{tokenId}")]
14+
[ProducesResponseType(StatusCodes.Status200OK)]
15+
public async Task<IActionResult> DeleteApiToken([FromRoute] Guid tokenId, [FromServices] IApiTokenService apiTokenService, CancellationToken cancellationToken)
16+
{
17+
PedanticallyEnsureAdmin();
18+
19+
// Deleting straight off the DbSet would skip the audit entry and, more importantly, the token
20+
// update that tears down an open live control connection - the revoked token would keep
21+
// controlling devices until that connection closed on its own.
22+
var deleted = await apiTokenService.DeleteToken(tokenId, actorId: CurrentUser.Id, cancellationToken: cancellationToken);
23+
24+
return deleted ? Ok() : NotFound();
25+
}
26+
}

‎API/Controller/Admin/GetUser.cs‎

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.EntityFrameworkCore;
3+
using OpenShock.Common.Constants;
4+
using OpenShock.Common.Utils;
5+
using OpenShock.Common.Models;
6+
using OpenShock.Common.OpenShockDb;
7+
using System.Linq.Expressions;
8+
using System.Net;
9+
using System.Net.Mime;
10+
using Z.EntityFramework.Plus;
11+
12+
namespace OpenShock.API.Controller.Admin;
13+
14+
public sealed partial class AdminController
15+
{
16+
public sealed class AdminUserView_UserRef
17+
{
18+
public required Guid Id { get; init; }
19+
public required string Name { get; init; }
20+
}
21+
public sealed class AdminUserView_Shocker
22+
{
23+
public required Guid Id { get; init; }
24+
public required string Name { get; init; }
25+
public required DateTimeOffset CreatedAt { get; init; }
26+
}
27+
public sealed class AdminUserView_Hub
28+
{
29+
public required Guid Id { get; init; }
30+
public required string Name { get; init; }
31+
public required DateTimeOffset CreatedAt { get; init; }
32+
public required AdminUserView_Shocker[] Shockers { get; init; }
33+
}
34+
public sealed class AdminUserView_ApiToken
35+
{
36+
public required Guid Id { get; init; }
37+
public required string Name { get; init; }
38+
public required List<PermissionType> Permissions { get; init; }
39+
public required DateTimeOffset? ValidUntil { get; init; }
40+
public required DateTimeOffset? LastUsed { get; init; }
41+
public required DateTimeOffset CreatedAt { get; init; }
42+
public required IPAddress CreatedByIp { get; init; }
43+
}
44+
public sealed class AdminUserView_PasswordReset
45+
{
46+
public required Guid Id { get; init; }
47+
public required DateTimeOffset CreatedAt { get; init; }
48+
public required DateTimeOffset? UsedAt { get; init; }
49+
}
50+
public sealed class AdminUserView_UserActivationRequest
51+
{
52+
public required int EmailSendAttempts { get; init; }
53+
public required DateTimeOffset CreatedAt { get; init; }
54+
}
55+
public sealed class AdminUserView_UserDeactivation
56+
{
57+
public required AdminUserView_UserRef DeactivatedBy { get; init; }
58+
public required DateTimeOffset? ScheduledDeletionTime { get; init; }
59+
public required DateTimeOffset DeactivatedAt { get; init; }
60+
}
61+
public sealed class AdminUserView_EmailChange
62+
{
63+
public required Guid Id { get; init; }
64+
public required string Email { get; init; }
65+
public required DateTimeOffset CreatedAt { get; init; }
66+
public required DateTimeOffset? UsedAt { get; init; }
67+
}
68+
public sealed class AdminUserView_NameChange
69+
{
70+
public required int Id { get; init; }
71+
public required string OldName { get; init; }
72+
public required DateTimeOffset CreatedAt { get; init; }
73+
}
74+
public sealed class AdminUserView
75+
{
76+
public required Guid Id { get; init; }
77+
78+
public required string Name { get; init; }
79+
80+
public required string Email { get; init; }
81+
82+
public required PasswordHashingAlgorithm PasswordHashType { get; init; }
83+
84+
public required List<RoleType> Roles { get; init; }
85+
86+
public required DateTimeOffset CreatedAt { get; init; }
87+
88+
public required DateTimeOffset? ActivatedAt { get; init; }
89+
90+
public required AdminUserView_UserActivationRequest? ActivationRequest { get; init; }
91+
92+
public required AdminUserView_UserDeactivation? Deactivation { get; init; }
93+
94+
public required AdminUserView_Hub[] Hubs { get; init; }
95+
public required AdminUserView_ApiToken[] ApiTokens { get; init; }
96+
public required AdminUserView_NameChange[] UsersNameChanges { get; init; }
97+
public required AdminUserView_EmailChange[] UsersEmailChanges { get; init; }
98+
public required AdminUserView_PasswordReset[] PasswordResets { get; init; }
99+
public required int ShockerControlLogsCount { get; init; }
100+
}
101+
102+
[NonAction]
103+
private async Task<AdminUserView?> GetUserBySelectorAsync(Expression<Func<User,bool>> expression, CancellationToken cancellationToken)
104+
{
105+
var user = await _db.Users
106+
.Where(expression)
107+
.Select(u => new
108+
{
109+
u.Id,
110+
u.Name,
111+
u.Email,
112+
u.PasswordHash,
113+
u.Roles,
114+
u.CreatedAt,
115+
u.ActivatedAt,
116+
Hubs = u.Devices.Select(hub =>
117+
new AdminUserView_Hub
118+
{
119+
Id = hub.Id,
120+
Name = hub.Name,
121+
CreatedAt = hub.CreatedAt,
122+
Shockers = hub.Shockers.Select(shocker => new AdminUserView_Shocker
123+
{
124+
Id = shocker.Id,
125+
Name = shocker.Name,
126+
CreatedAt = shocker.CreatedAt,
127+
}).ToArray()
128+
}
129+
).ToArray(),
130+
ApiTokens = u.ApiTokens.Select(token =>
131+
new AdminUserView_ApiToken
132+
{
133+
Id = token.Id,
134+
Name = token.Name,
135+
Permissions = token.Permissions,
136+
ValidUntil = token.ValidUntil,
137+
LastUsed = token.LastUsed,
138+
CreatedAt = token.CreatedAt,
139+
CreatedByIp = token.CreatedByIp,
140+
}
141+
).ToArray(),
142+
PasswordResets = u.PasswordResets.Select(reset =>
143+
new AdminUserView_PasswordReset
144+
{
145+
Id = reset.Id,
146+
CreatedAt = reset.CreatedAt,
147+
UsedAt = reset.UsedAt,
148+
}
149+
).ToArray(),
150+
ActivationRequest = u.UserActivationRequest == null
151+
? null
152+
: new AdminUserView_UserActivationRequest
153+
{
154+
EmailSendAttempts = u.UserActivationRequest.EmailSendAttempts,
155+
CreatedAt = u.UserActivationRequest.CreatedAt,
156+
},
157+
Deactivation = u.UserDeactivation == null
158+
? null
159+
: new
160+
{
161+
DeactivatedById = u.UserDeactivation.DeactivatedByUser.Id,
162+
DeactivatedByName = u.UserDeactivation.DeactivatedByUser.Name,
163+
u.UserDeactivation.DeleteLater,
164+
u.UserDeactivation.CreatedAt,
165+
},
166+
UsersEmailChanges = u.EmailChanges.Select(change =>
167+
new AdminUserView_EmailChange
168+
{
169+
Id = change.Id,
170+
Email = change.NewEmail,
171+
CreatedAt = change.CreatedAt,
172+
UsedAt = change.UsedAt,
173+
}
174+
).ToArray(),
175+
UsersNameChanges = u.NameChanges.Select(change =>
176+
new AdminUserView_NameChange
177+
{
178+
Id = change.Id,
179+
OldName = change.OldName,
180+
CreatedAt = change.CreatedAt,
181+
}
182+
).ToArray(),
183+
ShockerControlLogsCount = u.ShockerControlLogs.Count()
184+
185+
})
186+
.FirstOrDefaultAsync(cancellationToken);
187+
if (user == null) return null;
188+
189+
// The stored value is a prefixed hash ("bcrypt:$2..."), never a bare algorithm name, so it has
190+
// to go through the prefix parser rather than Enum.TryParse.
191+
var passwordHashingAlgorithm = string.IsNullOrEmpty(user.PasswordHash)
192+
? PasswordHashingAlgorithm.Unknown
193+
: HashingUtils.GetPasswordHashingAlgorithm(user.PasswordHash);
194+
195+
return new AdminUserView
196+
{
197+
Id = user.Id,
198+
Name = user.Name,
199+
Email = user.Email,
200+
PasswordHashType = passwordHashingAlgorithm,
201+
Roles = user.Roles,
202+
CreatedAt = user.CreatedAt,
203+
ActivatedAt = user.ActivatedAt,
204+
Hubs = user.Hubs,
205+
ApiTokens = user.ApiTokens,
206+
PasswordResets = user.PasswordResets,
207+
ActivationRequest = user.ActivationRequest,
208+
Deactivation = user.Deactivation is null
209+
? null
210+
: new AdminUserView_UserDeactivation
211+
{
212+
DeactivatedBy = new AdminUserView_UserRef
213+
{
214+
Id = user.Deactivation.DeactivatedById,
215+
Name = user.Deactivation.DeactivatedByName,
216+
},
217+
// Mirrors DeleteDeactivatedAccounts: only DeleteLater rows are ever deleted,
218+
// once DeactivatedAccountRetentionTime has elapsed since deactivation.
219+
ScheduledDeletionTime = user.Deactivation.DeleteLater
220+
? user.Deactivation.CreatedAt + Duration.DeactivatedAccountRetentionTime
221+
: null,
222+
DeactivatedAt = user.Deactivation.CreatedAt,
223+
},
224+
UsersEmailChanges = user.UsersEmailChanges,
225+
UsersNameChanges = user.UsersNameChanges,
226+
ShockerControlLogsCount = user.ShockerControlLogsCount,
227+
};
228+
}
229+
230+
231+
/// <summary>
232+
/// Gets a user by id
233+
/// </summary>
234+
/// <response code="200">User info</response>
235+
/// <response code="404">Not found</response>
236+
/// <response code="401">Unauthorized</response>
237+
[HttpGet("users/{userId}")]
238+
[ProducesResponseType<AdminUserView>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
239+
[ProducesResponseType(StatusCodes.Status404NotFound)]
240+
public async Task<IActionResult> GetUserById([FromRoute(Name = "userId")] Guid userId, CancellationToken cancellationToken)
241+
{
242+
PedanticallyEnsureAdmin();
243+
244+
var user = await GetUserBySelectorAsync(u => u.Id == userId, cancellationToken);
245+
return user == null ? NotFound() : Ok(user);
246+
}
247+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Diagnostics;
2+
using Microsoft.AspNetCore.Mvc;
3+
using OpenShock.API.Services.Account;
4+
using OpenShock.Common.Errors;
5+
using Results = OpenShock.Common.Results;
6+
7+
namespace OpenShock.API.Controller.Admin;
8+
9+
public sealed partial class AdminController
10+
{
11+
/// <summary>
12+
/// Sets a users email to the supplied value
13+
/// </summary>
14+
/// <response code="200">OK</response>
15+
/// <response code="404">Not found</response>
16+
/// <response code="401">Unauthorized</response>
17+
[HttpPut("users/{userId}/email")]
18+
[ProducesResponseType(StatusCodes.Status200OK)]
19+
[ProducesResponseType(StatusCodes.Status404NotFound)]
20+
public async Task<IActionResult> SetUserEmail([FromRoute(Name = "userId")] Guid userId, [FromBody] SetUserEmailRequestBody body, [FromServices] IAccountService accountService, CancellationToken cancellationToken)
21+
{
22+
PedanticallyEnsureAdmin();
23+
24+
var result = await accountService.ChangeEmail(userId, body.Email, actorId: CurrentUser.Id, cancellationToken);
25+
26+
return result switch
27+
{
28+
Results.Success => Ok(),
29+
EmailTaken => Problem(AdminError.EmailTaken),
30+
EmailInvalid => Problem(AdminError.EmailInvalid),
31+
Results.NotFound => NotFound(),
32+
_ => throw new UnreachableException()
33+
};
34+
}
35+
36+
public sealed class SetUserEmailRequestBody
37+
{
38+
[OpenShock.Common.DataAnnotations.EmailAddress(true)]
39+
public required string Email { get; init; }
40+
}
41+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Diagnostics;
2+
using System.Net.Mime;
3+
using Microsoft.AspNetCore.Mvc;
4+
using OpenShock.API.Services.Account;
5+
using OpenShock.Common.DataAnnotations;
6+
using OpenShock.Common.Errors;
7+
using OpenShock.Common.Problems;
8+
using OpenShock.Common.Validation;
9+
using Results = OpenShock.Common.Results;
10+
11+
using OpenShock.Internal.Common.Problems;
12+
13+
namespace OpenShock.API.Controller.Admin;
14+
15+
public sealed partial class AdminController
16+
{
17+
/// <summary>
18+
/// Sets a users name to the supplied value
19+
/// </summary>
20+
/// <response code="200">OK</response>
21+
/// <response code="404">Not found</response>
22+
/// <response code="401">Unauthorized</response>
23+
/// <response code="409">The target account is deactivated or not yet activated</response>
24+
[HttpPut("users/{userId}/name")]
25+
[ProducesResponseType(StatusCodes.Status200OK)]
26+
[ProducesResponseType(StatusCodes.Status404NotFound)]
27+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)]
28+
public async Task<IActionResult> SetUserName([FromRoute(Name = "userId")] Guid userId, [FromBody] SetUserNameRequestBody body, [FromServices] IAccountService accountService, CancellationToken cancellationToken)
29+
{
30+
PedanticallyEnsureAdmin();
31+
32+
var result = await accountService.ChangeUsernameAsync(userId, body.Name, actorId: CurrentUser.Id,
33+
ignoreLimit: true, cancellationToken);
34+
35+
return result switch
36+
{
37+
Results.Success => Ok(),
38+
UsernameTaken => Problem(AdminError.UsernameTaken),
39+
UsernameError usernameError => Problem(AccountError.UsernameInvalid(usernameError)),
40+
RecentlyChanged => throw new UnreachableException("Failed to bypass username change ratelimit!"),
41+
AccountDeactivated => Problem(AdminError.UserDeactivated),
42+
Results.NotFound => NotFound(),
43+
_ => throw new UnreachableException()
44+
};
45+
}
46+
47+
public sealed class SetUserNameRequestBody
48+
{
49+
[Username(true)]
50+
public required string Name { get; init; }
51+
}
52+
}

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL