feat(auth): add authentication controller with login endpoint
This commit is contained in:
parent
da56bdb493
commit
22ab0214e6
171
docs/task-04-create-auth-controller.md
Normal file
171
docs/task-04-create-auth-controller.md
Normal file
@ -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<ApplicationUser> _signInManager;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||
private readonly IOpenIddictAuthorizationManager _authorizationManager;
|
||||
private readonly IOpenIddictScopeManager _scopeManager;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
IOpenIddictAuthorizationManager authorizationManager,
|
||||
IOpenIddictScopeManager scopeManager,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_signInManager = signInManager;
|
||||
_userManager = userManager;
|
||||
_applicationManager = applicationManager;
|
||||
_authorizationManager = authorizationManager;
|
||||
_scopeManager = scopeManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> 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<LoginResponse> GenerateTokenAsync(ApplicationUser user)
|
||||
{
|
||||
var claims = new List<System.Security.Claims.Claim>
|
||||
{
|
||||
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
|
||||
@ -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<Data.ApplicationDbContext>();
|
||||
options.UseEntityFrameworkCore().UseDbContext<Data.ApplicationDbContext>();
|
||||
})
|
||||
.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;
|
||||
|
||||
90
src/Fengling.AuthService/Controllers/AuthController.cs
Normal file
90
src/Fengling.AuthService/Controllers/AuthController.cs
Normal file
@ -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<ApplicationUser> _signInManager;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||
private readonly IOpenIddictAuthorizationManager _authorizationManager;
|
||||
private readonly IOpenIddictScopeManager _scopeManager;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
IOpenIddictAuthorizationManager authorizationManager,
|
||||
IOpenIddictScopeManager scopeManager,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_signInManager = signInManager;
|
||||
_userManager = userManager;
|
||||
_applicationManager = applicationManager;
|
||||
_authorizationManager = authorizationManager;
|
||||
_scopeManager = scopeManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> 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<LoginResponse> GenerateTokenAsync(ApplicationUser user)
|
||||
{
|
||||
var claims = new List<System.Security.Claims.Claim>
|
||||
{
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
8
src/Fengling.AuthService/DTOs/LoginRequest.cs
Normal file
8
src/Fengling.AuthService/DTOs/LoginRequest.cs
Normal file
@ -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; }
|
||||
}
|
||||
9
src/Fengling.AuthService/DTOs/LoginResponse.cs
Normal file
9
src/Fengling.AuthService/DTOs/LoginResponse.cs
Normal file
@ -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";
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -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")]
|
||||
|
||||
@ -1 +1 @@
|
||||
1eaba22fd8465b8919c1974b8c0f155a6fbe657542b3b5a45f1e8a80051af341
|
||||
50a301695b1b134726fa13b5b2c0b63ad3a060ddadee761b61c665b60a99a799
|
||||
|
||||
@ -1 +1 @@
|
||||
e996c1aab4871c6049cd0bddb827bc47b25a15c82375345bf0a4a2222dcd4579
|
||||
ce7d9782f5bb04e3f094d69c3a3348ffeac4aa4dfaee5a67e382bb10cc4bac60
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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":{}}
|
||||
{"GlobalPropertiesHash":"kj0YdTIP9epXJ4ydBR9yaRr5OemJ36+FlRmnBdiGrUE=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["nGadCmuBEG\u002BKUP6Powa57G4ZzOO6ibT7XQKZuYm3g44=","elQhyiEcBZcCHMIxyXyx47S4otwc/MEXjAYU/dca/hQ=","QUvWOS2l6Gf\u002Bb29f7UDXsp99Km48zx\u002BXUkHxYrdP5O4=","587UMkRW9Duvi09dG2y/rsS2zVrz865mHwElGvidCDE=","bR13zEikIdOSEVKuKHZrbKmTVs\u002BR7qJh4XCzWsCB2JI="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
@ -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":{}}
|
||||
{"GlobalPropertiesHash":"cWEb6+iVjovCYrac7gX+Ogl5Z4cMpIEURSADGbv9ou0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["nGadCmuBEG\u002BKUP6Powa57G4ZzOO6ibT7XQKZuYm3g44=","elQhyiEcBZcCHMIxyXyx47S4otwc/MEXjAYU/dca/hQ=","QUvWOS2l6Gf\u002Bb29f7UDXsp99Km48zx\u002BXUkHxYrdP5O4=","587UMkRW9Duvi09dG2y/rsS2zVrz865mHwElGvidCDE=","bR13zEikIdOSEVKuKHZrbKmTVs\u002BR7qJh4XCzWsCB2JI="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
Loading…
Reference in New Issue
Block a user