fengling-auth-service/Controllers/AuthorizationController.cs
Sam 0c5bd5e647 feat: 添加OAuth2认证配置和实现
添加OAuth2认证相关配置文件和服务实现,包括环境变量配置、PKCE流程支持、token管理等功能。主要变更:
- 新增OAuth2配置文件
- 实现OAuth2服务层
- 更新请求拦截器支持token自动刷新
- 修改认证API和store以支持OAuth2流程
2026-02-07 17:47:11 +08:00

367 lines
18 KiB
C#

using Fengling.AuthService.Data;
using Fengling.AuthService.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;
using Fengling.AuthService.Configuration;
using Fengling.AuthService.ViewModels;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Primitives;
using Swashbuckle.AspNetCore.Annotations;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Fengling.AuthService.Controllers;
public class AuthorizationController(
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
ILogger<AuthorizationController> logger)
: Controller
{
[Authorize, FormValueRequired("submit.Accept")]
[Tags("submit.Accept")]
[HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
[SwaggerIgnore]
public async Task<IActionResult> Accept()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
// Retrieve the profile of the logged in user.
var user = await userManager.GetUserAsync(User) ??
throw new InvalidOperationException("The user details cannot be retrieved.");
if (user == null)
{
throw new InvalidOperationException("The user details cannot be retrieved.");
}
// Retrieve the application details from the database.
var application = await applicationManager.FindByClientIdAsync(request.ClientId!) ??
throw new InvalidOperationException(
"Details concerning the calling client application cannot be found.");
// Retrieve the permanent authorizations associated with the user and the calling client application.
var authorizations = await authorizationManager.FindAsync(
subject: await userManager.GetUserIdAsync(user),
client: await applicationManager.GetIdAsync(application),
status: OpenIddictConstants.Statuses.Valid,
type: OpenIddictConstants.AuthorizationTypes.Permanent,
scopes: request.GetScopes()).ToListAsync();
// Note: the same check is already made in the other action but is repeated
// here to ensure a malicious user can't abuse this POST-only endpoint and
// force it to return a valid response without the external authorization.
if (!authorizations.Any() &&
await applicationManager.HasConsentTypeAsync(application, OpenIddictConstants.ConsentTypes.External))
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
"The logged in user is not allowed to access this client application."
}!));
}
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
var principal = result.Principal!;
// Note: in this sample, the granted scopes match the requested scope
// but you may want to allow the user to uncheck specific scopes.
// For that, simply restrict the list of scopes before calling SetScopes.
principal.SetScopes(request.GetScopes());
principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());
// Automatically create a permanent authorization to avoid requiring explicit consent
// for future authorization or token requests containing the same scopes.
var authorization = authorizations.LastOrDefault();
if (authorization == null)
{
authorization = await authorizationManager.CreateAsync(
principal: principal,
subject: await userManager.GetUserIdAsync(user)!,
client: await applicationManager.GetIdAsync(application) ?? string.Empty,
type: OpenIddictConstants.AuthorizationTypes.Permanent,
scopes: principal.GetScopes());
}
principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization));
foreach (var claim in principal.Claims)
{
claim.SetDestinations(GetDestinations(claim, principal));
}
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
[Authorize, FormValueRequired("submit.Deny")]
[Tags("submit.Deny")]
[SwaggerIgnore]
[HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
// Notify OpenIddict that the authorization grant has been denied by the resource owner
// to redirect the user agent to the client application using the appropriate response_mode.
public IActionResult Deny() => Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
[Tags("Authorize")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
// If prompt=login was specified by the client application,
// immediately return the user agent to the login page.
if (request.HasPromptValue(OpenIddictConstants.PromptValues.Login))
{
// To avoid endless login -> authorization redirects, the prompt=login flag
// is removed from the authorization request payload before redirecting the user.
var prompt = string.Join(" ", request.GetPromptValues().Remove(OpenIddictConstants.PromptValues.Login));
var parameters = Request.HasFormContentType
? Request.Form.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList()
: Request.Query.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList();
parameters.Add(KeyValuePair.Create(OpenIddictConstants.Parameters.Prompt, new StringValues(prompt)));
return Challenge(
authenticationSchemes: IdentityConstants.ApplicationScheme,
properties: new AuthenticationProperties
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters)
});
}
// Retrieve the user principal stored in the authentication cookie.
// If a max_age parameter was provided, ensure that the cookie is not too old.
// If the user principal can't be extracted or the cookie is too old, redirect the user to the login page.
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
if (result is not { Succeeded: true } || (request.MaxAge != null && result.Properties?.IssuedUtc != null &&
DateTimeOffset.UtcNow - result.Properties.IssuedUtc >
TimeSpan.FromSeconds(request.MaxAge.Value)))
{
// If the client application requested promptless authentication,
// return an error indicating that the user is not logged in.
if (request.HasPromptValue(OpenIddictConstants.PromptValues.None))
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] =
OpenIddictConstants.Errors.LoginRequired,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in."
}!));
}
return Challenge(
authenticationSchemes: IdentityConstants.ApplicationScheme,
properties: new AuthenticationProperties
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList())
});
}
// Retrieve the profile of the logged in user.
var user = await userManager.GetUserAsync(result.Principal) ??
throw new InvalidOperationException("The user details cannot be retrieved.");
// Retrieve the application details from the database.
var application = await applicationManager.FindByClientIdAsync(request.ClientId!) ??
throw new InvalidOperationException(
"Details concerning the calling client application cannot be found.");
// Retrieve the permanent authorizations associated with the user and the calling client application.
var authorizations = await authorizationManager.FindAsync(
subject: await userManager.GetUserIdAsync(user),
client: (await applicationManager.GetIdAsync(application))!,
status: OpenIddictConstants.Statuses.Valid,
type: OpenIddictConstants.AuthorizationTypes.Permanent,
scopes: request.GetScopes()).ToListAsync();
switch (await applicationManager.GetConsentTypeAsync(application))
{
// If the consent is external (e.g when authorizations are granted by a sysadmin),
// immediately return an error if no authorization can be found in the database.
case OpenIddictConstants.ConsentTypes.External when !authorizations.Any():
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] =
OpenIddictConstants.Errors.ConsentRequired,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
"The logged in user is not allowed to access this client application."
}!));
// If the consent is implicit or if an authorization was found,
// return an authorization response without displaying the consent form.
case OpenIddictConstants.ConsentTypes.Implicit:
case OpenIddictConstants.ConsentTypes.External when authorizations.Any():
case OpenIddictConstants.ConsentTypes.Explicit
when authorizations.Any() && !request.HasPromptValue(OpenIddictConstants.PromptValues.Consent):
var principal = await signInManager.CreateUserPrincipalAsync(user);
// Note: in this sample, the granted scopes match the requested scope
// but you may want to allow the user to uncheck specific scopes.
// For that, simply restrict the list of scopes before calling SetScopes.
principal.SetScopes(request.GetScopes());
principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());
// Automatically create a permanent authorization to avoid requiring explicit consent
// for future authorization or token requests containing the same scopes.
var authorization = authorizations.LastOrDefault();
if (authorization == null)
{
authorization = await authorizationManager.CreateAsync(
principal: principal,
subject: await userManager.GetUserIdAsync(user),
client: (await applicationManager.GetIdAsync(application))!,
type: OpenIddictConstants.AuthorizationTypes.Permanent,
scopes: principal.GetScopes());
}
principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization));
foreach (var claim in principal.Claims)
{
claim.SetDestinations(GetDestinations(claim, principal));
}
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
// At this point, no authorization was found in the database and an error must be returned
// if the client application specified prompt=none in the authorization request.
case OpenIddictConstants.ConsentTypes.Explicit
when request.HasPromptValue(OpenIddictConstants.PromptValues.None):
case OpenIddictConstants.ConsentTypes.Systematic
when request.HasPromptValue(OpenIddictConstants.PromptValues.None):
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] =
OpenIddictConstants.Errors.ConsentRequired,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
"Interactive user consent is required."
}!));
// In every other case, render the consent form.
default:
return View(new AuthorizeViewModel(await applicationManager.GetDisplayNameAsync(application),
request.Scope));
}
}
private IEnumerable<string> GetDestinations(Claim claim, ClaimsPrincipal principal)
{
// Note: by default, claims are NOT automatically included in access and identity tokens.
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
switch (claim.Type)
{
case OpenIddictConstants.Claims.Name:
yield return OpenIddictConstants.Destinations.AccessToken;
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Profile))
yield return OpenIddictConstants.Destinations.IdentityToken;
yield break;
case OpenIddictConstants.Claims.Email:
yield return OpenIddictConstants.Destinations.AccessToken;
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Email))
yield return OpenIddictConstants.Destinations.IdentityToken;
yield break;
case OpenIddictConstants.Claims.Role:
yield return OpenIddictConstants.Destinations.AccessToken;
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Roles))
yield return OpenIddictConstants.Destinations.IdentityToken;
yield break;
// Never include the security stamp in the access and identity tokens, as it's a secret value.
case "AspNet.Identity.SecurityStamp": yield break;
default:
yield return OpenIddictConstants.Destinations.AccessToken;
yield break;
}
}
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
[HttpGet("~/connect/userinfo")]
public async Task<IActionResult> UserInfo()
{
var user = await userManager.GetUserAsync(User) ??
throw new InvalidOperationException("The user details cannot be retrieved.");
// 获取用户的角色
var roles = await userManager.GetRolesAsync(user);
// 获取用户的租户信息
var tenantInfo = user.TenantInfo;
var claims = new List<Claim>
{
new(OpenIddictConstants.Claims.Subject, await userManager.GetUserIdAsync(user)),
new(OpenIddictConstants.Claims.Name, user.UserName!),
new(OpenIddictConstants.Claims.PreferredUsername, user.UserName!)
};
if (!string.IsNullOrEmpty(user.Email))
{
claims.Add(new(OpenIddictConstants.Claims.Email, user.Email!));
}
// 添加角色 claims
foreach (var role in roles)
{
claims.Add(new(OpenIddictConstants.Claims.Role, role));
}
// 添加自定义 tenant 相关 claims
if (tenantInfo != null)
{
claims.Add(new Claim("tenant_id", tenantInfo.Id.ToString()));
claims.Add(new Claim("tenant_code", tenantInfo.TenantId));
claims.Add(new Claim("tenant_name", tenantInfo.Name));
}
return Ok(new Dictionary<string, object>
{
["sub"] = await userManager.GetUserIdAsync(user),
["name"] = user.UserName,
["preferred_username"] = user.UserName,
["email"] = user.Email ?? "",
["role"] = roles.ToArray(),
["tenant_id"] = tenantInfo != null ? tenantInfo.Id.ToString() : "",
["tenant_code"] = tenantInfo?.TenantId ?? "",
["tenant_name"] = tenantInfo?.Name ?? ""
});
}
}