!31 增加功能:增加手机短信验证码登录

This commit is contained in:
Argo 2019-09-16 17:59:23 +08:00
parent c3bbe488ea
commit 7a75f9ec68
9 changed files with 394 additions and 26 deletions

View File

@ -2,6 +2,7 @@
using Bootstrap.DataAccess; using Bootstrap.DataAccess;
using Longbow.GiteeAuth; using Longbow.GiteeAuth;
using Longbow.GitHubAuth; using Longbow.GitHubAuth;
using Longbow.Security.Cryptography;
using Longbow.Web; using Longbow.Web;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@ -64,6 +65,50 @@ namespace Bootstrap.Admin.Controllers
return User.Identity.IsAuthenticated ? (ActionResult)Redirect("~/Home/Index") : View("Login", new LoginModel()); return User.Identity.IsAuthenticated ? (ActionResult)Redirect("~/Home/Index") : View("Login", new LoginModel());
} }
/// <summary>
/// 短信验证登陆方法
/// </summary>
/// <param name="onlineUserSvr"></param>
/// <param name="ipLocator"></param>
/// <param name="configuration"></param>
/// <param name="phone"></param>
/// <param name="code"></param>
/// <returns></returns>
[HttpPost()]
public async Task<IActionResult> Mobile([FromServices]IOnlineUsers onlineUserSvr, [FromServices]IIPLocatorProvider ipLocator, [FromServices]IConfiguration configuration, [FromQuery]string phone, [FromQuery]string code)
{
var option = configuration.GetSection(nameof(SMSOptions)).Get<SMSOptions>();
if (UserHelper.AuthenticateMobile(phone, code, option.MD5Key, loginUser => CreateLoginUser(onlineUserSvr, ipLocator, HttpContext, loginUser)))
{
var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
identity.AddClaim(new Claim(ClaimTypes.Name, phone));
identity.AddClaim(new Claim(ClaimTypes.Role, "Default"));
await HttpContext.SignInAsync(new ClaimsPrincipal(identity));
if (UserHelper.RetrieveUserByUserName(identity) == null)
{
var user = new User()
{
ApprovedBy = "Mobile",
ApprovedTime = DateTime.Now,
DisplayName = "手机用户",
UserName = phone,
Password = LgbCryptography.GenerateSalt(),
Icon = "default.jpg",
Description = "手机用户",
App = "2"
};
UserHelper.Save(user);
CacheCleanUtility.ClearCache(cacheKey: UserHelper.RetrieveUsersDataKey);
}
// redirect origin url
var originUrl = Request.Query[CookieAuthenticationDefaults.ReturnUrlParameter].FirstOrDefault() ?? "~/Home/Index";
return Redirect(originUrl);
}
return View("Login", new LoginModel() { AuthFailed = true });
}
/// <summary> /// <summary>
/// Login the specified userName, password and remember. /// Login the specified userName, password and remember.
/// </summary> /// </summary>

View File

@ -5,15 +5,15 @@ using Longbow.Web;
using Longbow.Web.Mvc; using Longbow.Web.Mvc;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System.Net.Http;
using System.Threading.Tasks;
namespace Bootstrap.Admin.Controllers.Api namespace Bootstrap.Admin.Controllers.Api
{ {
/// <summary> /// <summary>
/// /// 登陆接口
/// </summary>
/// <summary>
///
/// </summary> /// </summary>
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
@ -28,7 +28,7 @@ namespace Bootstrap.Admin.Controllers.Api
public QueryData<LoginUser> Get([FromQuery]QueryLoginOption value) => value.RetrieveData(); public QueryData<LoginUser> Get([FromQuery]QueryLoginOption value) => value.RetrieveData();
/// <summary> /// <summary>
/// /// JWT 登陆认证接口
/// </summary> /// </summary>
/// <param name="onlineUserSvr"></param> /// <param name="onlineUserSvr"></param>
/// <param name="ipLocator"></param> /// <param name="ipLocator"></param>
@ -50,7 +50,25 @@ namespace Bootstrap.Admin.Controllers.Api
} }
/// <summary> /// <summary>
/// /// 下发手机短信方法
/// </summary>
/// <param name="configuration"></param>
/// <param name="factory"></param>
/// <param name="phone"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpPut]
public async Task<bool> Put([FromServices]IConfiguration configuration, [FromServices]IHttpClientFactory factory, [FromQuery]string phone)
{
if (string.IsNullOrEmpty(phone)) return false;
var option = configuration.GetSection(nameof(SMSOptions)).Get<SMSOptions>();
option.Phone = phone;
return await factory.CreateClient().SendCode(option);
}
/// <summary>
/// 跨域握手协议
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[AllowAnonymous] [AllowAnonymous]

View File

@ -47,30 +47,58 @@
<h2 class="form-signin-heading">@Model.Title</h2> <h2 class="form-signin-heading">@Model.Title</h2>
<div class="login-wrap" data-auth="@Model.AuthFailed" data-toggle="LgbValidate" data-valid-button="button[type='submit']"> <div class="login-wrap" data-auth="@Model.AuthFailed" data-toggle="LgbValidate" data-valid-button="button[type='submit']">
<div class="alert alert-danger d-none" asp-condition="@Model.AuthFailed">用户名或密码错误!</div> <div class="alert alert-danger d-none" asp-condition="@Model.AuthFailed">用户名或密码错误!</div>
<div class="form-group"> <div id="loginUser" class="form-group">
<div class="input-group"> <div class="input-group">
<div class="input-group-prepend"> <div class="input-group-prepend">
<div class="input-group-text"> <div class="input-group-text">
<span class="fa fa-user"></span> <span class="fa fa-user"></span>
</div> </div>
</div> </div>
<input type="text" name="userName" class="form-control" placeholder="用户名" maxlength="16" data-required-msg="请输入用户名" value="" autofocus data-valid="true" /> <input type="text" name="userName" class="form-control" data-toggle="tooltip" placeholder="用户名" maxlength="16" data-required-msg="请输入用户名" value="" autofocus data-valid="true" />
</div> </div>
</div> </div>
<div class="form-group"> <div id="loginMobile" class="form-group d-none">
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text">
<span class="fa fa-user"></span>
</div>
</div>
<input type="text" id="txtPhone" class="form-control digits" data-toggle="tooltip" placeholder="手机号码" minlength="11" maxlength="11" data-required-msg="请输入手机号码" value="" data-valid="true" />
</div>
</div>
<div id="loginPwd" class="form-group">
<div class="input-group"> <div class="input-group">
<div class="input-group-prepend"> <div class="input-group-prepend">
<div class="input-group-text"> <div class="input-group-text">
<span class="fa fa-lock"></span> <span class="fa fa-lock"></span>
</div> </div>
</div> </div>
<input type="password" name="password" class="form-control" value="" placeholder="密码" maxlength="16" data-required-msg="请输入密码" data-valid="true" /> <input type="password" name="password" class="form-control" value="" data-toggle="tooltip" placeholder="密码" maxlength="16" data-required-msg="请输入密码" data-valid="true" />
</div> </div>
</div> </div>
<div class="form-group rememberPwd" onselectstart="return false"> <div id="loginSMS" class="form-group d-none">
<i class="fa fa-square-o"></i> <div class="input-group">
<span>记住密码自动登录</span> <div class="input-group-prepend">
<input id="remember" name="remember" type="hidden" value="false" /> <div class="input-group-text">
<span class="fa fa-lock"></span>
</div>
</div>
<input type="text" id="smscode" class="form-control digits" data-toggle="tooltip" disabled value="" placeholder="验证码" maxlength="4" data-required-msg="请输入验证码" data-valid="true" />
<div class="input-group-append">
<button type="button" id="btnSendCode" class="btn btn-sms" data-toggle="tooltip" title="点击发送验证码">发送验证码</button>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<div class="form-group rememberPwd" onselectstart="return false">
<i class="fa fa-square-o"></i>
<span>记住密码自动登录</span>
<input id="remember" name="remember" type="hidden" value="false" />
</div>
<div>
<a id="loginType" data-value="username" href="#" class="">短信验证登陆</a>
</div>
</div> </div>
<button class="btn btn-lg btn-login btn-block" data-toggle="tooltip" title="不填写密码默认使用 Gitee 认证" type="submit">登 录</button> <button class="btn btn-lg btn-login btn-block" data-toggle="tooltip" title="不填写密码默认使用 Gitee 认证" type="submit">登 录</button>
<div class="login-footer"> <div class="login-footer">

View File

@ -82,6 +82,10 @@
"App": "0", "App": "0",
"StarredUrl": "https://api.github.com/user/starred/ArgoZhang/BootstrapAdmin" "StarredUrl": "https://api.github.com/user/starred/ArgoZhang/BootstrapAdmin"
}, },
"SMSOptions": {
"CompanyCode": "<CompanyCode>",
"MD5Key": "MD5Key"
},
"LongbowCache": { "LongbowCache": {
"Enabled": true, "Enabled": true,
"CorsItems": [ "CorsItems": [

View File

@ -86,6 +86,10 @@
"Roles": [ "Default" ], "Roles": [ "Default" ],
"App": "2" "App": "2"
}, },
"SMSOptions": {
"CompanyCode": "<CompanyCode>",
"MD5Key": "MD5Key"
},
"LongbowCache": { "LongbowCache": {
"Enabled": true, "Enabled": true,
"CorsItems": [ "CorsItems": [

View File

@ -44,7 +44,7 @@
box-shadow: 0 4px #e56b60; box-shadow: 0 4px #e56b60;
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
outline: none !important; outline: none !important;
margin-top: -0.5rem; margin-top: -0.25rem;
line-height: 1; line-height: 1;
} }
@ -57,8 +57,8 @@
border-color: #1ca0e9; border-color: #1ca0e9;
} }
.form-control { .form-control, .input-group-append .btn {
border-color: #1ca0e9; border-color: #1ca0e9 !important;
} }
.slidercaptcha { .slidercaptcha {
@ -70,6 +70,7 @@
right: 0; right: 0;
top: -270px; top: -270px;
height: 226px; height: 226px;
z-index: 1080
} }
.slidercaptcha canvas:first-child { .slidercaptcha canvas:first-child {
@ -144,3 +145,7 @@
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
.login-wrap .btn-sms {
width: 140px;
}

View File

@ -119,13 +119,97 @@
// use Gitee authentication when SystemDemoModel // use Gitee authentication when SystemDemoModel
var $login = $('#login'); var $login = $('#login');
var $username = $('[name="userName"]');
var $password = $('[name="password"]');
var $loginUser = $('#loginUser');
var $loginMobile = $('#loginMobile');
var $loginPwd = $('#loginPwd');
var $loginSMS = $('#loginSMS');
if ($login.attr('data-demo') === 'True') { if ($login.attr('data-demo') === 'True') {
$login.find('[data-valid="true"]').attr('data-valid', 'false');
$login.on('submit', function (e) { $login.on('submit', function (e) {
if ($('[name="userName"]').val() === '' && $('[name="password"]').val() === '') { var model = $loginType.attr('data-value');
location.href = "Gitee"; if (model === 'username') {
e.preventDefault(); $login.find('[data-valid="true"]').attr('data-valid', 'false');
if ($username.val() === '' && $password.val() === '') {
e.preventDefault();
location.href = "Gitee";
}
}
else {
// sms
var url = $.format('Account/Mobile?phone={0}&code={1}', $('#txtPhone').val(), $('#smscode').val());
$login.attr('action', $.formatUrl(url));
return true;
} }
}); });
} }
// login type
var $loginType = $('#loginType').on('click', function (e) {
e.preventDefault();
var $this = $(this);
$login.find('[data-toggle="tooltip"]').tooltip('hide');
var model = $this.attr('data-value');
if (model === 'username') {
$loginUser.addClass('d-none');
$loginPwd.addClass('d-none');
$loginSMS.removeClass('d-none');
$loginMobile.removeClass('d-none');
$this.attr('data-value', 'sms').text('用户名密码登陆');
}
else {
// sms model
$loginUser.removeClass('d-none');
$loginPwd.removeClass('d-none');
$loginSMS.addClass('d-none');
$loginMobile.addClass('d-none');
$this.attr('data-value', 'username').text('短信验证登陆');
}
});
var timeHanlder = null;
$('#btnSendCode').on('click', function () {
// validate mobile phone
var $phone = $('#txtPhone');
var validator = $login.find('[data-toggle="LgbValidate"]').lgbValidator();
if (!validator.validElement($phone.get(0))) {
$phone.tooltip('show');
return;
}
var phone = $phone.val();
var apiUrl = "api/Login?phone=" + phone;
var $this = $(this);
$.bc({
url: apiUrl,
method: 'PUT',
callback: function (result) {
$this.attr('data-original-title', result ? "发送成功" : "发送失败").tooltip('show');
var handler = setTimeout(function () {
clearTimeout(handler);
$this.tooltip('hide').attr('data-original-title', "点击发送验证码");
}, 1500);
if (result) {
// send success
$this.text('已发送').attr('disabled', true);
$('#smscode').removeAttr('disabled');
timeHanlder = setTimeout(function () {
clearTimeout(timeHanlder);
var count = 299;
timeHanlder = setInterval(function () {
if (count === 0) {
clearInterval(timeHanlder);
$this.text('发送验证码').removeAttr('disabled');
return;
}
$this.text(count-- + ' 秒后可重发');
}, 1000);
}, 1000);
}
}
});
});
}); });

View File

@ -0,0 +1,157 @@
using Microsoft.AspNetCore.WebUtilities;
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
namespace Bootstrap.DataAccess
{
/// <summary>
/// 手机号登陆帮助类
/// </summary>
public static class SMSHelper
{
private static ConcurrentDictionary<string, AutoExpireValidateCode> _pool = new ConcurrentDictionary<string, AutoExpireValidateCode>();
/// <summary>
/// 下发验证码方法
/// </summary>
/// <param name="client"></param>
/// <param name="option"></param>
/// <returns></returns>
public static async System.Threading.Tasks.Task<bool> SendCode(this HttpClient client, SMSOptions option)
{
option.Timestamp = (DateTimeOffset.UtcNow.Ticks - 621355968000000000) / 10000000;
var requestParameters = new Dictionary<string, string>()
{
{ "CompanyCode", option.CompanyCode },
{ "Phone", option.Phone },
{ "TimeStamp", option.Timestamp.ToString() },
{ "Sign", option.Sign() }
};
var url = QueryHelpers.AddQueryString("http://open.bluegoon.com/api/sms/sendcode", requestParameters);
var req = await client.GetAsync(url);
var result = JsonConvert.DeserializeObject<SMSResult>(await req.Content.ReadAsStringAsync());
var ret = false;
if (result.Code == "1")
{
_pool.AddOrUpdate(option.Phone, key => new AutoExpireValidateCode(option.Phone, result.Data, option.Expires), (key, v) => v.Reset(result.Data));
ret = true;
}
return ret;
}
/// <summary>
/// 验证验证码方法
/// </summary>
/// <param name="phone">手机号</param>
/// <param name="code">验证码</param>
/// <param name="secret">密钥</param>
/// <returns></returns>
public static bool Validate(string phone, string code, string secret) => _pool.TryGetValue(phone, out var signKey) && Hash($"{code}{secret}") == signKey.Code;
private static string Sign(this SMSOptions option)
{
return Hash($"{option.CompanyCode}{option.Phone}{option.Timestamp}{option.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 SMSResult
{
public string Code { get; set; }
public string Data { get; set; }
}
private class AutoExpireValidateCode
{
public AutoExpireValidateCode(string phone, string code, TimeSpan expires)
{
Phone = phone;
Code = code;
Expires = expires;
RunAsync();
}
/// <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 System.Threading.Tasks.Task RunAsync() => System.Threading.Tasks.Task.Run(() =>
{
_tokenSource = new CancellationTokenSource();
if (!_tokenSource.Token.WaitHandle.WaitOne(Expires)) _pool.TryRemove(Phone, out var _);
});
/// <summary>
///
/// </summary>
/// <param name="code"></param>
public AutoExpireValidateCode Reset(string code)
{
Code = code;
_tokenSource.Cancel();
RunAsync();
return this;
}
}
}
/// <summary>
/// 短信网关配置类
/// </summary>
public class SMSOptions
{
/// <summary>
/// 获得/设置 公司编码
/// </summary>
public string CompanyCode { get; set; }
/// <summary>
/// 获得/设置 下发手机号码
/// </summary>
public string Phone { get; set; }
/// <summary>
/// 获得/设置 签名密钥
/// </summary>
public string MD5Key { get; set; }
/// <summary>
/// 获得/设置 时间戳
/// </summary>
public long Timestamp { get; set; }
/// <summary>
/// 获得/设置 验证码有效时长
/// </summary>
public TimeSpan Expires { get; set; } = TimeSpan.FromMinutes(5);
}
}

View File

@ -58,9 +58,9 @@ namespace Bootstrap.DataAccess
/// </summary> /// </summary>
/// <param name="userName"></param> /// <param name="userName"></param>
/// <param name="password"></param> /// <param name="password"></param>
/// <param name="config"></param> /// <param name="configure"></param>
/// <returns>返回真表示认证通过</returns> /// <returns>返回真表示认证通过</returns>
public static bool Authenticate(string userName, string password, Action<LoginUser> config) public static bool Authenticate(string userName, string password, Action<LoginUser> configure)
{ {
if (!UserChecker(new User { UserName = userName, Password = password })) return false; if (!UserChecker(new User { UserName = userName, Password = password })) return false;
var loginUser = new LoginUser var loginUser = new LoginUser
@ -69,13 +69,36 @@ namespace Bootstrap.DataAccess
LoginTime = DateTime.Now, LoginTime = DateTime.Now,
Result = "登录失败" Result = "登录失败"
}; };
config(loginUser); configure(loginUser);
var ret = string.IsNullOrEmpty(userName) ? false : DbContextManager.Create<User>().Authenticate(userName, password); var ret = string.IsNullOrEmpty(userName) ? false : DbContextManager.Create<User>().Authenticate(userName, password);
if (ret) loginUser.Result = "登录成功"; if (ret) loginUser.Result = "登录成功";
LoginHelper.Log(loginUser); LoginHelper.Log(loginUser);
return ret; return ret;
} }
/// <summary>
/// 短信验证码认证方法
/// </summary>
/// <param name="phone"></param>
/// <param name="code"></param>
/// <param name="secret"></param>
/// <param name="configure"></param>
/// <returns></returns>
public static bool AuthenticateMobile(string phone, string code, string secret, Action<LoginUser> configure)
{
var loginUser = new LoginUser
{
UserName = phone,
LoginTime = DateTime.Now,
Result = "登录失败"
};
configure(loginUser);
var ret = string.IsNullOrEmpty(phone) ? false : SMSHelper.Validate(phone, code, secret);
if (ret) loginUser.Result = "登录成功";
LoginHelper.Log(loginUser);
return ret;
}
/// <summary> /// <summary>
/// 查询所有的新注册用户 /// 查询所有的新注册用户
/// </summary> /// </summary>