Auth 端是整个 SSO 的核心。
基本逻辑:
- 采用
HybridAndClientCredentials
方式授权 - 用户未登录时,访问任何一个 SSO 的系统,都会自动跳转到 Auth 端进行登录
- 成功登录之后,再打开其他系统,都将自动登录(用户无感知)
- 从任何一个系统中注销后,其它相关的系统也将一并注销
- 登录页面:各个系统中没有登录页面,统一跳转到 Auth 端去登录
- 注销页面:各个系统中有自己的注销页面,注销后,IdentityServer4 会自动触发 Auth 端的注销页面,在 Auth 端进行注销
建立好项目之后,先添加 Nuget 包(基于.NET Core 2.0.3):
dotnet add package IdentityServer4 --Version 2.1.0
// 也可以不安装上面那个包,直接安装这个(因为此包已包含上面的包)
// 若未安装上面的包,可能此包中包含的 IdentityServer4 不是 2.1.0
dotnet add package IdentityServer4.AspNetIdentity --version 2.0.0
在开始之前,建议将本项目的属性调整成控制台应用,这样,方便看到各种 Log,方便调试,如下图所示(注意图中红框的地方就好了):
调试时可以这样做,但是真正发布的时候,还是建议做一个 SSO 首页。
配置
appsettings.json
文件(请自行设计json
结构)
// appsettings.json
"SSO": {
"Apis": {
"Count": 1,
"Items": [
{
"name": "api_1",
"displayName": "API 1"
}
]
},
"Clients": {
"Count": 2,
"Items": [
{
"Id": "web_1",
"Name": "Portal 1",
"Secret": "c28a936b089340d9948efb788741e6e4c892ef273f9a40ed91814f3f6f76e4c0",
"Urls": "https://localhost:30001,http://localhost:30001",
"ApiNames": "api_1"
},
{
"Id": "web_2",
"Name": "Portal 2",
"Secret": "7f201132c6f74ef185b55a5f143bf1ca9bfbb22a209e456b9645966a143cfa62",
"Urls": "https://localhost:30002,http://localhost:30002",
"ApiNames": "api_1"
}
]
}
}
新建一个
Config.cs
文件
在里面设置:IdentityResource
、ApiResource
、Client
。
// SSOConfig.cs
// 本文将所有的配置文件,都写在 appsettings.json 中,也可以放在数据库或 Redis 中,总之,就是不在代码里面 hard code
public class SSOConfig
{
public static IEnumerable<IdentityResource> IdentityResources => new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile()
};
public static IEnumerable<ApiResource> GetApiResources(IConfigurationSection section)
{
var apis = new List<ApiResource>();
var prefix = "Apis:Items:{0}:";
var max = int.Parse(section["Apis:Count"]);
for (var i = 0; i < max; i++)
{
apis.Add(new ApiResource(section[string.Format(prefix, i) + "name"],
section[string.Format(prefix, i) + "displayName"]));
}
return apis;
}
public static IEnumerable<Client> GetClients(IConfigurationSection section)
{
var clients = new List<Client>();
var prefix = "Clients:Items:{0}:";
string[] arrUrl;
var scopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile
};
var max = int.Parse(section["Clients:Count"]);
for (var i = 0; i < max; i++)
{
arrUrl = section[string.Format(prefix, i) + "Urls"].Split(',');
Array.ForEach(section[string.Format(prefix, i) + "ApiNames"].Split(','), per =>
{
if (!scopes.Contains(per))
{
scopes.Add(per);
}
});
clients.Add(new Client
{
ClientId = section[string.Format(prefix, i) + "Id"],
ClientName = section[string.Format(prefix, i) + "Name"],
ClientSecrets = { new Secret(section[string.Format(prefix, i) + "Secret"].Sha256()) },
AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
RequireConsent = false,
RedirectUris = arrUrl.Select(per => per + "/signin-oidc").ToArray(),
PostLogoutRedirectUris = arrUrl.Select(per => per + "/signout-callback-oidc").ToArray(),
AllowedScopes = scopes,
// AlwaysIncludeUserClaimsInIdToken = true,
AllowOfflineAccess = true
});
}
return clients;
}
}
修改
Startup.cs
文件
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// 采用自己的用户和角色表,用 Dapper 进行访问
services.AddIdentity<MKUserInfo, MKRoleInfo>()
.AddUserStore<MKUserStore>()
.AddRoleStore<MKRoleStore>()
.AddDefaultTokenProviders();
// 很多采用的是下面这个方法,本文没有这样用
// services.AddTransient<IUserStore<MKUserInfo>, MKUserStore>();
// services.AddTransient<IRoleStore<MKRoleInfo>, MKRoleStore>();
services.AddMvc();
var section = Configuration.GetSection("SSO");
services.AddIdentityServer()
.AddDeveloperSigningCredential(filename: "tmpKey.rsa")
.AddInMemoryIdentityResources(SSOConfig.IdentityResources)
.AddInMemoryApiResources(SSOConfig.GetApiResources(section))
.AddInMemoryClients(SSOConfig.GetClients(section))
.AddAspNetIdentity<MKUserInfo>();
// IdentityServer4 默认的登录地址是:/account/login
// 如果不想使用默认的地址,可以将上面一段改为如下配置
//services.AddIdentityServer(opts =>
// {
// opts.UserInteraction = new UserInteractionOptions
// {
// LoginUrl = "你想要的地址,默认:/account/login",
// LoginReturnUrlParameter = "你想要的返回页的参数名,默认:returnUrl"
// };
// })
// .AddDeveloperSigningCredential(filename: "tmpKey.rsa")
// .AddInMemoryIdentityResources(SSOConfig.IdentityResources)
// .AddInMemoryApiResources(SSOConfig.GetApiResources(section))
// .AddInMemoryClients(SSOConfig.GetClients(section))
// .AddAspNetIdentity<MKUserInfo>();
// 此处是防止 CSRF 攻击的 Token 相关的名称(不采用默认名称)
services.AddAntiforgery(opts =>
{
opts.Cookie.Name = "_mk_x_c_token";
opts.FormFieldName = "_mk_x_f_token";
opts.HeaderName = "_mk_x_h_token";
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseBrowserLink();
}
else
{
app.UseExceptionHandler("/Error");
}
app.UseStaticFiles().UseIdentityServer();
app.UseMvc(routes =>
{
routes.MapRoute(name: "default", template: "{controller}/{action=Index}/{id?}");
});
}
上面涉及到了采用自己的用户(MKUserInfo
、MKUserStore
)和角色(MKRoleInfo
、MKRoleStore
),所以,自己实现了数据库的访问,并且没有采用 EF 之类的 ORM,直接使用 Dapper,完全自定义,只是想探寻一种可行性,毕竟,多一个选择嘛。
要实现自己的 MKUserInfo
,需继承 IdentityUser
这个类(也可继承 IdentityUser<TKey>
,不要问为什么,F12
进去看看就知道原因了)。
// MKUserInfo.cs
// 增加自定义需要的属性
public class MKUserInfo : IdentityUser<ulong>
{
public string Salt { get; set; } // 登录密码的加密参数
public string TrueName { get; set; }
public uint DeptID { get; set; }
}
添加了 MKUserInfo
之后,剩下的工作,就是访问数据库了。根据不同的条件,从数据库中获取到用户,并进行自定义登录逻辑的判断。这时,就需要自定义一个 MKUserStore
了。这个类需要继承三个接口,分别是:IUserStore<TUser>
、IUserPasswordStore<TUser>
、IUserEmailStore<TUser>
。
// MKUserStore.cs
// 这里只是做了简单处理,没有用到的方法,就没有进行实现
// 可根据相关方法自行处理,根据方法名称就能大概判断出各个方法的用途了吧
public class MKUserStore : IUserStore<MKUserInfo>, IUserPasswordStore<MKUserInfo>, IUserEmailStore<MKUserInfo>
{
public Task<IdentityResult> CreateAsync(MKUserInfo user, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IdentityResult> DeleteAsync(MKUserInfo user, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
// 这里不要 throw Exception,置空就好
public void Dispose()
{ }
public Task<MKUserInfo> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public async Task<MKUserInfo> FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
// 根据 ID 找用户
// 这里就可以进行数据库的访问了
// 本文就简单处理了,直接返回了一个用户对象
return await Task.Run(() =>
{
return new MKUserInfo
{
Id = 122333,
UserName = "admin", // 一定要给这个属性赋值,否则会报 value cannot be null
Salt = "222",
TrueName = "张三三",
PasswordHash = "111222",
DeptID = 2
};
});
}
public async Task<MKUserInfo> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
// 根据 Username 找用户
// 同样,这里也是进行数据库的访问
// 本文就简单处理了,直接返回了一个用户对象
return await Task.Run(() =>
{
return new MKUserInfo
{
Id = 122333,
UserName = "admin", // 一定要给这个属性赋值,否则会报 value cannot be null
Salt = "222",
TrueName = "张三三",
PasswordHash = "111222",
DeptID = 2
};
});
}
public async Task<string> GetEmailAsync(MKUserInfo user, CancellationToken cancellationToken = default(CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
return await Task.FromResult(user.Email);
}
public Task<bool> GetEmailConfirmedAsync(MKUserInfo user, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<string> GetNormalizedEmailAsync(MKUserInfo user, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<string> GetNormalizedUserNameAsync(MKUserInfo user, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public async Task<string> GetPasswordHashAsync(MKUserInfo user, CancellationToken cancellationToken = default(CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
return await Task.FromResult(user.PasswordHash);
}
public async Task<string> GetUserIdAsync(MKUserInfo user, CancellationToken cancellationToken = default(CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
return await Task.FromResult(user.Id.ToString());
}
public async Task<string> GetUserNameAsync(MKUserInfo user, CancellationToken cancellationToken = default(CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
return await Task.FromResult(user.UserName);
}
public Task<bool> HasPasswordAsync(MKUserInfo user, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task SetEmailAsync(MKUserInfo user, string email, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task SetEmailConfirmedAsync(MKUserInfo user, bool confirmed, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task SetNormalizedEmailAsync(MKUserInfo user, string normalizedEmail, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task SetNormalizedUserNameAsync(MKUserInfo user, string normalizedName, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task SetPasswordHashAsync(MKUserInfo user, string passwordHash, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task SetUserNameAsync(MKUserInfo user, string userName, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IdentityResult> UpdateAsync(MKUserInfo user, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
同理,MKRoleInfo
和 MKRoleStore
的实现也可参考上面的方式进行,这里就直接贴代码了。
// MKRoleInfo.cs
public class MKRoleInfo : IdentityRole<uint>
{
}
// MKRoleStore.cs
public class MKRoleStore : IRoleStore<MKRoleInfo>
{
public Task<IdentityResult> CreateAsync(MKRoleInfo role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IdentityResult> DeleteAsync(MKRoleInfo role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
// 同样,这里也不要 throw Exception,置空就好
public void Dispose()
{ }
public Task<MKRoleInfo> FindByIdAsync(string roleId, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<MKRoleInfo> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<string> GetNormalizedRoleNameAsync(MKRoleInfo role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<string> GetRoleIdAsync(MKRoleInfo role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<string> GetRoleNameAsync(MKRoleInfo role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task SetNormalizedRoleNameAsync(MKRoleInfo role, string normalizedName, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task SetRoleNameAsync(MKRoleInfo role, string roleName, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IdentityResult> UpdateAsync(MKRoleInfo role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
上面完成之后,Auth 端的工作就完成了一大半了,接下来,就是添加登录和注销的方法了。
哦,对了,关于密码验证的逻辑!!!
.NET Identity 的密码验证比较复杂,感兴趣的可以自己上 Github 去看源码,本文就简单实现了,如下所示:
// MKPassword.cs
public class MKPassword : IPasswordHasher<MKUserInfo>
{
public string HashPassword(MKUserInfo user, string password)
{
throw new NotImplementedException();
}
public PasswordVerificationResult VerifyHashedPassword(MKUserInfo user, string hashedPassword, string providedPassword)
{
// 这里用自己的逻辑进行验证即可,注意返回值就好了
// 这里就简单处理了
var newPass = providedPassword + user.Salt;
if (hashedPassword == newPass)
{
return PasswordVerificationResult.Success;
}
return PasswordVerificationResult.Failed;
}
}
由于本文采用的是 IdentityServer4
的默认路径,所以,在 Auth
项目里面,新建一个 Account
文件夹,在里面添加 Login.cshtml
和 Logout.cshtml
,如下图所示:
// Login.cshtml
<h2>Login</h2>
@if (User.Identity.IsAuthenticated)
{
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
}
else
{
<form>
<input type="text" id="txt-login-name" value="admin" />
<input type="password" id="txt-login-pass" value="111" />
<button type="submit">登录</button>
</form>
<script type="text/javascript">var _backUrl = '@Model.BackUrl';</script>
}
// Login.cshtml.cs
public class LoginModel : PageModel
{
public string BackUrl { get; set; }
public async Task<IActionResult> OnGetAsync(string returnUrl)
{
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
if (User.Identity.IsAuthenticated)
{
return Redirect(returnUrl ?? "/");
}
BackUrl = returnUrl ?? "/";
return Page();
}
}
// Logout.cshtml
<h2>Logout</h2>
// Logout.cshtml.cs
public class LogoutModel : PageModel
{
private readonly IIdentityServerInteractionService _interaction;
private readonly SignInManager<MKUserInfo> _signInMgr;
public LogoutModel(IIdentityServerInteractionService interaction, SignInManager<MKUserInfo> signInMgr)
{
_interaction = interaction;
_signInMgr = signInMgr;
}
public async Task<IActionResult> OnGetAsync(string logoutId)
{
await _signInMgr.SignOutAsync();
var logout = await _interaction.GetLogoutContextAsync(logoutId);
if (!string.IsNullOrWhiteSpace(logout?.PostLogoutRedirectUri))
{
return Redirect(logout.PostLogoutRedirectUri);
}
return Redirect("/account/login");
}
}
页面写好了之后,就是登录交互了,本文采用的是 jQuery
的 ajax
进行访问,所以,在 Auth
项目中,需要一个 Login api
进行登录操作(放心,Razor Page
可以和 MVC
混合存在,只要路径不搞乱就行),代码如下:
// LoginController.cs
[Produces("application/json")]
[Route("api/[controller]")]
public class LoginController : Controller
{
private readonly UserManager<MKUserInfo> _userMgr;
private readonly SignInManager<MKUserInfo> _signInMgr;
public LoginController(UserManager<MKUserInfo> userMgr, SignInManager<MKUserInfo> signInMgr)
{
_userMgr = userMgr;
_userMgr.PasswordHasher = new MKPassword(); // 自定义密码验证(代码见上)
_signInMgr = signInMgr;
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<MKResult> Login([FromBody]UserLoginModel model)
{
var result = await _signInMgr.PasswordSignInAsync(model.LoginName, model.LoginPass, false, false);
if (!result.Succeeded)
{
return new MKResult(400, "登录名称或密码错误");
}
return new MKResult();
}
}
参数 UserLoginModel 很简单,就两个字段,如下:
// UserLoginModel.cs
public class UserLoginModel
{
public string LoginName { get; set; }
public string LoginPass { get; set; }
}
另外就是,简单写了一个通用返回类
// MKResult.cs
public class MKResult
{
/// <summary>
/// 返回代码(默认:200 表示操作成功)
/// </summary>
public int Code { get; set; }
/// <summary>
/// 返回消息(默认:操作成功)
/// </summary>
public string Msg { get; set; }
/// <summary>
/// 构造函数(默认 Code:200)
/// </summary>
/// <param name="code">返回代码(默认:200 表示操作成功)</param>
/// <param name="msg">返回消息(默认:操作成功)</param>
public MKResult(int code = 200, string msg = "操作成功")
{
Code = code;
Msg = msg;
}
}
接下来是 jQuery 代码
// jQuery 代码
<script type="text/javascript">
// Url 处理
function escape2Html(content) {
var tags = { 'lt': '<', 'gt': '>', 'nbsp': ' ', 'amp': '&', 'quot': '"' };
return content.replace(/&(lt|gt|nbsp|amp|quot);/ig, function (all, idx) { return tags[idx]; });
};
$(function () {
// 测试 Api 访问
$('#btn-test').on('click', function () {
$.getJSON('http://localhost:30101/values', function (result) {
console.log(result);
});
});
// 提交登录
$('form').on('submit', function () {
var data = {};
data.loginName = $('#txt-login-name').val();
data.loginPass = $('#txt-login-pass').val();
$.ajax({
url: '/api/login',
type: 'post',
data: JSON.stringify(data),
contentType: 'application/json',
headers: {
'_mk_x_h_token': $('input[name="_mk_x_f_token"]').val()
},
success: function (result) {
if (result.code != 200) {
return;
}
// 登录成功后进行跳转
location.href = escape2Html(_backUrl);
}
});
return false;
});
});
</script>
至此,Auth 端的全部功能已经实现完毕了,接下就是添加 Client 端和 Api 端了。