1
0

feat: Add fully user functions

This commit is contained in:
Ca2didi 2025-03-23 20:33:16 +08:00
parent 6e156ee3a4
commit 271165fb88
24 changed files with 464 additions and 82 deletions

View File

@ -6,6 +6,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticatorTokenProvider_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fd56cb0a089b14dab96ad3ee133819f966d938_003Feb_003Fa2d5eee1_003FAuthenticatorTokenProvider_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaimsPrincipal_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf5ce3f8ae0647439d514bb1a3c7f96b13600_003F20_003Fabeaf9ae_003FClaimsPrincipal_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaimTypes_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf5ce3f8ae0647439d514bb1a3c7f96b13600_003Fbd_003F4cde67a5_003FClaimTypes_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdffdaf205cf54e098aa7d66ba76b38621de920_003F53_003F6f15feba_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADbContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcb0587797ea44bd6915ede69888c6766291038_003Fbc_003F2b4c89d0_003FDbContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityDbContext_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3eeae7a548684642a53a9ceddc825b7a1a930_003Fcf_003F6a374370_003FIdentityDbContext_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUserToken_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Facfead36ed0138084f13ee724545d2dcb853f354ec658443b72fc26eff58781_003FIdentityUserToken_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -18,15 +19,16 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASignInManager_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F40411c547364428dafc988a7615774e28b910_003F6d_003F1a409232_003FSignInManager_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStringValue_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee39d1e9346e41aa9d44f0e1b1c6630f76268_003F49_003Fb92346b2_003FStringValue_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATestMethodInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F5ef53d675c5d34a6b85963919015dc0c1b06e5ea9834aac59ae6911f4c6f38_003FTestMethodInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUserManager_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb3abda585dc54c3f81f64fdda8fc5ba72b708_003F2f_003F1d20f21a_003FUserManager_00601_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUserManager_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fd56cb0a089b14dab96ad3ee133819f966d938_003F9c_003F183f8355_003FUserManager_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValueTuple_00602_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa6b7f037ba7b44df80b8d3aa7e58eeb2e8e938_003Fa7_003F76eb4679_003FValueTuple_00602_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b5573ef9_002Db554_002D4a56_002D82c4_002D2531c8feef65/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" IsLocked="True" Name="PathValidationTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;TestAncestor&gt;
&lt;TestId&gt;MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit&lt;/TestId&gt;
&lt;/TestAncestor&gt;
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b5573ef9_002Db554_002D4a56_002D82c4_002D2531c8feef65/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" IsLocked="True" Name="PathValidationTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;TestAncestor&gt;&#xD;
&lt;TestId&gt;MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit&lt;/TestId&gt;&#xD;
&lt;/TestAncestor&gt;&#xD;
&lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=f3f8a684_002Dc08e_002D489f_002D949c_002D6c38a1ed63b0/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="PathValidationTest #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;TestAncestor&gt;
&lt;TestId&gt;MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit.PathValidationTest&lt;/TestId&gt;
&lt;/TestAncestor&gt;
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=f3f8a684_002Dc08e_002D489f_002D949c_002D6c38a1ed63b0/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="PathValidationTest #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;TestAncestor&gt;&#xD;
&lt;TestId&gt;MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit.PathValidationTest&lt;/TestId&gt;&#xD;
&lt;/TestAncestor&gt;&#xD;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>

View File

@ -6,4 +6,10 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Identity.Core">
<HintPath>..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\9.0.2\Microsoft.Extensions.Identity.Core.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@ -1,10 +0,0 @@
namespace Flawless.Communication.Request.Auth;
public record RegisterRequest
{
public string Email { get; set; }
public string Username { get; set; }
public string Password { get; set; }
}

View File

@ -0,0 +1,3 @@
namespace Flawless.Communication.Request;
public record LocateUserRequest(string? UserId, string? Username);

View File

@ -1,4 +1,4 @@
namespace Flawless.Communication.Request.Auth;
namespace Flawless.Communication.Request;
public record LoginRequest
{

View File

@ -0,0 +1,10 @@
namespace Flawless.Communication.Request;
public class QueryPagesRequest<T>
{
public required int Offset { get; init; }
public required int Length { get; init; }
public T? Parameter { get; init; }
}

View File

@ -0,0 +1,10 @@
namespace Flawless.Communication.Request;
public record RegisterRequest
{
public required string Email { get; set; }
public required string Username { get; set; }
public required string Password { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace Flawless.Communication.Request;
public class ResetPasswordRequest
{
public string? Identity { get; set; }
public string? OldPassword { get; set; }
public required string NewPassword { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace Flawless.Communication.Request;
public record UserContactModifyResponse
{
public string? Email { get; set; }
public string? Phone { get; set; }
}

View File

@ -0,0 +1,14 @@
using Flawless.Communication.Shared;
namespace Flawless.Communication.Request;
public record UserInfoModifyResponse
{
public string? NickName { get; set; }
public UserSex? Gender { get; set; }
public string? Bio { get; set; }
public bool? PublicEmail { get; set; }
}

View File

@ -1,8 +1,3 @@
namespace Flawless.Communication.Response;
public record FailedResponse(object message, string? failure = "Unknown")
{
public string? Failure { get; set; } = failure;
public object? Message { get; set; } = message;
}
public record FailedResponse(object Message, string? Failure = "Default");

View File

@ -0,0 +1,25 @@
namespace Flawless.Communication.Response;
public record PagedResponse<T>
{
public required int Offset { get; init; }
public required int Length { get; init; }
public uint? Total { get; init; }
public IEnumerable<T>? Data { get; init; }
}
public record PagedResponse<TData, TMetadata>
{
public required int Offset { get; init; }
public required int Length { get; init; }
public int? Total { get; init; }
public IEnumerable<TData>? Data { get; init; }
public TMetadata? Metadata { get; init; }
}

View File

@ -1,8 +1,14 @@
namespace Flawless.Communication.Response;
public record UnintendedExceptionResponse(Exception e)
public record UnintendedExceptionResponse
{
public string ExceptionType { get; } = e.GetType().Name;
public string ExceptionType { get; }
public string ExceptionMessage { get; }
public string ExceptionMessage { get; } = e.Message;
public UnintendedExceptionResponse(Exception e)
{
ExceptionType = e.GetType().FullName;
ExceptionMessage = e.Message;
}
}

View File

@ -0,0 +1,24 @@
using Flawless.Communication.Shared;
namespace Flawless.Communication.Response;
public record UserInfoResponse
{
public bool Authorized { get; set; }
public string? Username { get; set; }
public string? NickName { get; set; }
public UserSex? Gender { get; set; }
public string? Bio { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public bool? PublicEmail { get; set; }
public DateTime? CreatedAt { get; set; }
}

View File

@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Identity;
namespace Flawless.Communication.Response;
public class UserLoginHistoryResponse
{
}

View File

@ -0,0 +1,9 @@
namespace Flawless.Communication.Shared;
public enum UserSex : byte
{
Unset = 0,
Male = 1,
Female = 2,
WalmartPlasticBag = 3
}

View File

@ -0,0 +1,67 @@
using Flawless.Communication.Request;
using Flawless.Communication.Response;
using Flawless.Server.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace Flawless.Server.Controllers;
[ApiController, Authorize, Route("api/admin")]
public class AdminUserController(
UserManager<AppUser> userManager) : ControllerBase
{
[HttpPost("user/delete")]
public async Task<IActionResult> DeleteUserAsync(LocateUserRequest r)
{
if (r.UserId == null) return BadRequest(new FailedResponse("User id is not set!"));
var user = await userManager.FindByIdAsync(r.UserId);
if (user == null) return BadRequest(new FailedResponse("User does not exist!"));
var result = await userManager.DeleteAsync(user);
if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors));
return Ok();
}
[HttpPost("user/enable")]
public async Task<IActionResult> EnableUserAsync(LocateUserRequest r)
{
if (r.UserId == null) return BadRequest(new FailedResponse("User id is not set!"));
var user = await userManager.FindByIdAsync(r.UserId);
if (user == null) return BadRequest(new FailedResponse("User does not exist!"));
var result = await userManager.SetLockoutEnabledAsync(user, false);
if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors));
return Ok();
}
[HttpPost("user/disable")]
public async Task<IActionResult> DisableUserAsync(LocateUserRequest r)
{
if (r.UserId == null) return BadRequest(new FailedResponse("User id is not set!"));
var user = await userManager.FindByIdAsync(r.UserId);
if (user == null) return BadRequest(new FailedResponse("User does not exist!"));
var result = await userManager.SetLockoutEnabledAsync(user, true);
if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors));
return Ok();
}
[HttpPost("user/reset_password")]
public async Task<IActionResult> ResetPasswordAsync(ResetPasswordRequest r)
{
if (r.Identity == null) return BadRequest(new FailedResponse("Identity (User Id) is not set!"));
var user = await userManager.FindByIdAsync(r.Identity);
if (user == null) return BadRequest(new FailedResponse("Identity (User Id) does not exist!"));
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
var result = await userManager.ResetPasswordAsync(user, resetToken, r.NewPassword);
if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors));
return Ok();
}
}

View File

@ -1,9 +1,10 @@
using System.Security.Claims;
using Flawless.Communication.Request.Auth;
using Flawless.Communication.Request;
using Flawless.Communication.Response;
using Flawless.Communication.Shared;
using Flawless.Server.Models;
using Flawless.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -22,7 +23,7 @@ public class AuthenticationController(
{
[HttpPost("register")]
public async Task<IActionResult> PublicRegisterAsync(RegisterRequest request)
public async Task<ActionResult> PublicRegisterAsync(RegisterRequest request)
{
if (!config.GetValue("User:PublicRegister", true))
return BadRequest(new FailedResponse("Not opened for public register."));
@ -49,7 +50,7 @@ public class AuthenticationController(
}
[HttpPost("login")]
public async Task<IActionResult> LoginAsync(LoginRequest r)
public async Task<ActionResult> LoginAsync(LoginRequest r)
{
var user = await userManager.FindByNameAsync(r.Username);
if (user == null) return BadRequest(new FailedResponse("Invalid username or password."));
@ -83,12 +84,12 @@ public class AuthenticationController(
}
[HttpPost("refresh")]
public async Task<IActionResult> RefreshAsync(TokenInfo r)
public async Task<ActionResult<TokenInfo>> RefreshAsync(TokenInfo r)
{
var now = DateTime.UtcNow;
var set = dbContext.RefreshTokens;
var principal = tokenService.GetPrincipalFromExpiredToken(r.Token);
var user = await signInManager.ValidateSecurityStampAsync(principal);
var user = await userManager.GetUserAsync(principal);
if (user == null) return BadRequest(new FailedResponse("Token is ban. Please login again."));
try
@ -124,12 +125,41 @@ public class AuthenticationController(
}
[HttpPost("logout_all")]
[Authorize]
public async Task<IActionResult> LogoutAllAsync()
{
var u = (await userManager.GetUserAsync(User))!;
u.RenewSecurityStamp();
// Do not let tokens can be refresh
await dbContext.RefreshTokens.Where(k => k.UserId == u.Id).ExecuteDeleteAsync();
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpPost("renew_password")]
[Authorize]
public async Task<IActionResult> RenewPasswordAsync(ResetPasswordRequest r)
{
var u = (await userManager.GetUserAsync(User))!;
if (string.IsNullOrEmpty(r.OldPassword)) return Unauthorized(new FailedResponse("Old password is empty."));
var result = await userManager.ChangePasswordAsync(u, r.OldPassword, r.NewPassword);
if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors));
u.RenewSecurityStamp();
await dbContext.SaveChangesAsync();
return Ok();
}
private async ValueTask<IList<Claim>> GetClaimsAsync(AppUser user, string refreshToken)
{
var c = await userManager.GetClaimsAsync(user);
c.Add(new (FlawlessClaimsType.SecurityStamp, user.SecurityStamp ?? string.Empty));
c.Add(new (ClaimTypes.NameIdentifier, user.Id.ToString()));
c.Add(new (ClaimTypes.Name, user.UserName!));
c.Add(new (FlawlessClaimsType.RefreshToken, refreshToken));
return c;

View File

@ -1,9 +1,152 @@
using Microsoft.AspNetCore.Mvc;
using Flawless.Communication.Request;
using Flawless.Communication.Response;
using Flawless.Communication.Shared;
using Flawless.Server.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Flawless.Server.Controllers;
[ApiController, Route("api/user")]
public class UserController : ControllerBase
[ApiController, Authorize, Route("api/user")]
public class UserController(
UserManager<AppUser> userManager
) : ControllerBase
{
[HttpPost("update/info")]
public async Task<IActionResult> UpdateUserInfoAsync(UserInfoModifyResponse r)
{
bool update = false;
bool renew = false;
// Modify content
var u = (await userManager.GetUserAsync(HttpContext.User))!;
if (r.NickName != null)
{
update = true;
u.NickName = r.NickName;
}
if (r.Bio != null)
{
update = true;
u.Bio = r.Bio;
}
if (r.Gender != null)
{
update = true;
u.Gender = r.Gender ?? UserSex.Unset;
}
if (r.PublicEmail != null)
{
update = true;
u.PublicEmail = r.PublicEmail ?? false;
}
if (renew) u.RenewSecurityStamp();
if (update || renew) await userManager.UpdateAsync(u);
return Ok();
}
[HttpPost("update/email")]
public async Task<IActionResult> UpdateEmailAsync(UserContactModifyResponse r)
{
if (string.IsNullOrWhiteSpace(r.Email))
return BadRequest(new FailedResponse("No valid email address provided!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var result = await userManager.SetEmailAsync(u, r.Email);
if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors));
return Ok();
}
[HttpPost("update/phone")]
public async Task<IActionResult> UpdatePhoneAsync(UserContactModifyResponse r)
{
if (string.IsNullOrWhiteSpace(r.Phone))
return BadRequest(new FailedResponse("No valid phone number provided!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var result = await userManager.SetPhoneNumberAsync(u, r.Phone);
if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors));
return Ok();
}
[HttpGet("get/info")]
public async Task<ActionResult<UserInfoResponse>> GetUserInfoAsync(LocateUserRequest r)
{
var self = (await userManager.GetUserAsync(HttpContext.User))!;
if (r.UserId != null)
{
var u = await userManager.FindByIdAsync(r.UserId);
if (u == null) return BadRequest(new FailedResponse("User is not existed!"));
return Ok(GetUserInfoInternal(u, self));
}
if (r.Username != null)
{
var u = await userManager.FindByNameAsync(r.Username);
if (u == null) return BadRequest(new FailedResponse("User is not existed!"));
return Ok(GetUserInfoInternal(u, self));
}
// Return self as default
return Ok(GetUserInfoInternal(self, self));
}
[HttpGet("query/info")]
public async Task<ActionResult<PagedResponse<UserInfoResponse>>> GetUserInfoAsync(QueryPagesRequest<LocateUserRequest> r)
{
var queryNamePrefix = r.Parameter?.Username ?? String.Empty;
var queryId = r.Parameter == null ? Guid.Empty : Guid.Parse(r.Parameter.UserId!);
var payload = await userManager.Users
.Where(u => u.UserName!.StartsWith(queryNamePrefix) || u.Id == queryId)
.Skip(r.Offset)
.Take(r.Length)
.Select(u => GetUserInfoInternal(u, null))
.ToArrayAsync();
// Return self as default
return Ok(new PagedResponse<UserInfoResponse>
{
Offset = r.Offset,
Length = r.Length,
Data = payload
});
}
[HttpGet("delete")]
public async Task<IActionResult> DeleteUserAsync()
{
var self = (await userManager.GetUserAsync(HttpContext.User))!;
await userManager.DeleteAsync(self);
return Ok();
}
private UserInfoResponse GetUserInfoInternal(AppUser queryUser, AppUser? currentUser)
{
var authorized = queryUser.Id == currentUser?.Id;
return new UserInfoResponse
{
Username = queryUser.UserName,
CreatedAt = queryUser.CreatedOn,
Bio = queryUser.Bio,
Gender = queryUser.Gender,
NickName = queryUser.NickName,
Email = queryUser.PublicEmail || authorized ? queryUser.Email : null,
};
}
}

View File

@ -37,8 +37,4 @@
<Compile Remove="Migrations\20250322194407_InitialCreate.Designer.cs" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View File

@ -1,11 +1,25 @@
using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations;
using Flawless.Communication.Shared;
using Microsoft.AspNetCore.Identity;
namespace Flawless.Server.Models;
public class AppUser : IdentityUser<Guid>
{
public DateTime CreatedOn { get; set; }
public UserSex Gender { get; set; }
[MaxLength(50)]
public string? NickName { get; set; }
[MaxLength(200)]
public string? Bio { get; set; }
public bool PublicEmail { get; set; }
public void RenewSecurityStamp()
{
this.SecurityStamp = Guid.NewGuid().ToString();

View File

@ -1,10 +1,12 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json.Serialization;
using Flawless.Communication.Response;
using Flawless.Server.Middlewares;
using Flawless.Server.Models;
using Flawless.Server.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@ -32,7 +34,11 @@ public static class Program
{
// Api related
builder.Services.AddOpenApi();
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(opt =>
{
opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opt =>
{
@ -62,7 +68,6 @@ public static class Program
opt.SignIn.RequireConfirmedEmail = false;
opt.SignIn.RequireConfirmedPhoneNumber = false;
opt.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier;
opt.ClaimsIdentity.UserNameClaimType = ClaimTypes.Name;
opt.ClaimsIdentity.SecurityStampClaimType = FlawlessClaimsType.SecurityStamp;
})
.AddSignInManager()
@ -75,7 +80,6 @@ public static class Program
var secretKey = config["Jwt:SecretKey"] ?? throw new ApplicationException("No secret key found.");
var issuer = config["Jwt:Issuer"] ?? throw new ApplicationException("No issuer found.");
// Authentication related.
builder.Services.AddSingleton<TokenGenerationService>();
builder.Services.AddAuthentication(opt =>
@ -96,45 +100,12 @@ public static class Program
RoleClaimType = ClaimTypes.Role,
NameClaimType = ClaimTypes.Name,
};
opt.Events = new JwtBearerEvents {
OnAuthenticationFailed = context =>
{
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) {
context.Response.Headers.Append("Token-Expired", "true");
}
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
if (context.Principal != null)
{
var p = context.Principal!;
var id = p.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (id == null) throw new SecurityTokenExpiredException("User is not defined in the token!");
var stamp = p.FindFirst(FlawlessClaimsType.SecurityStamp)?.Value;
if (stamp == null) throw new SecurityTokenExpiredException("No valid SecurityStamp found.");
var db = context.HttpContext.RequestServices.GetRequiredService<UserManager<AppUser>>();
// Start validate user is existed and not expired.
var u = await db.FindByIdAsync(id!);
if (u == null) throw new SecurityTokenExpiredException("User is not existed in db.");
if (u.SecurityStamp != stamp) throw new SecurityTokenExpiredException("SecurityStamp is mismatched.");
context.HttpContext.User = p;
context.Success();
}
}
OnAuthenticationFailed = OnAuthenticationFailedAsync,
OnTokenValidated = OnTokenValidatedAsync
};
});
// builder.Services.AddAuthorization(opt =>
// {
// opt.DefaultPolicy =
// })
}
private static void SetupWebApplication(WebApplication app)
@ -170,4 +141,45 @@ public static class Program
app.MapGet("/", () => "<p>Please use client app to open this server.</p>");
}
}
private static async Task OnTokenValidatedAsync(TokenValidatedContext context)
{
if (context.Principal != null)
{
var p = context.Principal!;
var auth = context.HttpContext.GetEndpoint()?.Metadata.GetOrderedMetadata<AuthorizeAttribute>();
if (auth?.Any() ?? false)
{
var id = p.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (id == null) throw new SecurityTokenExpiredException("User is not defined in the token!");
var stamp = p.FindFirst(FlawlessClaimsType.SecurityStamp)?.Value;
if (stamp == null) throw new SecurityTokenExpiredException("No valid SecurityStamp found.");
// Validate user status
var db = context.HttpContext.RequestServices.GetRequiredService<UserManager<AppUser>>();
var u = await db.FindByIdAsync(id!);
if (u == null) throw new SecurityTokenExpiredException("User is not existed.");
if (u.SecurityStamp != stamp) throw new SecurityTokenExpiredException("SecurityStamp is mismatched.");
if (u.LockoutEnabled) throw new SecurityTokenExpiredException("User has been locked.");
}
// Extract user info into HttpContext
context.HttpContext.User = p;
context.Success();
}
}
private static Task OnAuthenticationFailedAsync(AuthenticationFailedContext context)
{
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
context.Response.Headers.Append("Token-Expired", "true");
}
return Task.CompletedTask;
}
}

View File

@ -9,4 +9,5 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>(options)
{
public DbSet<AppUserRefreshKey> RefreshTokens { get; set; }
}

View File

@ -11,7 +11,7 @@
},
"LocalStoragePath": "./data/development",
"User": {
"PublicRegister": false
"PublicRegister": true
},
"Jwt": {
"SecretKey": "your_256bit_security_key_at_here_otherwise_not_bootable",