diff --git a/docs/task-04-create-auth-controller.md b/docs/task-04-create-auth-controller.md new file mode 100644 index 0000000..577f894 --- /dev/null +++ b/docs/task-04-create-auth-controller.md @@ -0,0 +1,171 @@ +# Task 4: Create Auth Controller + +## Task Description + +**Files:** +- Create: `src/Fengling.AuthService/Controllers/AuthController.cs` +- Create: `src/Fengling.AuthService/DTOs/LoginRequest.cs` +- Create: `src/Fengling.AuthService/DTOs/LoginResponse.cs` +- Create: `src/Fengling.AuthService/DTOs/TokenResponse.cs` + +## Implementation Steps + +### Step 1: Create DTOs + +Create: `src/Fengling.AuthService/DTOs/LoginRequest.cs` + +```csharp +namespace Fengling.AuthService.DTOs; + +public class LoginRequest +{ + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public long TenantId { get; set; } +} +``` + +Create: `src/Fengling.AuthService/DTOs/LoginResponse.cs` + +```csharp +namespace Fengling.AuthService.DTOs; + +public class LoginResponse +{ + public string AccessToken { get; set; } = string.Empty; + public string RefreshToken { get; set; } = string.Empty; + public int ExpiresIn { get; set; } + public string TokenType { get; set; } = "Bearer"; +} +``` + +### Step 2: Create AuthController + +Create: `src/Fengling.AuthService/Controllers/AuthController.cs` + +```csharp +using Fengling.AuthService.DTOs; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly IOpenIddictScopeManager _scopeManager; + private readonly ILogger _logger; + + public AuthController( + SignInManager signInManager, + UserManager userManager, + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + ILogger logger) + { + _signInManager = signInManager; + _userManager = userManager; + _applicationManager = applicationManager; + _authorizationManager = authorizationManager; + _scopeManager = scopeManager; + _logger = logger; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + var user = await _userManager.FindByNameAsync(request.UserName); + if (user == null || user.IsDeleted) + { + return Unauthorized(new { error = "用户不存在" }); + } + + if (user.TenantId != request.TenantId) + { + return Unauthorized(new { error = "租户不匹配" }); + } + + var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false); + if (!result.Succeeded) + { + return Unauthorized(new { error = "用户名或密码错误" }); + } + + var token = await GenerateTokenAsync(user); + return Ok(token); + } + + private async Task GenerateTokenAsync(ApplicationUser user) + { + var claims = new List + { + new(Claims.Subject, user.Id.ToString()), + new(Claims.Name, user.UserName ?? string.Empty), + new(Claims.Email, user.Email ?? string.Empty), + new("tenant_id", user.TenantId.ToString()) + }; + + var roles = await _userManager.GetRolesAsync(user); + foreach (var role in roles) + { + claims.Add(new Claim(Claims.Role, role)); + } + + var identity = new System.Security.Claims.ClaimsIdentity(claims, "Server"); + var principal = new System.Security.Claims.ClaimsPrincipal(identity); + + return new LoginResponse + { + AccessToken = "token-placeholder", + RefreshToken = "refresh-placeholder", + ExpiresIn = 3600, + TokenType = "Bearer" + }; + } +} +``` + +### Step 3: Run to verify controller compilation + +Run: +```bash +dotnet build +``` +Expected: Build succeeds + +### Step 4: Commit + +```bash +git add src/Fengling.AuthService/Controllers/ src/Fengling.AuthService/DTOs/ +git commit -m "feat(auth): add authentication controller with login endpoint" +``` + +## Context + +This task creates an authentication controller with a login endpoint. The login endpoint validates user credentials, checks tenant isolation, and generates JWT tokens with embedded tenant_id claims for multi-tenant routing. + +**Tech Stack**: ASP.NET Core Controllers, OpenIddict + +## Verification + +- [ ] LoginRequest DTO created +- [ ] LoginResponse DTO created +- [ ] AuthController created with login endpoint +- [ ] Tenant validation implemented +- [ ] Build succeeds +- [ ] Committed to git + +## Notes + +- Token generation is placeholder, will be replaced by OpenIddict in next task +- Tenant validation ensures multi-tenant isolation diff --git a/src/Fengling.AuthService/Configuration/OpenIddictSetup.cs b/src/Fengling.AuthService/Configuration/OpenIddictSetup.cs index d561975..0e73445 100644 --- a/src/Fengling.AuthService/Configuration/OpenIddictSetup.cs +++ b/src/Fengling.AuthService/Configuration/OpenIddictSetup.cs @@ -6,25 +6,30 @@ namespace Fengling.AuthService.Configuration; public static class OpenIddictSetup { - public static IServiceCollection AddOpenIddictConfiguration(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddOpenIddictConfiguration( + this IServiceCollection services, + IConfiguration configuration + ) { - services.AddOpenIddict() + services + .AddOpenIddict() .AddCore(options => { - options.UseEntityFrameworkCore() - .UseDbContext(); + options.UseEntityFrameworkCore().UseDbContext(); }) .AddServer(options => { - options.SetIssuer(configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local"); + options.SetIssuer( + configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local" + ); - options.AddDevelopmentEncryptionCertificate() - .AddDevelopmentSigningCertificate(); + options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate(); - options.AllowAuthorizationCodeFlow() - .AllowPasswordFlow() - .AllowRefreshTokenFlow() - .RequireProofKeyForCodeExchange(); + options + .AllowAuthorizationCodeFlow() + .AllowPasswordFlow() + .AllowRefreshTokenFlow() + .RequireProofKeyForCodeExchange(); options.RegisterScopes("api", "offline_access"); @@ -38,8 +43,10 @@ public static class OpenIddictSetup services.AddAuthentication(options => { - options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = + OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = + OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; }); return services; diff --git a/src/Fengling.AuthService/Controllers/AuthController.cs b/src/Fengling.AuthService/Controllers/AuthController.cs new file mode 100644 index 0000000..c57d7d7 --- /dev/null +++ b/src/Fengling.AuthService/Controllers/AuthController.cs @@ -0,0 +1,90 @@ +using Fengling.AuthService.DTOs; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using System.Security.Claims; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly IOpenIddictScopeManager _scopeManager; + private readonly ILogger _logger; + + public AuthController( + SignInManager signInManager, + UserManager userManager, + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + ILogger logger) + { + _signInManager = signInManager; + _userManager = userManager; + _applicationManager = applicationManager; + _authorizationManager = authorizationManager; + _scopeManager = scopeManager; + _logger = logger; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + var user = await _userManager.FindByNameAsync(request.UserName); + if (user == null || user.IsDeleted) + { + return Unauthorized(new { error = "用户不存在" }); + } + + if (user.TenantId != request.TenantId) + { + return Unauthorized(new { error = "租户不匹配" }); + } + + var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false); + if (!result.Succeeded) + { + return Unauthorized(new { error = "用户名或密码错误" }); + } + + var token = await GenerateTokenAsync(user); + return Ok(token); + } + + private async Task GenerateTokenAsync(ApplicationUser user) + { + var claims = new List + { + new(Claims.Subject, user.Id.ToString()), + new(Claims.Name, user.UserName ?? string.Empty), + new(Claims.Email, user.Email ?? string.Empty), + new("tenant_id", user.TenantId.ToString()) + }; + + var roles = await _userManager.GetRolesAsync(user); + foreach (var role in roles) + { + claims.Add(new Claim(Claims.Role, role)); + } + + var identity = new System.Security.Claims.ClaimsIdentity(claims, "Server"); + var principal = new System.Security.Claims.ClaimsPrincipal(identity); + + return new LoginResponse + { + AccessToken = "token-placeholder", + RefreshToken = "refresh-placeholder", + ExpiresIn = 3600, + TokenType = "Bearer" + }; + } +} diff --git a/src/Fengling.AuthService/DTOs/LoginRequest.cs b/src/Fengling.AuthService/DTOs/LoginRequest.cs new file mode 100644 index 0000000..9228867 --- /dev/null +++ b/src/Fengling.AuthService/DTOs/LoginRequest.cs @@ -0,0 +1,8 @@ +namespace Fengling.AuthService.DTOs; + +public class LoginRequest +{ + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public long TenantId { get; set; } +} diff --git a/src/Fengling.AuthService/DTOs/LoginResponse.cs b/src/Fengling.AuthService/DTOs/LoginResponse.cs new file mode 100644 index 0000000..c1f95fd --- /dev/null +++ b/src/Fengling.AuthService/DTOs/LoginResponse.cs @@ -0,0 +1,9 @@ +namespace Fengling.AuthService.DTOs; + +public class LoginResponse +{ + public string AccessToken { get; set; } = string.Empty; + public string RefreshToken { get; set; } = string.Empty; + public int ExpiresIn { get; set; } + public string TokenType { get; set; } = "Bearer"; +} diff --git a/src/Fengling.AuthService/bin/Debug/net9.0/Fengling.AuthService.dll b/src/Fengling.AuthService/bin/Debug/net9.0/Fengling.AuthService.dll index fd68dd1..4c8b2de 100644 Binary files a/src/Fengling.AuthService/bin/Debug/net9.0/Fengling.AuthService.dll and b/src/Fengling.AuthService/bin/Debug/net9.0/Fengling.AuthService.dll differ diff --git a/src/Fengling.AuthService/bin/Debug/net9.0/Fengling.AuthService.pdb b/src/Fengling.AuthService/bin/Debug/net9.0/Fengling.AuthService.pdb index bbd9cb0..37e5712 100644 Binary files a/src/Fengling.AuthService/bin/Debug/net9.0/Fengling.AuthService.pdb and b/src/Fengling.AuthService/bin/Debug/net9.0/Fengling.AuthService.pdb differ diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.AssemblyInfo.cs b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.AssemblyInfo.cs index 062d115..298d967 100644 --- a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.AssemblyInfo.cs +++ b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("Fengling.AuthService")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+3deca408f1ed8079b4cde5cf165b56b46834b516")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+da56bdb4931bd9659c595c33940fbee16b9cfc0b")] [assembly: System.Reflection.AssemblyProductAttribute("Fengling.AuthService")] [assembly: System.Reflection.AssemblyTitleAttribute("Fengling.AuthService")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.AssemblyInfoInputs.cache b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.AssemblyInfoInputs.cache index ef37eb0..41a3922 100644 --- a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.AssemblyInfoInputs.cache +++ b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.AssemblyInfoInputs.cache @@ -1 +1 @@ -1eaba22fd8465b8919c1974b8c0f155a6fbe657542b3b5a45f1e8a80051af341 +50a301695b1b134726fa13b5b2c0b63ad3a060ddadee761b61c665b60a99a799 diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.csproj.CoreCompileInputs.cache b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.csproj.CoreCompileInputs.cache index 3df680a..ffb11de 100644 --- a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.csproj.CoreCompileInputs.cache +++ b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.csproj.CoreCompileInputs.cache @@ -1 +1 @@ -e996c1aab4871c6049cd0bddb827bc47b25a15c82375345bf0a4a2222dcd4579 +ce7d9782f5bb04e3f094d69c3a3348ffeac4aa4dfaee5a67e382bb10cc4bac60 diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.dll b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.dll index fd68dd1..4c8b2de 100644 Binary files a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.dll and b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.dll differ diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.pdb b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.pdb index bbd9cb0..37e5712 100644 Binary files a/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.pdb and b/src/Fengling.AuthService/obj/Debug/net9.0/Fengling.AuthService.pdb differ diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/ref/Fengling.AuthService.dll b/src/Fengling.AuthService/obj/Debug/net9.0/ref/Fengling.AuthService.dll index 87345af..6826e1d 100644 Binary files a/src/Fengling.AuthService/obj/Debug/net9.0/ref/Fengling.AuthService.dll and b/src/Fengling.AuthService/obj/Debug/net9.0/ref/Fengling.AuthService.dll differ diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/refint/Fengling.AuthService.dll b/src/Fengling.AuthService/obj/Debug/net9.0/refint/Fengling.AuthService.dll index 87345af..6826e1d 100644 Binary files a/src/Fengling.AuthService/obj/Debug/net9.0/refint/Fengling.AuthService.dll and b/src/Fengling.AuthService/obj/Debug/net9.0/refint/Fengling.AuthService.dll differ diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/rjsmcshtml.dswa.cache.json b/src/Fengling.AuthService/obj/Debug/net9.0/rjsmcshtml.dswa.cache.json index df6e410..48b5e94 100644 --- a/src/Fengling.AuthService/obj/Debug/net9.0/rjsmcshtml.dswa.cache.json +++ b/src/Fengling.AuthService/obj/Debug/net9.0/rjsmcshtml.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"kj0YdTIP9epXJ4ydBR9yaRr5OemJ36+FlRmnBdiGrUE=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["nGadCmuBEG\u002BKUP6Powa57G4ZzOO6ibT7XQKZuYm3g44=","elQhyiEcBZcCHMIxyXyx47S4otwc/MEXjAYU/dca/hQ=","QUvWOS2l6Gf\u002Bb29f7UDXsp99Km48zx\u002BXUkHxYrdP5O4=","587UMkRW9Duvi09dG2y/rsS2zVrz865mHwElGvidCDE=","BDJLn2XnsDdeDzb/\u002B28pT5PUs\u002BI3LFuQ9WGSKahX1Mo="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"kj0YdTIP9epXJ4ydBR9yaRr5OemJ36+FlRmnBdiGrUE=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["nGadCmuBEG\u002BKUP6Powa57G4ZzOO6ibT7XQKZuYm3g44=","elQhyiEcBZcCHMIxyXyx47S4otwc/MEXjAYU/dca/hQ=","QUvWOS2l6Gf\u002Bb29f7UDXsp99Km48zx\u002BXUkHxYrdP5O4=","587UMkRW9Duvi09dG2y/rsS2zVrz865mHwElGvidCDE=","bR13zEikIdOSEVKuKHZrbKmTVs\u002BR7qJh4XCzWsCB2JI="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file diff --git a/src/Fengling.AuthService/obj/Debug/net9.0/rjsmrazor.dswa.cache.json b/src/Fengling.AuthService/obj/Debug/net9.0/rjsmrazor.dswa.cache.json index 8f7e832..6fdf2d4 100644 --- a/src/Fengling.AuthService/obj/Debug/net9.0/rjsmrazor.dswa.cache.json +++ b/src/Fengling.AuthService/obj/Debug/net9.0/rjsmrazor.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"cWEb6+iVjovCYrac7gX+Ogl5Z4cMpIEURSADGbv9ou0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["nGadCmuBEG\u002BKUP6Powa57G4ZzOO6ibT7XQKZuYm3g44=","elQhyiEcBZcCHMIxyXyx47S4otwc/MEXjAYU/dca/hQ=","QUvWOS2l6Gf\u002Bb29f7UDXsp99Km48zx\u002BXUkHxYrdP5O4=","587UMkRW9Duvi09dG2y/rsS2zVrz865mHwElGvidCDE=","BDJLn2XnsDdeDzb/\u002B28pT5PUs\u002BI3LFuQ9WGSKahX1Mo="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"cWEb6+iVjovCYrac7gX+Ogl5Z4cMpIEURSADGbv9ou0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["nGadCmuBEG\u002BKUP6Powa57G4ZzOO6ibT7XQKZuYm3g44=","elQhyiEcBZcCHMIxyXyx47S4otwc/MEXjAYU/dca/hQ=","QUvWOS2l6Gf\u002Bb29f7UDXsp99Km48zx\u002BXUkHxYrdP5O4=","587UMkRW9Duvi09dG2y/rsS2zVrz865mHwElGvidCDE=","bR13zEikIdOSEVKuKHZrbKmTVs\u002BR7qJh4XCzWsCB2JI="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file