Skip to content
Go back

ASP.NET Core 自定义用户管理:从注册到管理员面板

MapIdentityApi() 一条调用就能拿到注册、登录、token 刷新,看起来很诱人。但一旦需要注册时多填个 FirstName、需要管理员列出所有用户、需要给用户分配角色——这个快捷方式就到头了。

ASP.NET Core 自定义用户管理,就是在 Identity 之上自己写端点。你保留 Identity 的用户存储、密码哈希、角色和锁定机制,但完全控制 HTTP 层面的行为和返回数据。这比听起来要写的代码少得多。

MapIdentityApi 停在哪

MapIdentityApi 暴露了十个固定端点。你加不了自定义字段到 /register 请求体,改不了路由名,也没有列表用户或管理员管理用户的端点。这些限制在 dotnet/aspnetcore 仓库的 #50303#47288 里有明确记录。

所以真实应用需要自定义注册字段、管理界面或角色管理的时刻,你就停止调用 MapIdentityApi,自己写端点。本文构建的端点如下:

分组端点用途
AuthPOST /auth/register注册(带自定义字段)
AuthPOST /auth/login登录,返回 JWT
AuthPOST /auth/forgot-password .../reset-password密码重置
AuthPOST /auth/confirm-email邮箱确认
AdminGET /api/admin/users用户列表(分页、搜索、含角色)
AdminGET /api/admin/users/{id}单个用户
AdminPUT / DELETE /api/admin/users/{id}更新或删除
AdminPOST / DELETE /api/admin/users/{id}/roles分配或移除角色
AdminPOST .../lock .../unlock锁定/解锁(安全锁定)
AdminPOST .../disable .../enable禁用/启用(管理员开关)
SelfGET / PUT /api/me读或更新自己的资料
SelfPOST /api/me/change-password修改自己的密码

项目配置

从 .NET 10 Web API 开始。关键选择是用 AddIdentity 而非 AddIdentityApiEndpoints——前者保留完整 Identity 引擎但把端点留给你。

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseInMemoryDatabase("CustomUserManagementDb"));

builder.Services
    .AddIdentity<ApplicationUser, IdentityRole>(options =>
    {
        options.User.RequireUniqueEmail = true;
        options.Password.RequiredLength = 6;
        options.Lockout.MaxFailedAccessAttempts = 5;
        options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
    })
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();

AddDefaultTokenProviders() 是邮箱确认和密码重置令牌生效的前提,别跳过。

扩展 IdentityUser 加自定义字段

这是 MapIdentityApi 做不到的第一件事。IdentityUser 自带 IdEmailUserNamePasswordHash 和锁定相关列。加自己的字段只需要继承它:

public class ApplicationUser : IdentityUser
{
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string? ProfilePictureUrl { get; set; }
    public bool IsActive { get; set; } = true;
    public DateTimeOffset CreatedOn { get; set; } = DateTimeOffset.UtcNow;
}

DbContext 指向你的自定义用户类型,新列自动落到 AspNetUsers 表:

public class AppDbContext(DbContextOptions<AppDbContext> options)
    : IdentityDbContext<ApplicationUser>(options);

成本就是一个类加一个泛型参数。

种子角色和超级管理员

管理员端点被调之前至少要有一个管理员存在。启动时种子角色和管理员账号:

foreach (var role in new[] { "Admin", "User" })
{
    if (!await roleManager.RoleExistsAsync(role))
        await roleManager.CreateAsync(new IdentityRole(role));
}

if (await userManager.FindByEmailAsync("admin@example.com") is null)
{
    var admin = new ApplicationUser
    {
        UserName = "admin@example.com",
        Email = "admin@example.com",
        FirstName = "Super",
        LastName = "Admin",
        EmailConfirmed = true
    };
    await userManager.CreateAsync(admin, "Admin123!");
    await userManager.AddToRoleAsync(admin, "Admin");
}

UserManager<ApplicationUser>RoleManager<IdentityRole> 是后面所有端点背后的引擎。

自定义注册端点

MapIdentityApi 不允许加的注册端点现在你自己写:接受额外字段、创建用户、加入默认 User 角色、触发邮箱确认:

group.MapPost("/register", async (
    RegisterRequest request,
    UserManager<ApplicationUser> userManager,
    IEmailSender emailSender) =>
{
    var user = new ApplicationUser
    {
        UserName = request.Email,
        Email = request.Email,
        FirstName = request.FirstName,
        LastName = request.LastName
    };

    var result = await userManager.CreateAsync(user, request.Password);
    if (!result.Succeeded)
        return Results.ValidationProblem(result.ToProblemDictionary());

    await userManager.AddToRoleAsync(user, "User");

    var token = await userManager
        .GenerateEmailConfirmationTokenAsync(user);
    await emailSender.SendAsync(user.Email, "Confirm your email",
        $"Confirm with this token: {token}");

    var roles = await userManager.GetRolesAsync(user);
    return Results.Created(
        $"/api/admin/users/{user.Id}", user.ToResponse(roles));
});

关键习惯:永远不直接返回 ApplicationUser。它携带着 PasswordHash、安全戳和锁定内部状态。映射到你控制的响应 DTO:

public record UserResponse(
    string Id, string Email, string FirstName, string LastName,
    string? ProfilePictureUrl, bool IsActive, bool IsLockedOut,
    DateTimeOffset CreatedOn, IEnumerable<string> Roles);

管理员端点

管理端点整组受角色保护:

var group = app.MapGroup("/api/admin/users")
    .RequireAuthorization(policy => policy.RequireRole("Admin"));

用户列表:分页、搜索、含角色

UserManager.UsersIQueryable,所以可以搜、数、分页,再给每个用户附加角色:

group.MapGet("/", async (
    UserManager<ApplicationUser> userManager,
    int page = 1, int pageSize = 10, string? search = null) =>
{
    pageSize = pageSize is < 1 or > 100 ? 10 : pageSize;

    var query = userManager.Users.AsNoTracking();
    if (!string.IsNullOrWhiteSpace(search))
    {
        var term = search.Trim();
        query = query.Where(u =>
            u.Email!.Contains(term) ||
            u.FirstName.Contains(term) ||
            u.LastName.Contains(term));
    }

    var totalCount = await query.CountAsync();
    var users = await query
        .OrderBy(u => u.Email)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();

    var items = new List<UserResponse>();
    foreach (var user in users)
    {
        var roles = await userManager.GetRolesAsync(user);
        items.Add(user.ToResponse(roles));
    }

    return Results.Ok(new PagedResult<UserResponse>(
        items, page, pageSize, totalCount));
});

默认分页大小 10,上限 100——防止客户端一次拉整张用户表。

角色管理

两个小端点。先确认角色存在,再让 UserManager 做:

group.MapPost("/{id}/roles", async (
    string id, AssignRoleRequest request,
    UserManager<ApplicationUser> userManager,
    RoleManager<IdentityRole> roleManager) =>
{
    var user = await userManager.FindByIdAsync(id);
    if (user is null) return Results.NotFound();
    if (!await roleManager.RoleExistsAsync(request.Role))
        return Results.BadRequest(
            $"Role '{request.Role}' does not exist.");
    await userManager.AddToRoleAsync(user, request.Role);
    return Results.NoContent();
});

锁定 vs 禁用:两种不同的阻断方式

这两个概念混的人很多:

锁定(Lockout)禁用(IsActive)
是什么Identity 内置安全机制你自己加的自定义标志
触发方式登录失败过多,或管理员主动锁定管理员决定
持续时间临时(自动过期)永久,直到管理员重新开启
用途防暴力破解停用账号、离职处理

锁定SetLockoutEndDateAsync

await userManager.SetLockoutEnabledAsync(user, true);
await userManager.SetLockoutEndDateAsync(
    user, DateTimeOffset.UtcNow.AddYears(100));

禁用IsActive 字段,Identity 不认识它,你必须在登录端点自己强制执行:

var user = await userManager.FindByEmailAsync(request.Email);
if (user is null || !user.IsActive)
    return Results.Unauthorized();

自助资料端点

登录用户管理自己的账号。核心规则:用户 ID 从 JWT 中读取,不从请求体获取,确保用户只能改自己的记录:

var group = app.MapGroup("/api/me")
    .RequireAuthorization();

group.MapGet("/", async (
    ClaimsPrincipal principal,
    UserManager<ApplicationUser> userManager) =>
{
    var user = await GetCurrentUserAsync(principal, userManager);
    if (user is null) return Results.Unauthorized();
    var roles = await userManager.GetRolesAsync(user);
    return Results.Ok(user.ToResponse(roles));
});

改密码一行搞定——ChangePasswordAsync 自动验证当前密码:

var result = await userManager.ChangePasswordAsync(
    user, request.CurrentPassword, request.NewPassword);
return result.Succeeded
    ? Results.NoContent()
    : Results.ValidationProblem(result.ToProblemDictionary());

邮箱确认和密码重置

Identity 生成安全令牌;你来决定怎么投递。投递是一个缝——生产环境用 SendGrid、Amazon SES 或 SMTP 实现 IEmailSender 接口。

密码重置是两个端点。forgot-password 始终返回 204,即使邮箱不存在——防止被人用来探测注册邮箱:

var user = await userManager.FindByEmailAsync(request.Email);
if (user is not null)
{
    var token = await userManager
        .GeneratePasswordResetTokenAsync(user);
    await emailSender.SendAsync(user.Email!,
        "Reset your password",
        $"Reset with this token: {token}");
}
return Results.NoContent();

什么时候自己写 vs 用外部服务

维度MapIdentityApi自定义用户管理外部服务 (Entra/Auth0)
搭建成本最低(一行)中等(自己写端点)中高(搭/配一个服务)
自定义字段不行可以可以
管理员用户管理不行可以,完全自定义可以,在对方后台
角色管理 API不行可以可以
社交/SSO 登录不支持不支持内置
用户数据归属服务方

判断标准:你拥有用户且需要真正控制他们——自定义字段、管理员面板、角色管理——但不需要 SSO 或社交登录时,自己写。这覆盖了大多数业务 API 和 SaaS 后端。

一旦需求中出现社交登录、跨应用 SSO 或企业身份(SAML、SCIM),就用外部服务。手写联邦身份是时间黑洞。

常见坑

结语

ASP.NET Core 自定义用户管理不是一个要安装的框架——是你在 Identity 之上搭建的一层薄但整齐的代码。保留 Identity 的用户存储、密码哈希、角色和锁定,扩展 IdentityUser 加自己的字段,通过你控制的端点暴露 UserManagerRoleManager

整个东西放在一个 Minimal API 项目里。克隆、F5、顺着 requests.http 文件跑一遍就看到所有端点的运行效果。当你拥有用户且需要真正控制他们时才自己写——需要 SSO 或社交登录时才用外部服务。

参考


Tags


Previous

从零搭建你的第一个 MCP 服务端(C# 快速上手)

Next

.NET 私有 NuGet 源实战:Azure Artifacts 与 GitHub Packages