来聊点授权与认证的话题
- 2023-07-27 河北
本文字数:22986 字
阅读完需:约 75 分钟
长文警告!
本篇以接入OpenIddict为例,展开聊认证授权的一些话题,篇幅会很长。
碎语
7 月份终于要过完了,感觉这个月过的好难,难道好像每天都有干不完的活...
到月底,虽然要做的事情还有很多,回头看去,多少也有些“轻舟已过万重山”般的轻松,可以做一点自己喜欢的事了。
图片出自追光动画的电影《长安三万里》
一、前言
说起认证,授权,我们最直接能想到的一个场景就是登录模块,现在绝大部分的应用(包括但不限于网站,app,小程序等形式),都会集成一个登录模块。
当下各大语言的大部分开发框架都集成了这个模块,虽然认证授权的相关概念错综复杂,而随着各类框架的发展,开发认证模块的复杂程度已经大大降低,只需要根据其提供的接口标准,做一些定制化开发,很容易就可以开发出一个集认证,授权功能于一身的认证授权中心。
图片来源:https://blog.goodsxx.cn/assets/1682168801108-10b72855.png
那我们提到的这个标准,就是 OAuth2.0,即开放授权,它允许应用程序在不提供用户密码的情况下,安全的访问用户数据,而 OpenID Connect 是 OAuth2.0 标准之上的标识层,它拓展了 OAuth2.0。依赖 OpenID Connect,使得客户端能通过认证来识别用户,标准化了认证的方式。
本篇没有用过多篇幅来介绍概念性的知识,而这部分的资料属实有些错综复杂,一不小心就深陷其中,难以自拔,我相信大部分开发者对这块的知识点和笔者一样,属于模棱两可,似懂非懂的状态,所以我们需要读一些高质量的资料,并加以练习,来巩固我们的认知。文末我会贴出一些官方站点以及一些我个人觉得还不错的博客或者翻译文。这里主要的篇幅还是在接入 Openiddict 上。
二、OpenIddict
作为一个授权库,实际上我们有很多选择,但这些选择,要么自定义能力太差,要么就是 saas 类的产品,像 OpenIddict 这样开源免费,功能丰富的框架,就是凤毛麟角了,本来 IdentityServer4 一直是这个领域最好的选择,奈何人家收费了,最小的收费单元是 5 个客户端一年 1500 刀(2022 年以后白嫖需谨慎,软件侵权行为被坐实之后的罚款可是很高的!【价目表】在这),这里有一个图表可以形象展示市面上的一些认证授权方案。
图片来源https://andreyka26.com/assets/2023-02-19-oauth-authorization-code-using-openiddict-and-dot-net/image1.png
三、开始接入
首先打开 chatgpt/文心一言/通义千问等大模型界面,输入如何接入 openiddict 相关 prompt...额,不好意思乱入了,哈哈~~!
这里我想写的细一点,分 4 个部分来写,展示 Server 端和三种不同形式的 Client 端的接入案例,但这也只是涉及到了该授权框架的一小部分内容。
3.1、开始之前
我参考了 Openiddict 官方,也就是作者本人提供的例子,案例考虑的主要还是上手的门槛,以及规范性,所以,作者在创建项目时,集成了微软提供的身份认证模块,差不多就长这样👇
而这个模块本身是封闭性模块,作者在案例里用它是想着让开发者先不用考虑认证的部分,只需把专注力放到授权上。
那引用这个模块的结果就是,在执行数据迁移的时候,数据库里会多出一些 aspnet 开头的表
大家如果参考作者的案例时,需要注意这点。尤其如果需要应用到自己的项目里时,还是根据需要看需不需要集成微软的认证模块了,我这里没有集成。还有,案例用的 dbms 是 sqlite,orm 是 ef core,这倒是没啥可说的,根据需要更换不同的 dbms 就好,我这里本来是想用 sqlserver 的,然后这次也是试探性的用了一下 postgres sql,感觉 ef 对 pgsql 的支持度也挺好的,几乎可以平滑过渡过来。
3.2、创建一个授权中心的 Server 端项目
授权服务器将负责验证资源所有者,验证资源所有者的同意,并向客户端颁发令牌。
1)、创建项目
首先用 vs 或者命令行创建一个 asp.net core web 项目,建完成后,引入如图所示的几个包,可以直接编辑项目文件,或者通过包管理器控制台,nuget 等方式完成引入。
这里因为我要用 pgsql,所以引入了 pgsql 相关的包,大家可以根据需要选择其他 dbms。
2)、创建一些公用服务类/变量
封装一些常用的行为,在 controller 或者 razorpage 的后台服务中直接调用。
public class AuthorizationService
{
public IDictionary<string, StringValues> ParseOAuthParameters(HttpContext httpContext, List<string>? excluding = null)
{
excluding ??= new List<string>();
var parameters = httpContext.Request.HasFormContentType
? httpContext.Request.Form
.Where(v => !excluding.Contains(v.Key))
.ToDictionary(v => v.Key, v => v.Value)
: httpContext.Request.Query
.Where(v => !excluding.Contains(v.Key))
.ToDictionary(v => v.Key, v => v.Value);
return parameters;
}
//构建重定向地址
public string BuildRedirectUrl(HttpRequest request, IDictionary<string, StringValues> oAuthParameters)
{
var url = request.PathBase + request.Path + QueryString.Create(oAuthParameters);
return url;
}
//验证授权状态
public bool IsAuthenticated(AuthenticateResult authenticateResult, OpenIddictRequest request)
{
//拒绝授权
if (!authenticateResult.Succeeded)
{
return false;
}
//过期
if (request.MaxAge.HasValue && authenticateResult.Properties != null)
{
var maxAgeSeconds = TimeSpan.FromSeconds(request.MaxAge.Value);
var expired = !authenticateResult.Properties.IssuedUtc.HasValue ||
DateTimeOffset.UtcNow - authenticateResult.Properties.IssuedUtc > maxAgeSeconds;
if (expired)
{
return false;
}
}
return true;
}
//设置令牌归属
public static List<string> GetDestinations(ClaimsIdentity identity, Claim claim)
{
var destinations = new List<string>();
if (claim.Type is OpenIddictConstants.Claims.Name or OpenIddictConstants.Claims.Email)
{
destinations.Add(OpenIddictConstants.Destinations.AccessToken);
if (identity.HasScope(OpenIddictConstants.Scopes.OpenId))
{
destinations.Add(OpenIddictConstants.Destinations.IdentityToken);
}
}
return destinations;
}
}
定义一些常量
public class Consts
{
public const string Email = "email";
public const string Password = "password";
public const string ConsentNaming = "consent";
public const string GrantAccessValue = "Grant";
public const string DenyAccessValue = "Deny";
}
3)、写个登录页
这里,前台的部分我就不展示了,主要体现出登录元素来就好,看一下后台代码
public class AuthenticateModel : PageModel
{
public string Email { get; set; } = "test1";
public string Password { get; set; } = "123456";
[BindProperty]
public string? ReturnUrl { get; set; }
public string AuthStatus { get; set; } = "";
public IActionResult OnGet(string returnUrl)
{
ReturnUrl = returnUrl;
return Page();
}
public async Task<IActionResult> OnPostAsync(string email, string password)
{
using var db = new ApplicationDbContext();
if(!await db.Users.AnyAsync(u=>u.UserName==email || u.Email==email))
{
AuthStatus = "用户名不存在";
return Page();
}
var user = await db.Users.Where(u => u.UserName == email || u.Email == email).FirstOrDefaultAsync();
if(!PasswordHasher.VerifyHashedPassword(password,user.Password))
{
//实际上,这里时密码错误,但为了安全性,这里的提示不能太具体。
AuthStatus = "用户名或密码错误";
return Page();
}
var claims = new List<Claim>
{
new(ClaimTypes.Email, email),
};
var principal = new ClaimsPrincipal(
new List<ClaimsIdentity>
{
new(claims, CookieAuthenticationDefaults.AuthenticationScheme)
});
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
if (!string.IsNullOrEmpty(ReturnUrl))
{
return Redirect(ReturnUrl);
}
AuthStatus = "认证通过";
return Page();
}
}
登录认证的部分其实不在 openiddic 框架范围之内,我们需要自己来实现,这里因为是一个简单的例子,所以我就只创建了一个 user 表,来存储用户信息。
我这里登录页长这样👇
4)、写个授权页
同样这里,我也只展示后台代码,前台代码主要就是体现出允许/拒绝授权之类的元素,另外表单参数要写对,跳转到指定的地方
[Authorize]
public class Consent : PageModel
{
[BindProperty]
public string? ReturnUrl { get; set; }
public IActionResult OnGet(string returnUrl)
{
ReturnUrl = returnUrl;
return Page();
}
public async Task<IActionResult> OnPostAsync(string grant)
{
User.SetClaim(Consts.ConsentNaming, grant);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, User);
return Redirect(ReturnUrl);
}
}
主要,除登录页之外的页面,基本都需要在入口增加[Authorize]属性,来表示需要通过认证之后才可以访问。
我的授权前端页面长这样👇
5)、增加授权中心相关接口
[ApiController]
public class AuthorizationController : Controller
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IOpenIddictAuthorizationManager _authorizationManager;
private readonly IOpenIddictScopeManager _scopeManager;
private readonly AuthorizationService _authService;
public AuthorizationController(
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager,
AuthorizationService authService)
{
_applicationManager = applicationManager;
_authorizationManager = authorizationManager;
_scopeManager = scopeManager;
_authService = authService;
}
[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ??
throw new InvalidOperationException("Details concerning the calling client application cannot be found.");
if (await _applicationManager.GetConsentTypeAsync(application) != ConsentTypes.Explicit)
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidClient,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
"Only clients with explicit consent type are allowed."
}));
}
var parameters = _authService.ParseOAuthParameters(HttpContext, new List<string> { Parameters.Prompt });
var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (!_authService.IsAuthenticated(result, request))
{
return Challenge(properties: new AuthenticationProperties
{
RedirectUri = _authService.BuildRedirectUrl(HttpContext.Request, parameters)
}, new[] { CookieAuthenticationDefaults.AuthenticationScheme });
}
if (request.HasPrompt(Prompts.Login))
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Challenge(properties: new AuthenticationProperties
{
RedirectUri = _authService.BuildRedirectUrl(HttpContext.Request, parameters)
}, new[] { CookieAuthenticationDefaults.AuthenticationScheme });
}
var consentClaim = result.Principal.GetClaim(Consts.ConsentNaming);
//它的扩展方式可能是同意声明将包含允许的客户端ID列表
if (consentClaim != Consts.GrantAccessValue || request.HasPrompt(Prompts.Consent))
{
//await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var returnUrl = HttpUtility.UrlEncode(_authService.BuildRedirectUrl(HttpContext.Request, parameters));
var consentRedirectUrl = $"/Consent?returnUrl={returnUrl}";
return Redirect(consentRedirectUrl);
}
var userId = result.Principal.FindFirst(ClaimTypes.Email)!.Value;
var identity = new ClaimsIdentity(
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: Claims.Name,
roleType: Claims.Role);
identity.SetClaim(Claims.Subject, userId)
.SetClaim(Claims.Email, userId)
.SetClaim(Claims.Name, userId)
.SetClaims(Claims.Role, new List<string> { "user", "admin" }.ToImmutableArray());
identity.SetScopes(request.GetScopes());
identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());
identity.SetDestinations(c => AuthorizationService.GetDestinations(identity, c));
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
[HttpPost("~/connect/token")]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
if (!request.IsAuthorizationCodeGrantType() && !request.IsRefreshTokenGrantType())
throw new InvalidOperationException("The specified grant type is not supported.");
var result =
await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
var userId = result.Principal.GetClaim(Claims.Subject);
if (string.IsNullOrEmpty(userId))
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
"Cannot find user from the token."
}));
}
var identity = new ClaimsIdentity(result.Principal.Claims,
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: Claims.Name,
roleType: Claims.Role);
identity.SetClaim(Claims.Subject, userId)
.SetClaim(Claims.Email, userId)
.SetClaim(Claims.Name, userId)
.SetClaims(Claims.Role, new List<string> { "user", "admin" }.ToImmutableArray());
identity.SetDestinations(c => AuthorizationService.GetDestinations(identity, c));
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
[HttpGet("~/connect/userinfo"), HttpPost("~/connect/userinfo")]
public async Task<IActionResult> Userinfo()
{
var db = new ApplicationDbContext();
//if (User.GetClaim(Claims.Subject) != Consts.Email)
if (!await db.Users.AnyAsync(u => u.UserName == User.GetClaim(Claims.Subject) || u.Email == User.GetClaim(Claims.Subject)))
{
return Challenge(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidToken,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
"The specified access token is bound to an account that no longer exists."
}));
}
var user = await db.Users.Where(u => u.UserName == User.GetClaim(Claims.Subject) || u.Email == User.GetClaim(Claims.Subject)).FirstAsync();
var claims = new Dictionary<string, object>(StringComparer.Ordinal)
{
// Note: the "sub" claim is a mandatory claim and must be included in the JSON response.
//[Claims.Subject] = Consts.Email
[Claims.Subject] = user.UserName
};
if (User.HasScope(Scopes.Email))
{
//claims[Claims.Email] = Consts.Email;
claims[Claims.Email] = user.Email;
}
return Ok(claims);
}
[HttpGet("~/connect/logout")]
public IActionResult Logout() {
var parameters = _authService.ParseOAuthParameters(HttpContext);
return Redirect("/logout"+ QueryString.Create(parameters));
//return View();
}
[HttpPost("~/connect/logout")]
public async Task<IActionResult> LogoutPost()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return SignOut(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties
{
RedirectUri = "/"
});
}
}
这段代码有点长,但实际就是包含了几个流程
检查访问者是否通过身份验证,如果没有,定向去登录
通过身份认证的用户,是否被授予了访问权限,如果没有,定向去授权
认证授权都通过了,下发令牌,回到授权验证之前访问的页面
以上是 Authorize 和 Exchange 这两个 action 完成的事,
还有就是退出接口(logout,logoutpost),退出就是从 CookieAuthenticationDefaults.AuthenticationScheme(身份验证/同意)注销,然后从 OpenIddictServerAspNetCoreDefaults.AuthenticationScheme(授权)注销,其中 logout 那个是返回了一个重定向,因为我的页面没有写在 views 里,而是直接写到了 razorpages 里,注意重定向时要把参数一并转发过去,不然退出后无法回到客户端配置的重定向页面。
此外还有一个 UserInfo 接口,是我创建的一个获取基本信息的接口,比较简单不赘述。
*6)、注入种子数据
这里就是写一个服务,把一些种子数据在项目运行时提前注入到数据库里,这一步大家按需执行即可。
public class ClientsSeeder
{
private readonly IServiceProvider _serviceProvider;
public ClientsSeeder(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task AddScopes()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictScopeManager>();
var apiScope = await manager.FindByNameAsync("api1");
if (apiScope != null)
{
await manager.DeleteAsync(apiScope);
}
await manager.CreateAsync(new OpenIddictScopeDescriptor
{
DisplayName = "Api scope",
Name = "api1",
Resources =
{
"resource_server_1"
}
});
}
public async Task AddClients()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await context.Database.EnsureCreatedAsync();
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var client = await manager.FindByClientIdAsync("web-client");
if (client != null)
{
await manager.DeleteAsync(client);
}
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = "web-client",
ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654",
ConsentType = ConsentTypes.Explicit,
DisplayName = "Postman client application",
RedirectUris =
{
new Uri("https://localhost:7002/swagger/oauth2-redirect.html")
},
PostLogoutRedirectUris =
{
new Uri("https://localhost:7002/resources")
},
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Logout,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles,
$"{Permissions.Prefixes.Scope}api1"
},
//Requirements =
//{
// Requirements.Features.ProofKeyForCodeExchange
//}
});
}
public async Task AddOidcDebuggerClient()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await context.Database.EnsureCreatedAsync();
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var client = await manager.FindByClientIdAsync("oidc-debugger");
if (client != null)
{
await manager.DeleteAsync(client);
}
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = "oidc-debugger",
ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654",
ConsentType = ConsentTypes.Explicit,
DisplayName = "Postman client application",
RedirectUris =
{
new Uri("https://oidcdebugger.com/debug")
},
PostLogoutRedirectUris =
{
new Uri("https://oauth.pstmn.io/v1/callback")
},
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Logout,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles,
$"{Permissions.Prefixes.Scope}api1"
},
//Requirements =
//{
// Requirements.Features.ProofKeyForCodeExchange
//}
});
}
public async Task AddMvcClient()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await context.Database.EnsureCreatedAsync();
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var client = await manager.FindByClientIdAsync("mvc");
if (client != null)
{
await manager.DeleteAsync(client);
}
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = "mvc",
ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654",
ConsentType = ConsentTypes.Explicit,
DisplayName = "mvc client application",
RedirectUris =
{
new Uri("https://localhost:7003/callback/login/local")
},
PostLogoutRedirectUris =
{
new Uri("https://localhost:7003/callback/logout/local")
},
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Logout,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles,
//$"{Permissions.Prefixes.Scope}api1"
},
Requirements =
{
Requirements.Features.ProofKeyForCodeExchange
}
});
}
public async Task AddInitUsers()
{
var db = new ApplicationDbContext();
if(db.Users.Count() == 0)
{
await db.Users.AddAsync(new Models.User()
{
UserName = "test1",
Password = PasswordHasher.HashPassword("123456"),
Email="wtlemon@126.com",
Mobile = "110",
Remark="初始化测试账号",
//CreatedAt =
});
await db.Users.AddAsync(new Models.User()
{
UserName = "test2",
Password = PasswordHasher.HashPassword("123456"),
Email = "wtlemon@126.com",
Mobile = "110",
Remark = "初始化测试账号"
});
await db.SaveChangesAsync();
}
}
}
我分别准备了接下来要介绍的 3 个客户端的种子数据,以及自己创建的 User 表里的种子数据,注意,由于 openiddict 本身了 4 个关键表,即便不执行数据迁移,在运行项目时,也会自动创建;而我们自己定义的模型,则需要执行迁移命令后,才会同步到库里,所以需要修改默认的数据上下文件(ApplicationDbContext.cs)。
public class ApplicationDbContext : DbContext
{
//新增的模型
public DbSet<User> Users { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{
}
public ApplicationDbContext() { }
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseNpgsql("Host=10.185.1.179;Port=5432;Database=Magic.Auth;Username=root;Password=root");
}
7)、配置服务,注入中间件
我这里用的是.net6 的最小模型框架,没有 startup 文件,但为了保持简洁的同时,也保持代码的清晰,我还是把配置服务和注册中间件的步骤分开到了不同的文件,然后到入口文件引入
public static class RegisterServices
{
public static WebApplicationBuilder SetupServices(this WebApplicationBuilder builder)
{
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
options.UseOpenIddict();
});
builder.Services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<ApplicationDbContext>();
})
.AddServer(options =>
{
options.SetAuthorizationEndpointUris("connect/authorize")
.SetLogoutEndpointUris("connect/logout")
.SetTokenEndpointUris("connect/token")
.SetUserinfoEndpointUris("connect/userinfo");
options.RegisterScopes(Scopes.Email, Scopes.Profile, Scopes.Roles);
options.AllowAuthorizationCodeFlow();
options.AddEncryptionKey(new SymmetricSecurityKey(
Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableLogoutEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableUserinfoEndpointPassthrough()
.EnableStatusCodePagesIntegration();
});
builder.Services.AddTransient<AuthorizationService>();
builder.Services.AddControllers();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
//builder.Services();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(c =>
{
c.LoginPath = "/Authenticate";
});
builder.Services.AddTransient<ClientsSeeder>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("https://localhost:7002", "https://localhost:7003")
.AllowAnyHeader();
});
});
return builder;
}
}
public static class RegisterMiddlewares
{
public static WebApplication SetupMiddlewares(this WebApplication app)
{
using (var scope = app.Services.CreateScope())
{
var seeder = scope.ServiceProvider.GetRequiredService<ClientsSeeder>();
seeder.AddOidcDebuggerClient().GetAwaiter().GetResult();
seeder.AddClients().GetAwaiter().GetResult();
seeder.AddScopes().GetAwaiter().GetResult();
seeder.AddInitUsers().GetAwaiter().GetResult();
seeder.AddMvcClient().GetAwaiter().GetResult();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseStaticFiles();
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapRazorPages();
return app;
}
}
using Magic.Oidc.AuthorizatonServer.Extensions;
var builder = WebApplication.CreateBuilder(args).SetupServices();
var app = builder.Build().SetupMiddlewares();
app.Run();
这里我为 openiddict 添加了 DbContext,并注册了所有 openiddict 服务,添加了默认作用域(scope),仅允许授权码形式授权(Oauth2 里最安全的授权方式),添加了用于令牌签名的对称密钥(这是在资源服务器端验证已签名令牌的方法之一) 除此之外,我们还注册了 AuthorizationService 和 ClientsSeeder。 我们添加了 Cookie 身份验证,我们的登录路径指向上面描述的 Authenticate Razor 页面。 添加了 Cors 以允许客户端访问端点调用令牌端点。
到此,服务端的代码基本完成,然后就是执行迁移命令,查看一下库表结构。
add-migration CreateUserModel
update-database
执行完成后,库里会有这几张表👇
到这里只有一个服务端还独木难支,需要继续接入客户端来完成整个认证授权链条。
2、创建一个客户端(mvc)项目
客户端的部分我主要参考的是 openiddict 的官方案例,只是由于我这边的服务端和官方案例有些差异,所以在对接时候也有一些细微差异
1)、创建一个 mvc 项目
和服务端一样,创建完项目后,引入以下包文件
*2)、创建一个 worker 服务
这个服务主要是把授权的记录存储到数据库中,从生产角度上来说,还是有必要的,但开发阶段入宫我们觉得麻烦,可以不要这个服务
public class Worker : IHostedService
{
private readonly IServiceProvider _serviceProvider;
public Worker(IServiceProvider serviceProvider)
=> _serviceProvider = serviceProvider;
public async Task StartAsync(CancellationToken cancellationToken)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await context.Database.EnsureCreatedAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
3)、写一个认证接口
public class AuthenticationController : Controller
{
[HttpGet("~/login")]
public ActionResult LogIn(string returnUrl)
{
var properties = new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictClientAspNetCoreConstants.Properties.Issuer] = "https://localhost:7000/"
})
{
RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/"
};
return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
}
[HttpPost("~/logout"), ValidateAntiForgeryToken]
public async Task<ActionResult> LogOut(string returnUrl)
{
var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (result is not { Succeeded: true })
{
return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/");
}
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var properties = new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictClientAspNetCoreConstants.Properties.Issuer] = "https://localhost:7000/",
[OpenIddictClientAspNetCoreConstants.Properties.IdentityTokenHint] =
result.Properties.GetTokenValue(OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken)
})
{
RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/"
};
return SignOut(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
}
[HttpGet("~/callback/login/{provider}"), HttpPost("~/callback/login/{provider}"), IgnoreAntiforgeryToken]
public async Task<ActionResult> LogInCallback()
{
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
if (result.Principal.Identity is not ClaimsIdentity { IsAuthenticated: true })
{
throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
}
// 基于外部声明构建身份,并将用于创建身份验证Cookie。
//
// 默认情况下,在授权舞蹈过程中提取的所有声明都可用。
// 存储在Cookie中的索赔集合可以根据索赔名称或其颁发者被筛选出来或映射到不同的名称。
var claims = new List<Claim>(result.Principal.Claims
.Select(claim => claim switch
{
// 将标准的“Sub”和自定义的“id”声明映射到ClaimTypes.NameIdentifier,
// 这是.NET使用的默认声明类型,也是防伪组件所必需的。
{ Type: Claims.Subject }
=> new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer),
// 将标准的name声明映射到ClaimTypes.Name。
{ Type: Claims.Name }
=> new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer),
_ => claim
})
.Where(claim => claim switch
{
// 保留应用程序正常工作所必需的基本声明。
{ Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true,
// 不要保留其他声明。
_ => false
}));
var identity = new ClaimsIdentity(claims,
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);
// 根据质询触发时添加的属性构建身份验证属性
var properties = new AuthenticationProperties(result.Properties.Items);
// 如果需要,授权服务器返回的令牌可以存储在鉴权cookie中。
// 为了减轻Cookie的重量,在创建Cookie之前会过滤掉不使用的Token。
properties.StoreTokens(result.Properties.GetTokens().Where(token => token switch
{
// 保留令牌响应中返回的访问、身份和刷新令牌(如果可用)。
{
Name: OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or
OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken or
OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken
} => true,
// 忽略其他令牌。
_ => false
}));
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), properties);
return Redirect(properties.RedirectUri);
}
[HttpGet("~/callback/logout/{provider}"), HttpPost("~/callback/logout/{provider}"), IgnoreAntiforgeryToken]
public async Task<ActionResult> LogOutCallback()
{
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
return Redirect(result!.Properties!.RedirectUri);
}
}
4)、写一个客户端认证视图(Views 里的 home/index)
@using System.Security.Claims
@model string
<div class="jumbotron">
@if (User?.Identity is { IsAuthenticated: true })
{
<h1>Welcome, @User.Identity.Name</h1>
<p>
@foreach (var claim in Context.User.Claims)
{
<div>@claim.Type: <b>@claim.Value</b></div>
}
</p>
if (!string.IsNullOrEmpty(Model))
{
<h3>Message received from the resource controller: @Model</h3>
}
<form asp-action="Index" asp-controller="Home" method="post">
<button class="btn btn-lg btn-warning" type="submit">戳一下,验证认证授权状态</button>
</form>
<form asp-action="Logout" asp-controller="Authentication" method="post">
<button class="btn btn-lg btn-danger" type="submit">退出</button>
</form>
}
else
{
<h1>Welcome, anonymous</h1>
<a class="btn btn-lg btn-success" asp-controller="Authentication"
asp-action="Login">通过oidc登录</a>
}
</div>
5)、配置服务,注入中间件
public static class RegisterServices
{
public static WebApplicationBuilder SetupServices(this WebApplicationBuilder builder)
{
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
options.UseOpenIddict();
});
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.ExpireTimeSpan = TimeSpan.FromMinutes(50);
options.SlidingExpiration = false;
});
builder.Services.AddQuartz(options =>
{
options.UseMicrosoftDependencyInjectionJobFactory();
options.UseSimpleTypeLoader();
options.UseInMemoryStore();
});
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
builder.Services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<ApplicationDbContext>();
options.UseQuartz();
})
.AddClient(options =>
{
options.AllowAuthorizationCodeFlow();
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
options.UseAspNetCore()
.EnableStatusCodePagesIntegration()
.EnableRedirectionEndpointPassthrough()
.EnablePostLogoutRedirectionEndpointPassthrough();
// Register the System.Net.Http integration and use the identity of the current
// assembly as a more specific user agent, which can be useful when dealing with
// providers that use the user agent as a way to throttle requests (e.g Reddit).
options.UseSystemNetHttp()
.SetProductInformation(typeof(Program).Assembly);
// Add a client registration matching the client application definition in the server project.
options.AddRegistration(new OpenIddictClientRegistration
{
Issuer = new Uri("https://localhost:7000/", UriKind.Absolute),
ClientId = "mvc",
ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654",
//Scopes = { Scopes.Email, Scopes.Profile,Scopes.Roles },
Scopes = { Scopes.Email, Scopes.Profile },
RedirectUri = new Uri("callback/login/local", UriKind.Relative),
PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative)
});
});
builder.Services.AddHttpClient();
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();
// Register the worker responsible for creating the database used to store tokens.
// Note: in a real world application, this step should be part of a setup script.
builder.Services.AddHostedService<Worker>();
return builder;
}
}
public static WebApplication SetupMiddlewares(this WebApplication app)
{
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapRazorPages();
return app;
}
using Magic.Oidc.MvcClient.Extensions;
var builder = WebApplication.CreateBuilder(args).SetupServices();
var app = builder.Build().SetupMiddlewares();
app.Run();
6)、牛刀小试
到此,我们已经可以进行服务端和客户端的相关验证操作了
流程如下
用户访问客户端👇
认证登录👇
确认授权👇
返回验证👇
退出👇
3、创建一个客户端接口(前后分离)项目
1)、创建项目
添加如下引用
2)、随便写个接口
[ApiController]
[Route("resources")]
public class ResourceController : Controller
{
[Authorize]
[HttpGet]
public IActionResult GetSecretResources()
{
var user = HttpContext.User?.Identity?.Name;
return Ok($"user: {user}");
}
}
注意接口上的属性要标注[Authorize]属性
3)、配置服务和中间件
public static WebApplicationBuilder SetupServices(this WebApplicationBuilder builder)
{
builder.Services.AddControllers();
builder.Services.AddOpenIddict()
.AddValidation(options =>
{
options.SetIssuer("https://localhost:7000/");
options.AddAudiences("resource_server_1");
options.AddEncryptionKey(new SymmetricSecurityKey(
Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));
options.UseSystemNetHttp();
options.UseAspNetCore();
});
builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
builder.Services.AddAuthorization();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new Uri("https://localhost:7000/connect/authorize"),
TokenUrl = new Uri("https://localhost:7000/connect/token"),
Scopes = new Dictionary<string, string>
{
{ "api1", "resource server scope" }
}
},
}
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" }
},
Array.Empty<string>()
}
});
});
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("https://localhost:7000", "https://localhost:7003")
.AllowAnyHeader();
});
});
return builder;
}
}
public static class RegisterMiddlewares
{
public static WebApplication SetupMiddlewares(this WebApplication app)
{
app.UseStaticFiles();
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.OAuthClientId("web-client");
c.OAuthClientSecret("901564A5-E7FE-42CB-B10D-61EF6A8F3654");
});
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
return app;
}
}
using Magic.Oidc.ResourceServer.Extensions;
var builder = WebApplication.CreateBuilder(args).SetupServices();
var app = builder.Build().SetupMiddlewares();
app.Run();
4)、牛刀小试
因为我们没有写和数据库交互的接口,只是用于演示,所以案例比较简单,主要工作都在配置上。运行之后是这样
接口 swagger 界面👇
通过授权之前,访问返回 401👇
通过授权之后,正常返回数据
*4、通过 oidcdebuger 和 postman 来调试
因为我们在服务端部分,配置了 oidcdebuger 相关的信息,所以,我们的授权中心,现在也支持通过 oidcdebuger 和 postman 来调试授权流程
1)、打开 oidcdebuger
填入我们在服务端配置的信息
2)、获取交换的 code
上面配置好点击确定后,如果没有出错,会跳转到新页面,看到用于授权交互的 code
服务端的控制台也会输出相关信息
3)、postman
配置一个 post 请求
4)、获取 token
5)、验证 token(jwt)
打开https://jwt.io ,复制上面得到的 id_token 到页面
呼,总算写完了,第一次写这么长的博客了,我去喘口气~
相关引用
一些源头网站。
Oath2.0:https://oauth.net/2/
OpenID Connect:https://openid.net/developers/how-connect-works/
IdentityServer4:https://identityserver4docs.readthedocs.io/zh_CN/latest/index.html (已过时且停止服务,开源免费,当前国内大部分应用使用该框架的,用的都是这个版本)
IdentityServer:https://docs.duendesoftware.com/identityserver/v6(收费,价目表)
OpenIddict:https://documentation.openiddict.com/guides/getting-started.html (本篇主角)
这些概念性的文章都太干了,非常枯燥,建议大家自己去搜一些适合自己阅读风格的文章,博客或者视频来看,我这里也分享几篇。
微软身份平台和 OAuth2.0 授权代码流:https://learn.microsoft.com/zh-cn/azure/active-directory/develop/v2-oauth2-auth-code-flow
OpenIddict 作者提供的实例代码:https://github.com/openiddict/openiddict-samples
国内作者的翻译博客:https://blog.goodsxx.cn/articles/distributed-middleware/authorization_authentication/
国外作者博客:https://andreyka26.com/oauth-authorization-code-using-openiddict-and-dot-net
国外作者博客:https://andreyka26.com/openid-connect-authorization-code-using-openiddict-and-dot-net
国外作者博客:https://andreyka26.com/postgres-with-docker-local-development
OIDC 调试站点:https://oidcdebugger.com/
微信网页授权:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html
微信小程序登录:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
是的,最后两篇微信提供的登录授权相关的开发文档,也很有参考价值,它是符合 OAuth2.0 标准的,采用的也是更加安全的授权码流程,类似的开发文档还有支付宝等我们常见的一些第三方登录平台。
就这些吧,英文站点居多,主要是就目前来说,国外的一些文章质量还是要更优秀一些。
好了,就分享到这里吧。
版权声明: 本文为 InfoQ 作者【为自己带盐】的原创文章。
原文链接:【http://xie.infoq.cn/article/7f7dda6ba1d375afd39506d9e】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
为自己带盐
学着码代码,学着码人生。 2019-04-11 加入
努力狂奔的小码农
评论