Asp.net core Identity + identity server + angular 学习笔记 (第二篇)
先纠正一下第一篇的的错误.
在 Login.cshtml 和 Login.cshtml.cs 里, 本来应该是 Register 我却写成 Login .
cshtml 修改部分
<form asp-page="Login" asp-page-handler="Register"> <input type="text" name="username" placeholder="username"> <input type="password" name="password" placeholder="password"> <button type="submit">Login</button> </form>
cshtml.cs 修改部分
public class RegisterInputModel { public string username { get; set; } public string password { get; set; } } [BindProperty] public RegisterInputModel RegisterData { get; set; } public async Task OnPostRegisterAsync( [FromServices] UserManager<IdentityUser> userManager ) { var user = new IdentityUser { UserName = RegisterData.username }; var reuslt = await userManager.CreateAsync(user, RegisterData.password); if (reuslt.Succeeded) { } }
继续上路.
上回说到 register 完成了, 一般上情况下,我们可以直接使用 SignInManager 让用户注册完成自动登入.
但我想来点麻烦的, 我们不直接给用户登入. 而是要求用户先 confirm email.
之前的 register 没有要求 username 必须是 email, 我们现在加上这个要求. (model 验证我就不写了, 读者自己要懂啊)
首先我们先在 startup.cs service 里添加 options
services.Configure<IdentityOptions>(options => { options.Password.RequireDigit = false; options.Password.RequireLowercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.Password.RequiredLength = 0; options.Password.RequiredUniqueChars = 0; options.User.AllowedUserNameCharacters = null; // 默认是 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; null 表示啥都行 options.User.RequireUniqueEmail = false; options.SignIn.RequireConfirmedEmail = true; // 加这个 options.SignIn.RequireConfirmedPhoneNumber = false;
});
一旦设置了 RequireConfirmedEmail, 如果 user 没有 confirm 过, 那么 sign in 的时候就会 error
RequireConfirmedPhoneNumber 和 email 同样原理
有一种情况我们可以考虑进去,就是当 phone = null or "" 的时候, 我们可以直接设置 phone confirm 为 true.
当以后 phone 有值了在设置 phone confirm 为 false 然后发出 token 验证. 这些就看项目需求做变化了.
那我这里给的项目需求是 email 要 confirm, phone 不管.
首先在 register 的时候填入 email
然后 generate token
然后一个 link url
然后发 confirmation email 给用户
public async Task OnPostRegisterAsync( [FromServices] UserManager<IdentityUser> userManager ) { var user = new IdentityUser { UserName = RegisterData.username, Email = RegisterData.username // 加入这个 }; var reuslt = await userManager.CreateAsync(user, RegisterData.password); if (reuslt.Succeeded) { var token = await userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = Url.Page( "ConfirmEmail", pageHandler: null, values: new { userId = user.Id, token }, protocol: Request.Scheme ); var subject = "Confirm your email"; var htmlMessage = $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>."; var sendTo = RegisterData.username; // EmailService.send(sendTo, subject, htmlMessage); } }
email service 我就不写具体实现了. 如果你不熟悉可以看看这个 https://www.cnblogs.com/keatkeat/p/7576748.html
token 是一个很长很长的字符串, 1 小时过期, 基本上是不可能暴力破解的.
现在呢,我们去写一个 confirm email razor page 来接收吧.
public class ConfirmEmailModel : PageModel { public async Task OnGetAsync( string token, string userId, [FromServices] UserManager<IdentityUser> userManager ) { var user = await userManager.FindByIdAsync(userId); var result = await userManager.ConfirmEmailAsync(user, token); if (result.Succeeded) { } } }
我们直接让 register 跳转到 confirm 页面
public async Task<IActionResult> OnPostRegisterAsync( [FromServices] UserManager<IdentityUser> userManager ) { var user = new IdentityUser { UserName = RegisterData.username, Email = RegisterData.username // 加入这个 }; var reuslt = await userManager.CreateAsync(user, RegisterData.password); if (reuslt.Succeeded) { var token = await userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = Url.Page( "ConfirmEmail", pageHandler: null, values: new { userId = user.Id, token }, protocol: Request.Scheme ); var subject = "Confirm your email"; var htmlMessage = $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>."; var sendTo = RegisterData.username; // EmailService.send(sendTo, subject, htmlMessage); return RedirectToPage("ConfirmEmail", new { userId = user.Id, token }); } return Page(); }
这样 confirm email 就搞定了。
当然真实情况下并不会那么顺利. 我们来找点麻烦.
1.用户不 confirm 但强行登入怎么办 ?
当然是挡丫. identity 有替我们照顾这一点, 上面有提到 signIn 的时候会有 error, 我们来试试吧.
添加一个 login form 在 Login.cshtml
Login form <form asp-page="Login" asp-page-handler="Login"> <input type="text" name="username" placeholder="username"> <input type="password" name="password" placeholder="password"> <button type="submit">Login</button> </form>
添加一个 handler 在 Login.cshtml.cs
public class LoginInputModel { public string username { get; set; } public string password { get; set; } } [BindProperty] public LoginInputModel LoginData { get; set; } public async Task OnPostLoginAsync( [FromServices] UserManager<IdentityUser> userManager, [FromServices] SignInManager<IdentityUser> signInManager ) { var result = await signInManager.PasswordSignInAsync(LoginData.username, LoginData.password, lockoutOnFailure: true, isPersistent: true); if (result.IsNotAllowed) { // 报错了, 因为 email 或 phone 还没有 confirm } }
这里有一点要留意, 就是 SignInManager, 这个必须在 startup.cs 里 service 添加
services.AddIdentityCore<IdentityUser>(o => { o.Stores.MaxLengthForKeys = 128; }) .AddDefaultTokenProviders() .AddEntityFrameworkStores<ApplicationDbContext>() .AddSignInManager(); // 加这个 //.AddPasswordValidator<MyPasswordValidator>() //.AddUserValidator<MyUserValidator>();
如果使用 AddDefaultIdentity 它是会在 AddDefaultUI 时帮我们添加 AddSignInManager, 但第一篇时我们移除了 AddDefaultUI 所以必须自己添加了。
2.用户收不到 email 怎么办 ?
再发丫...哈哈哈
那我们继续来看看 phone confirm 是怎样做的.
和 email 区别挺多的, 首先是它并没有 GeneratePhoneNumberConfirmationTokenAsync 方法, 也没有 ConfirmPhoneNumberAsync 方法.
取而代之的是 GenerateChangePhoneNumberTokenAsync 和 ChangePhoneNumberAsync 方法
要知道 email 也是有这 2 个方法的哦 GenerateChangeEmailTokenAsync 和 ChangeEmailAsync.
奇葩吧...
还有一个区别就是 token, email 的 token 是长字符串.
phone token 自然就无法用长的啦,因为用户是看着手机输入,而不像 email 是通过一个 url 链接.
phone token 只有 6 个号码.
这就诞生了一个危险. 就是暴力破解啦.
先来段代码看看
添加一个 phone input
Register form <form asp-page="Login" asp-page-handler="Register"> <input type="text" name="username" placeholder="username"> <input type="text" name="phone" placeholder="phone"> <input type="password" name="password" placeholder="password"> <button type="submit">Login</button> </form>
.cs
public async Task<IActionResult> OnPostRegisterAsync( [FromServices] UserManager<IdentityUser> userManager ) { var user = new IdentityUser { UserName = RegisterData.username, Email = RegisterData.username, PhoneNumber = RegisterData.phone // 加入这个 }; var reuslt = await userManager.CreateAsync(user, RegisterData.password); if (reuslt.Succeeded) { var token = await userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = Url.Page( "ConfirmEmail", pageHandler: null, values: new { userId = user.Id, token }, protocol: Request.Scheme ); var subject = "Confirm your email"; var message = $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>."; var sendTo = RegisterData.username; // EmailService.send(sendTo, subject, message); // return RedirectToPage("ConfirmEmail", new { userId = user.Id, token }); var phoneToken = await userManager.GenerateChangePhoneNumberTokenAsync(user, user.PhoneNumber); subject = "Confirm your account"; sendTo = RegisterData.phone; message = $"please confirm your account by enter numbers : {phoneToken}"; // SMSService.send(sendTo, subject, message); for (var i = Convert.ToInt32(phoneToken) - 3000; i < 999999; i++) // 尝试 3000 次就好了 { var phoneResult = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber, i.ToString()); if (phoneResult.Succeeded) { //暴力破解成功 } } } return Page(); }
看的出来, identity 并没有帮我们保护暴力破解. 我们先不要管它, 因为 SignIn 的时候也有暴力破解的问题, identity 是否有保护那边呢 ?
如果你眼睛利,刚才应该已经看见了
var result = await signInManager.PasswordSignInAsync(LoginData.username, LoginData.password, lockoutOnFailure: true, isPersistent: true);
这里有个叫 lockoutOnFailure 的东西.
这个就是防止暴力破解的冬冬啦。 identity 的做法是这样的, 用户登入密码错误, 记入错误的次数在数据库里, 用户继续试错... 一旦到了试错次数上限, 账户被锁上一个时辰.
在这个时辰内,不管密码对还是不对,一律返回账号被封锁信息。 这就是它防止暴力破解的方式了.
在 startup.cs 的 service 里加入配置
services.Configure<IdentityOptions>(options => { options.Password.RequireDigit = false; options.Password.RequireLowercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.Password.RequiredLength = 0; options.Password.RequiredUniqueChars = 0; options.User.AllowedUserNameCharacters = null; // 默认是 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; null 表示啥都行 options.User.RequireUniqueEmail = false; options.SignIn.RequireConfirmedEmail = true; options.SignIn.RequireConfirmedPhoneNumber = false; // 加入这些 options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromSeconds(20); // 锁 20 秒 options.Lockout.MaxFailedAccessAttempts = 2; // 错误次数上限 = 2 次 options.Lockout.AllowedForNewUsers = true; // 每一个新账户都开启这个功能 });
被锁后会返回 error
var result = await signInManager.PasswordSignInAsync(LoginData.username, LoginData.password, lockoutOnFailure: true, isPersistent: true); if (result.IsLockedOut) { var user = await userManager.FindByNameAsync(LoginData.username); DateTimeOffset? datetime = await userManager.GetLockoutEndDateAsync(user); }
我们可以通过 GetLockoutEndDateAsync 查看封锁到几点可以在尝试.
好,话说回来,刚才的 phone confirm token 我们是否需要自己做一个防止暴力破解的方案呢?.
我想 identity 不提供给我们也是有原因的, 因为 phone token 是基于 Time-based One-time Password (TOTP)
如果你不熟悉可以看这里 : http://www.ruanyifeng.com/blog/2017/11/2fa-tutorial.html
一般上 30 秒就失效了, 所以如果对方真的要暴力. 30 秒内得发 90 万个请求试错. 即便是一半 45 万次, 服务器应该也挂掉了...哈哈哈
但是 identity 默认并不是 30 秒.. 而是 9 分钟. 所以还是有点危险得. 在源码中可以看到
也有人提出了 issue 希望可以通过 options 来配置. 因为 Google Authenticator App 也是 30 秒.
本来呢,想自己写一个类似 lockoutOnFailure 方案来防爆, 但是感觉未来 identity 会开放这个功能出来, 干脆就等等吧.
https://github.com/aspnet/AspNetIdentity/issues/15 options for adjust timestep
https://github.com/aspnet/AspNetCore/issues/5811 开放 Rfc6238AuthenticationService
identity 一共给了 4 个 token provider
emailTokenProviderType 没有找到任何地方在用, 所以暂时忽略掉它.
在源码 TokenOptions.cs 里面可以看到所有使用的地方
EmailConfirmationTokenProvider = DefaultProvider = dataProtectionProviderType
PasswordResetTokenProvider = DefaultProvider = dataProtectionProviderType
ChangeEmailTokenProvider = DefaultProvider = dataProtectionProviderType
ChangePhoneNumberTokenProvider = DefaultPhoneProvider = phoneNumberProviderType
AuthenticatorTokenProvider = DefaultAuthenticatorProvider = authenticatorProviderType
authenticatorProviderType 和 phoneNumberProviderType 都用到了 TOTP
dataProtectionProviderType 用的是一般的 dataProtection 加密, 如果你不了解 data protection 可以看这里 : https://www.cnblogs.com/keatkeat/p/9316389.html
所以主要分 2 种 token, 一种是长的,默认过期 1 小时(可以调), 短的就是 TOTP 默认 9 分钟, 不可以调.
1.confirm email, confirm phone, change email, change phone, reset password
这些情况下都需要把 token 发到 email 或 手机上. 显然 identity 的 reset password 用的是长 token 不利于手机.
这个意思就是说歧视手机用户啦.. 如果用户就只用手机注册不可以吗 ? 手机注册就不可以 reset password 吗. 非要给 email ?
那如果项目需要实现有办法吗 ?
看看这个源码... 要实现的话,我们需要直接调用 GenerateUserTokenAsync 然后把 Options.Tokens.PasswordResetTokenProvider 换掉才可以了.
验证的时候也是一样.. 好多代码丫,这显然不太友好... 一般这种情况我就不会去动它了. 除非项目非要不可.
另外我想说说 two factor, 有 3 个重点
two factor 意义在于至少 2 个身份识别.
它是通过 token hardware 或者手机 + apps 来弄的 (不是手机 sms 哦)
在处理 reset password 时要额外小心, 2 个识别下, 如果弄丢了一个,我们不可以用另外一个来恢复哦,必须找到第 3 个识别才是安全的。
这里就不做实现了, 因为上面的 TOTP issue 嘛.
说说实现方式就好. 首先是要有一个 token hardware, 基本上是用手机 + google app 来做啦.
参考
用 asp.net core + js 做一个 qrcode, 用户用 google app 来扫描. 这个是第一次的密钥, 然后就可以用 google app 来生成 6 digit 了
当请求验证时, asp.net core 用同样的密钥生成 6 digit 来 match. 相同密钥 + 同个时间点 (standard 是不能差超过 30 秒)就可以匹配成功了.
最后来讲讲 token provider 的 override.
我们可以参考之前的图, AddDefaultTokenProviders 去创建 token provider 或者是继承 override 也行.
然后通过修改 TokenOptions 的匹配 provider 就可以实现 override 了.
目前我是没有看到什么好 override 的啦, 要弄就要弄大的,比如上面提到的 reset password by phone
虽然可以把所有 reset password 换成 by phone 但是,通常需求并不是要全部, 而是依据用户的选择. 这种 dynamic 的方式就不可能用这种 override 方式做到了.
最终还是要修改 userManger 实现才行. 算了吧 。以后在打算.
来说说 reset password, 这个非常简单
public async Task OnPostResetPassword( [FromServices] UserManager<IdentityUser> userManager ) { var user = await userManager.FindByNameAsync("hengkeat87@gmail.com"); var token = await userManager.GeneratePasswordResetTokenAsync(user); await userManager.ResetPasswordAsync(user, token, newPassword: "123456"); }
搞定.
在来说说 SignIn 的 cookie 配置.
services.ConfigureApplicationCookie(options => { options.Cookie.HttpOnly = true; options.Cookie.Name = "Identity"; options.ExpireTimeSpan = TimeSpan.FromMinutes(5); options.SlidingExpiration = true; options.LoginPath = "/Login"; options.LogoutPath = "/Logout"; options.AccessDeniedPath = "/AccessDenied"; options.ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter; });
HttpOnly 是说 js 无法读取, 为了安全一定要的啊.
Cookie.Name 也是一定要的啦.
expiredTimespan 是 cookie 多久过期
SlidingExpiration 是自动更新延长过期, 就是如果用户一直有访问网站, cookie 就一直有效. 实现方式大概就是请求来的时候发现 cookie 要过期了就写入一个新的时间咯.
LoginPath 就是登入页面咯, 用户访问权限页面时如果没有登入会自动跳转到这里
LogoutPath 用户登出后自动跳转到这里
AccessDeniedPath 如果用户登入了但依然无权限访问的话会跳转到这里 (比如页面要求 role = admin, 但是用户没有 role admin)
ReturnUrlParameter 默认叫 returnUrl ... 从前我就想问为什么不叫 redirect url 呢 ? 很好,现在可以换掉.
总结 :
这篇主要讲的是
要求 email confirm 或 phone confirm
sign in 的暴力破解
还有 token provider 的设计和运用
最后是一些基本的 cookie 配置.