feat: 增加手机登录

This commit is contained in:
Argo-Tianyi 2021-12-16 15:18:43 +08:00
parent 1d94e53866
commit 892049c2cb
10 changed files with 588 additions and 91 deletions

View File

@ -1,5 +1,6 @@
using BootstrapAdmin.Web.Core;
using BootstrapAdmin.Web.Services;
using BootstrapAdmin.Web.Services.SMS;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
@ -15,46 +16,9 @@ namespace Bootstrap.Admin.Controllers
[AllowAnonymous]
public class AccountController : Controller
{
//private const string MobileSchema = "Mobile";
///// <summary>
///// 系统锁屏界面
///// </summary>
///// <returns></returns>
//[HttpGet]
//public async Task<ActionResult> Lock()
//{
// if (!User.Identity!.IsAuthenticated) return Login();
// var authenticationType = User.Identity.AuthenticationType;
// await HttpContext.SignOutAsync();
// var urlReferrer = Request.Headers["Referer"].FirstOrDefault();
// if (urlReferrer?.Contains("/Pages", StringComparison.OrdinalIgnoreCase) ?? false) urlReferrer = "/Pages";
// return View(new LockModel(User.Identity.Name)
// {
// AuthenticationType = authenticationType,
// ReturnUrl = WebUtility.UrlEncode(string.IsNullOrEmpty(urlReferrer) ? CookieAuthenticationDefaults.LoginPath.Value : urlReferrer)
// });
//}
///// <summary>
///// 系统锁屏界面
///// </summary>
///// <param name="provider"></param>
///// <param name="userName"></param>
///// <param name="password"></param>
///// <param name="authType"></param>
///// <returns></returns>
//[HttpPost]
//[IgnoreAntiforgeryToken]
//public Task<IActionResult> Lock([FromServices] ISMSProvider provider, string userName, string password, string authType)
//{
// // 根据不同的登陆方式
// Task<IActionResult> ret;
// if (authType == MobileSchema) ret = Mobile(provider, userName, password);
// else ret = Login(userName, password, string.Empty);
// return ret;
//}
private const string MobileSchema = "Mobile";
#region UserLogin
/// <summary>
/// Login the specified userName, password and remember.
/// </summary>
@ -96,6 +60,18 @@ namespace Bootstrap.Admin.Controllers
return Redirect(originUrl);
}
private IActionResult RedirectLogin()
{
var query = Request.Query.Aggregate(new Dictionary<string, string?>(), (d, v) =>
{
d.Add(v.Key, v.Value.ToString());
return d;
});
return Redirect(QueryHelpers.AddQueryString(Request.PathBase + CookieAuthenticationDefaults.LoginPath, query));
}
#endregion
#region Logout
/// <summary>
/// Logout this instance.
/// </summary>
@ -108,57 +84,51 @@ namespace Bootstrap.Admin.Controllers
await HttpContext.SignOutAsync();
return Redirect(QueryHelpers.AddQueryString(Request.PathBase + CookieAuthenticationDefaults.LoginPath, "AppId", appId ?? context.AppId));
}
#endregion
private IActionResult RedirectLogin()
#region Mobile Login
/// <summary>
/// 短信验证登陆方法
/// </summary>
/// <param name="provider"></param>
/// <param name="loginService"></param>
/// <param name="phone"></param>
/// <param name="code"></param>
/// <returns></returns>
[HttpPost()]
public async Task<IActionResult> Mobile([FromServices] ISMSProvider provider, [FromServices] ILogins loginService, string phone, string code)
{
var query = Request.Query.Aggregate(new Dictionary<string, string?>(), (d, v) =>
if (string.IsNullOrEmpty(phone) || string.IsNullOrEmpty(code)) return RedirectLogin();
var auth = provider.Validate(phone, code);
await loginService.Log(phone, auth);
if (auth)
{
d.Add(v.Key, v.Value.ToString());
return d;
});
return Redirect(QueryHelpers.AddQueryString(Request.PathBase + CookieAuthenticationDefaults.LoginPath, query));
//var user = UserHelper.Retrieves().FirstOrDefault(u => u.UserName == phone);
//if (user == null)
//{
// user = new User()
// {
// ApprovedBy = "Mobile",
// ApprovedTime = DateTime.Now,
// DisplayName = "手机用户",
// UserName = phone,
// Password = code,
// Icon = "default.jpg",
// Description = "手机用户",
// App = provider.Options.App
// };
// if (UserHelper.Save(user) && !string.IsNullOrEmpty(user.Id))
// {
// // 根据配置文件设置默认角色
// var roles = RoleHelper.Retrieves().Where(r => provider.Options.Roles.Any(rl => rl.Equals(r.RoleName, StringComparison.OrdinalIgnoreCase))).Select(r => r.Id!);
// RoleHelper.SaveByUserId(user.Id, roles);
// }
//}
}
return auth ? await SignInAsync(phone, true, MobileSchema) : RedirectLogin();
}
///// <summary>
///// 短信验证登陆方法
///// </summary>
///// <param name="provider"></param>
///// <param name="phone"></param>
///// <param name="code"></param>
///// <returns></returns>
//[HttpPost()]
//public async Task<IActionResult> Mobile([FromServices] ISMSProvider provider, string phone, string code)
//{
// if (string.IsNullOrEmpty(phone) || string.IsNullOrEmpty(code)) return RedirectLogin();
// var auth = provider.Validate(phone, code);
// await HttpContext.Log(phone, auth);
// if (auth)
// {
// var user = UserHelper.Retrieves().FirstOrDefault(u => u.UserName == phone);
// if (user == null)
// {
// user = new User()
// {
// ApprovedBy = "Mobile",
// ApprovedTime = DateTime.Now,
// DisplayName = "手机用户",
// UserName = phone,
// Password = code,
// Icon = "default.jpg",
// Description = "手机用户",
// App = provider.Options.App
// };
// if (UserHelper.Save(user) && !string.IsNullOrEmpty(user.Id))
// {
// // 根据配置文件设置默认角色
// var roles = RoleHelper.Retrieves().Where(r => provider.Options.Roles.Any(rl => rl.Equals(r.RoleName, StringComparison.OrdinalIgnoreCase))).Select(r => r.Id!);
// RoleHelper.SaveByUserId(user.Id, roles);
// }
// }
// }
// return auth ? await SignInAsync(phone, true, MobileSchema) : RedirectLogin();
//}
#endregion
///// <summary>
///// Accesses the denied.
@ -222,5 +192,43 @@ namespace Bootstrap.Admin.Controllers
// var enabled = config.GetValue($"{nameof(WeChatOptions)}:Enabled", false);
// return Challenge(enabled ? WeChatDefaults.AuthenticationScheme : CookieAuthenticationDefaults.AuthenticationScheme);
//}
///// <summary>
///// 系统锁屏界面
///// </summary>
///// <returns></returns>
//[HttpGet]
//public async Task<ActionResult> Lock()
//{
// if (!User.Identity!.IsAuthenticated) return Login();
// var authenticationType = User.Identity.AuthenticationType;
// await HttpContext.SignOutAsync();
// var urlReferrer = Request.Headers["Referer"].FirstOrDefault();
// if (urlReferrer?.Contains("/Pages", StringComparison.OrdinalIgnoreCase) ?? false) urlReferrer = "/Pages";
// return View(new LockModel(User.Identity.Name)
// {
// AuthenticationType = authenticationType,
// ReturnUrl = WebUtility.UrlEncode(string.IsNullOrEmpty(urlReferrer) ? CookieAuthenticationDefaults.LoginPath.Value : urlReferrer)
// });
//}
///// <summary>
///// 系统锁屏界面
///// </summary>
///// <param name="provider"></param>
///// <param name="userName"></param>
///// <param name="password"></param>
///// <param name="authType"></param>
///// <returns></returns>
//[HttpPost]
//[IgnoreAntiforgeryToken]
//public Task<IActionResult> Lock([FromServices] ISMSProvider provider, string userName, string password, string authType)
//{
// // 根据不同的登陆方式
// Task<IActionResult> ret;
// if (authType == MobileSchema) ret = Mobile(provider, userName, password);
// else ret = Login(userName, password, string.Empty);
// return ret;
//}
}
}

View File

@ -1,6 +1,8 @@
using BootstrapAdmin.Web.Services;
using System.Text;
using Microsoft.EntityFrameworkCore;
using BootstrapAdmin.Web.Services.SMS.Tencent;
using BootstrapAdmin.Web.Services.SMS;
namespace Microsoft.Extensions.DependencyInjection
{
@ -62,6 +64,9 @@ namespace Microsoft.Extensions.DependencyInjection
// 增加 BootstrapBlazor 组件
services.AddBootstrapBlazor();
// 增加手机短信服务
services.AddTransient<ISMSProvider, TencentSMSProvider>();
// 增加认证授权服务
services.AddBootstrapAdminSecurity<AdminService>();

View File

@ -1,5 +1,4 @@
using BootstrapAdmin.Web.Core;
using BootstrapAdmin.Web.Services;
namespace BootstrapAdmin.Web.Pages.Account
{
@ -24,10 +23,6 @@ namespace BootstrapAdmin.Web.Pages.Account
[NotNull]
private IDicts? DictsService { get; set; }
[Inject]
[NotNull]
private IUsers? UserService { get; set; }
/// <summary>
///
/// </summary>

View File

@ -0,0 +1,66 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
namespace BootstrapAdmin.Web.Services.SMS
{
/// <summary>
///
/// </summary>
internal class AutoExpireValidateCode
{
/// <summary>
///
/// </summary>
/// <param name="phone"></param>
/// <param name="code"></param>
/// <param name="expires"></param>
/// <param name="expiredCallback"></param>
public AutoExpireValidateCode(string phone, string code, TimeSpan expires, Action<string> expiredCallback)
{
Phone = phone;
Code = code;
Expires = expires;
ExpiredCallback = expiredCallback;
RunAsync();
}
/// <summary>
///
/// </summary>
protected Action<string> ExpiredCallback { get; set; }
/// <summary>
///
/// </summary>
public string Code { get; private set; }
/// <summary>
///
/// </summary>
public string Phone { get; }
/// <summary>
///
/// </summary>
public TimeSpan Expires { get; set; }
private CancellationTokenSource? _tokenSource;
private Task RunAsync() => Task.Run(() =>
{
_tokenSource = new CancellationTokenSource();
if (!_tokenSource.Token.WaitHandle.WaitOne(Expires)) ExpiredCallback(Phone);
});
/// <summary>
///
/// </summary>
/// <param name="code"></param>
public AutoExpireValidateCode Reset(string code)
{
Code = code;
_tokenSource?.Cancel();
RunAsync();
return this;
}
}
}

View File

@ -0,0 +1,126 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
using Microsoft.AspNetCore.WebUtilities;
using System.Collections.Concurrent;
using System.Collections.Specialized;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace BootstrapAdmin.Web.Services.SMS
{
/// <summary>
/// 手机号登陆帮助类
/// </summary>
public class DefaultSMSProvider : ISMSProvider
{
private static readonly ConcurrentDictionary<string, AutoExpireValidateCode> _pool = new ConcurrentDictionary<string, AutoExpireValidateCode>();
/// <summary>
/// 获得 短信配置信息
/// </summary>
public SMSOptions Options { get { return _options; } }
private readonly DefaultSMSOptions _options;
private readonly HttpClient _client;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="configuration"></param>
/// <param name="factory"></param>
public DefaultSMSProvider(IConfiguration configuration, IHttpClientFactory factory)
{
_options = configuration.GetSection(nameof(SMSOptions)).Get<DefaultSMSOptions>();
_client = factory.CreateClient();
}
/// <summary>
/// 下发验证码方法
/// </summary>
/// <param name="phoneNumber"></param>
/// <returns></returns>
public async Task<SMSResult> SendCodeAsync(string phoneNumber)
{
Options.Timestamp = (DateTimeOffset.UtcNow.Ticks - 621355968000000000) / 10000000;
Options.Phone = phoneNumber;
var requestParameters = new Dictionary<string, string?>()
{
{ "CompanyCode", _options.CompanyCode },
{ "Phone", Options.Phone },
{ "TimeStamp", Options.Timestamp.ToString() },
{ "Sign", Sign() }
};
var url = QueryHelpers.AddQueryString(Options.RequestUrl, requestParameters);
var req = await _client.GetAsync(url);
var content = await req.Content.ReadAsStringAsync();
#if !NETSTANDARD2_0
var result = JsonSerializer.Deserialize<DefaultSMSResult>(content, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
#else
var result = JsonConvert.DeserializeObject<DefaultSMSResult>(content);
#endif
var ret = new SMSResult() { Result = result!.Code == 1, Msg = result.Msg };
if (ret.Result)
{
_pool.AddOrUpdate(Options.Phone, key => new AutoExpireValidateCode(Options.Phone, result.Data, Options.Expires, phone => _pool.TryRemove(phone, out var _)), (key, v) => v.Reset(result.Data));
}
else
{
new Exception(result.Msg).Format(new NameValueCollection()
{
["UserId"] = Options.Phone,
["url"] = url,
["content"] = content
});
}
return ret;
}
/// <summary>
/// 验证验证码方法
/// </summary>
/// <param name="phone">手机号</param>
/// <param name="code">验证码</param>
/// <returns></returns>
public bool Validate(string phone, string code) => _pool.TryGetValue(phone, out var signKey) && Hash($"{code}{_options.MD5Key}") == signKey.Code;
private string Sign()
{
return Hash($"{_options.CompanyCode}{Options.Phone}{Options.Timestamp}{_options.MD5Key}");
}
private static string Hash(string data)
{
using var md5 = MD5.Create();
var sign = BitConverter.ToString(md5.ComputeHash(Encoding.UTF8.GetBytes(data)));
sign = sign.Replace("-", "").ToLowerInvariant();
return sign;
}
private class DefaultSMSResult
{
public int Code { get; set; }
public string Data { get; set; } = "";
public string Msg { get; set; } = "";
}
}
/// <summary>
///
/// </summary>
public class DefaultSMSOptions : SMSOptions
{
/// <summary>
/// 获得/设置 公司编码
/// </summary>
public string CompanyCode { get; set; } = "";
/// <summary>
/// 获得/设置 签名密钥
/// </summary>
public string MD5Key { get; set; } = "";
}
}

View File

@ -0,0 +1,30 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
namespace BootstrapAdmin.Web.Services.SMS
{
/// <summary>
/// 短信登录接口
/// </summary>
public interface ISMSProvider
{
/// <summary>
/// 手机下发验证码方法
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <returns></returns>
Task<SMSResult> SendCodeAsync(string phoneNumber);
/// <summary>
/// 验证手机验证码是否正确方法
/// </summary>
/// <param name="phoneNumber">手机号</param>
/// <param name="code">验证码</param>
/// <returns></returns>
bool Validate(string phoneNumber, string code);
/// <summary>
///
/// </summary>
SMSOptions Options { get; }
}
}

View File

@ -0,0 +1,45 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
namespace BootstrapAdmin.Web.Services.SMS
{
/// <summary>
/// 短信网关配置类
/// </summary>
public class SMSOptions
{
/// <summary>
/// 获得/设置 下发手机号码
/// </summary>
public string Phone { get; set; } = "";
/// <summary>
/// 获得/设置 时间戳
/// </summary>
public long Timestamp { get; set; }
/// <summary>
/// 获得/设置 验证码有效时长
/// </summary>
public TimeSpan Expires { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// 获得/设置 角色集合
/// </summary>
public ICollection<string> Roles { get; } = new HashSet<string>();
/// <summary>
/// 获得/设置 登陆后首页
/// </summary>
public string HomePath { get; set; } = "";
/// <summary>
/// 获得/设置 默认授权 App
/// </summary>
public string App { get; set; } = "";
/// <summary>
/// 获得/设置 短信下发网关地址
/// </summary>
public string RequestUrl { get; set; } = "http://open.bluegoon.com/api/sms/sendcode";
}
}

View File

@ -0,0 +1,25 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
namespace BootstrapAdmin.Web.Services.SMS
{
/// <summary>
/// 短信结果实体类
/// </summary>
public class SMSResult
{
/// <summary>
/// 短信验证码是否发送成功
/// </summary>
public bool Result { get; set; }
/// <summary>
/// 短信验证码返回数据
/// </summary>
public string? Data { get; set; }
/// <summary>
/// 短信失败原因提示信息
/// </summary>
public string? Msg { get; set; }
}
}

View File

@ -0,0 +1,35 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
namespace BootstrapAdmin.Web.Services.SMS.Tencent
{
/// <summary>
/// 腾讯短信配置实体类
/// </summary>
public class TencentSMSOptions : SMSOptions
{
/// <summary>
/// 腾讯 AppId
/// </summary>
public string AppId { get; set; } = "";
/// <summary>
/// 腾讯 AppKey
/// </summary>
public string AppKey { get; set; } = "";
/// <summary>
/// 腾讯 模板 ID
/// </summary>
public int TplId { get; set; }
/// <summary>
/// 腾讯 应用签名
/// </summary>
public string Sign { get; set; } = "";
/// <summary>
/// 是否为 Debug 模式 默认为 False
/// </summary>
public bool Debug { get; set; }
}
}

View File

@ -0,0 +1,162 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
using Microsoft.AspNetCore.WebUtilities;
using System.Collections.Concurrent;
using System.Collections.Specialized;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace BootstrapAdmin.Web.Services.SMS.Tencent
{
/// <summary>
/// 腾讯云短信平台接口
/// </summary>
public class TencentSMSProvider : ISMSProvider
{
/// <summary>
///
/// </summary>
public SMSOptions Options { get { return _options; } }
private readonly HttpClient _client;
private readonly TencentSMSOptions _options;
private readonly Random _random;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="configuration"></param>
/// <param name="factory"></param>
public TencentSMSProvider(IConfiguration configuration, IHttpClientFactory factory)
{
_options = configuration.GetSection(nameof(TencentSMSOptions)).Get<TencentSMSOptions>();
Options.RequestUrl = "https://yun.tim.qq.com/v5/tlssmssvr/sendsms";
_client = factory.CreateClient();
_random = new Random();
}
private static readonly ConcurrentDictionary<string, AutoExpireValidateCode> _pool = new ConcurrentDictionary<string, AutoExpireValidateCode>();
/// <summary>
/// 手机下发验证码方法
/// </summary>
/// <param name="phoneNumber"></param>
/// <returns></returns>
public async Task<SMSResult> SendCodeAsync(string phoneNumber)
{
// post https://yun.tim.qq.com/v5/tlssmssvr/sendsms?sdkappid=xxxxx&random=xxxx
Options.Timestamp = (DateTimeOffset.UtcNow.Ticks - 621355968000000000) / 10000000;
Options.Phone = phoneNumber;
var requestParameters = new Dictionary<string, string?>()
{
{ "sdkappid", _options.AppId },
{ "random", Options.Timestamp.ToString() }
};
var url = QueryHelpers.AddQueryString(Options.RequestUrl, requestParameters);
var postData = new TencentSendData()
{
Sig = Sign(),
Sign = _options.Sign,
Time = Options.Timestamp,
Tel = new TencentPhone() { Mobile = Options.Phone },
Tpl_id = _options.TplId
};
var code = _random.Next(1000, 9999).ToString();
postData.Params.Add(code);
postData.Params.Add(Options.Expires.Minutes.ToString());
var result = _options.Debug ? await Task.FromResult(new TencenResponse() { Result = 0 }) : await RequestSendCodeUrl(url, postData);
var ret = new SMSResult() { Result = result.Result == 0, Msg = result.Errmsg };
// debug 模式下发验证码到客户端
if (_options.Debug) ret.Data = code;
if (ret.Result)
{
_pool.AddOrUpdate(Options.Phone, key => new AutoExpireValidateCode(Options.Phone, code, Options.Expires, phone => _pool.TryRemove(phone, out var _)), (key, v) => v.Reset(code));
}
return ret;
}
private async Task<TencenResponse> RequestSendCodeUrl(string url, TencentSendData postData)
{
var req = await _client.PostAsJsonAsync(url, postData, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var content = await req.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<TencenResponse>(content, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
if (result!.Result != 0)
{
new Exception(result.Errmsg).Format(new NameValueCollection()
{
["UserId"] = Options.Phone,
["url"] = url,
["content"] = content
});
}
return result;
}
private string Sign()
{
return Hash($"appkey={_options.AppKey}&random={Options.Timestamp}&time={Options.Timestamp}&mobile={Options.Phone}");
}
private static string Hash(string data)
{
using var algo = SHA256.Create();
var sign = BitConverter.ToString(algo.ComputeHash(Encoding.UTF8.GetBytes(data)));
sign = sign.Replace("-", "").ToLowerInvariant();
return sign;
}
/// <summary>
/// 验证手机验证码是否正确方法
/// </summary>
/// <param name="phoneNumber"></param>
/// <param name="code"></param>
/// <returns></returns>
public bool Validate(string phoneNumber, string code) => _pool.TryGetValue(phoneNumber, out var signKey) && code == signKey.Code;
/// <summary>
/// 文档 https://cloud.tencent.com/document/product/382/5976
/// </summary>
private class TencentSendData
{
public string Ext { get; set; } = "";
public string Extend { get; set; } = "";
public ICollection<string> Params { get; } = new HashSet<string>();
public string Sig { get; set; } = "";
public string Sign { get; set; } = "";
public TencentPhone Tel { get; set; } = new TencentPhone();
public long Time { get; set; }
public int Tpl_id { get; set; }
}
private class TencentPhone
{
public string Mobile { get; set; } = "";
public string Nationcode { get; set; } = "86";
}
private class TencenResponse
{
public int Result { get; set; } = -1;
public string Errmsg { get; set; } = "";
public string Ext { get; set; } = "";
public int Fee { get; set; }
public string Sid { get; set; } = "";
}
}
}