[C#] 如何使用 MOTP 搭配 OTP Authenticator App 产生一次性密码登入(附范例)

在传统的登入系统中总是使用帐号密码的方式验证身份,这种方式如果密码不小心被盗取的话,帐号资料就会有被骇入的可能性。
为了提升帐号安全性,我们可以利用手机 App 产生一次性密码 (MOTP),做为登入的第二道密码使用又称双重验证,这样的优点是不容易受到攻击,需要登入密码及一次性密码才可以完成登入。

这次的教学重点会放在如何与 OTP Authenticator 免费 App 搭配产生一次性密码,并在网页上验证一次性密码 (OTP)。

我写了简单的范例,在 C# Asp.Net 网页产生注册 QR Code,并利用免费的 OTP Authenticator App 扫描 QR Code 产生一次性密码 (OTP) 後,再回到网页上验证身份。

范例建置环境
前端架构: Vue.js, jQuery, Bootstrap
後端架构: C# ASP.Net MVC .Net Framework

此登入范例为求重点展示 MOTP 所以没有使用资料库,大家了解 MOTP 规则後,可以应用在实务专案上。
文末会提供范例档下载连结。

行动一次性密码 (MOTP) 是什麽

行动一次性密码(英语:Mobile One-Time Password,简称MOTP),又称动态密码或单次有效密码,利用行动装置上产生有效期只有一次的密码。
有效期采计时制,通常可设定为 30 秒到两分钟不等,时间过了之後就失效,下次需要时再产生新密码。

MOTP 产生密码的方法是手机取得注册时的密钥 (Token) 与 Pin 之後,以当下时间利用 MD5 加密後产生密码,并取前 6 码为一次性密码。

有关 MOTP 的原文介绍可参考: Mobile One Time Passwords

C# 产生使用者注册 QR Code

在范例页面中输入 UserID, Pin 及 密钥,就可以产生 OTP Authenticator 可注册的 QR Code。

Pin 要求为 4 码数字。密钥要求为 16 或 32 码字元,范例中会随机产生 16 码乱数。

接下来看一下程序码部份

HTML

<div class="panel panel-default">
	<div class="panel-heading">建立使用者</div>
	<div class="panel-body">
		<div class="row">
			<div class="col-md-4">
				<div class="form-group">
					<label>登入 ID:</label>
					<input type="text" class="form-control" v-model="form.UserID">
				</div>
			</div>
			<div class="col-md-4">
				<div class="form-group">
					<label>PIN (4 个数字):</label>
					<input type="text" class="form-control" v-model="form.UserPin">
				</div>
			</div>
			<div class="col-md-4">
				<label>密钥 (16 个字元):</label>
				<div class="input-group">
					<input type="text" class="form-control" v-model="form.UserKey">
					<div class="input-group-btn">
						<button class="btn btn-default" type="button" v-on:click="ChgKey()">
							更换
						</button>
					</div>
				</div>
			</div>
		</div>
		<button type="button" class="btn btn-primary" v-on:click="GenUserQRCode()">产生使用者 QR Code</button>
		<br />
		<img class="img-thumbnail" style="width: 300px;height:300px;" v-bind:src="form.QrCodePath">
	</div>
</div>

Javascript

// 产生使用者 QR Code
, GenUserQRCode: function () {
	var self = this;
	var postData = {};
	postData['UserID'] = self.form.UserID;
	postData['UserPin'] = self.form.UserPin;
	postData['UserKey'] = self.form.UserKey;
	$.blockUI({ message: '处理中...' });
	$.ajax({
		url:'@Url.Content("~/Home/GenUserQRCode")',
		method:'POST',
		dataType:'json',
		data: { inModel: postData, __RequestVerificationToken: self.GetToken() },
		success: function (datas) {
			if (datas.ErrMsg != '') {
				alert(datas.ErrMsg);
				$.unblockUI();
				return;
			}
			self.form.QrCodePath = datas.FileWebPath;
			$.unblockUI();
		},
		error: function (err) {
			alert(err.responseText);
			$.unblockUI();
		},
	});
}
// 更换密钥
, ChgKey: function () {
	var self = this;
	var key = self.MarkRan(16);
	self.form.UserKey = key;
}
// 随机密钥
, MarkRan: function (length) {
	var result = '';
	var characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
	var charactersLength = characters.length;
	for (var i = 0; i < length; i++) {
		result += characters.charAt(Math.floor(Math.random() * charactersLength));
	}
	return result;
}

C# Controller

/// <summary>
/// 产生使用者 QR Code
/// </summary>
/// <param name="inModel"></param>
/// <returns></returns>
[ValidateAntiForgeryToken]
public ActionResult GenUserQRCode(GenUserQRCodeIn inModel)
{
	GenUserQRCodeOut outModel = new GenUserQRCodeOut();
	outModel.ErrMsg = "";
	if (inModel.UserKey.Length != 16)
	{
		outModel.ErrMsg = "密钥长度需为 16 码";
	}
	if (inModel.UserPin.Length != 4)
	{
		outModel.ErrMsg = "PIN 长度需为 4 码";
	}
	int t = 0;
	if (int.TryParse(inModel.UserPin, out t) == false)
	{
		outModel.ErrMsg = "PIN 需为数字";
	}

	if (outModel.ErrMsg == "")
	{
		// 产生注册资料 For OTP Authenticator
		string motpUser = "<?xml version=\"1.0\" encoding=\"utf-8\"?><SSLOTPAuthenticator><mOTPProfile><ProfileName>{0}</ProfileName><PINType>0</PINType><PINSecurity>0</PINSecurity><Secret>{1}</Secret><AlgorithmMode>0</AlgorithmMode></mOTPProfile></SSLOTPAuthenticator>";
		motpUser = string.Format(motpUser, inModel.UserID, inModel.UserKey);

		// QR Code 设定
		BarcodeWriter bw = new BarcodeWriter
		{
			Format = BarcodeFormat.QR_CODE,
			Options = new QrCodeEncodingOptions //设定大小
			{
				Height = 300,
				Width = 300,
			}
		};
		//产生QRcode
		var img = bw.Write(motpUser); //来源网址
		string FileName = "qrcode.png"; //产生图档名称
		Bitmap myBitmap = new Bitmap(img);
		string FileWebPath = Server.MapPath("~/") + FileName; //完整路径
		myBitmap.Save(FileWebPath, ImageFormat.Png);
		string FileWebUrl = Url.Content("~/") + FileName; // 产生网页可看到的路径
		outModel.FileWebPath = FileWebUrl;
	}

	// 输出json
	ContentResult resultJson = new ContentResult();
	resultJson.ContentType = "application/json";
	resultJson.Content = JsonConvert.SerializeObject(outModel); ;
	return resultJson;
}

程序码使用到 QR Code 元件,使用 NuGet 安装 ZXing.Net 元件,安装方法可参考: [C#]QR Code 网址产生与解析

此段程序码要先产生可读取的 XML 档,例如

再将此 XML 转为 QR Code 即可。

C# Model

public class GenUserQRCodeIn
{
	public string UserID { get; set; }
	public string UserPin { get; set; }
	public string UserKey { get; set; }
}

public class GenUserQRCodeOut
{
	public string ErrMsg { get; set; }
	public string FileWebPath { get; set; }
}

程序产生 QR Code 之後,接下来就要利用 OTP Authenticator App 来操作了。

手机下载安装 OTP Authenticator

App 名称: OTP Authenticator
官网连结: https://www.swiss-safelab.com/en-us/products/otpauthenticator.aspx
App 性质: 免费软件
iOS App Store 下载: https://apps.apple.com/tw/app/otp-authenticator/id915359210
Android APK 下载: https://www.swiss-safelab.com/en-us/community/downloadcenter.aspx?Command=Core_Download&EntryId=1105

OTP Authenticator 是针对 Mobile-OTP,行动装置双因素身份验证规则而开发的免费 App,由 Swiss SafeLab 所开发。
Android 版本在 Google Play 没有连结,若下载连结失效,可至官网重新下载 Apk

OTP Authenticator 注册使用者帐号

打开 OTP Authenticator 後,左侧选单点击「Profiles」

下方点击「Create Profile」

点击「Scan Profile」

扫描网页上提供的 QR Code
完成後即会增加使用者列表

点击名称後,输入注册时的 Pin 4位数字,例如范例上的 「0000」

App 即会产生一次性密码,每 30 秒会更换新密码。此新密码在网页上使用者登入时会用到。

网页使用者登入验证 MOTP

在画面上输入登入ID, MOTP (手机上的一次性密码),再验证登入是否成功。

接下来看一下程序码部份

HTML

<div class="panel panel-default">
	<div class="panel-heading">验证登入</div>
	<div class="panel-body">
		<div class="row">
			<div class="col-md-4">
				<div class="form-group">
					<label>登入 ID:</label>
					<input type="text" class="form-control" v-model="form.UserID">
				</div>
			</div>
			<div class="col-md-4">
				<div class="form-group">
					<label>MOTP (6 个字元):</label>
					<input type="text" class="form-control" v-model="form.MOTP">
				</div>
			</div>

		</div>
		<button type="button" class="btn btn-primary" v-on:click="CheckLogin()">验证登入</button>
		<br /><br />
		<span style="color:red;">检核结果:{{form.CheckResult}}</span>

	</div>
</div>

Javascript

// 验证登入
, CheckLogin: function () {
	var self = this;
	var postData = {};
	postData['UserID'] = self.form.UserID;
	postData['UserPin'] = self.form.UserPin;
	postData['UserKey'] = self.form.UserKey;
	postData['MOTP'] = self.form.MOTP;
	$.blockUI({ message: '处理中...' });
	$.ajax({
		url:'@Url.Content("~/Home/CheckLogin")',
		method:'POST',
		dataType:'json',
		data: { inModel: postData, __RequestVerificationToken: self.GetToken() },
		success: function (datas) {
			if (datas.ErrMsg != '') {
				alert(datas.ErrMsg);
				$.unblockUI();
				return;
			}
			self.form.CheckResult = datas.CheckResult;
			$.unblockUI();
		},
		error: function (err) {
			alert(err.responseText);
			$.unblockUI();
		},
	});
}

C# Controller

public decimal timeStampEpoch = (decimal)Math.Round((DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds, 0); //Unix timestamp

/// <summary>
/// 验证登入
/// </summary>
/// <param name="inModel"></param>
/// <returns></returns>
[ValidateAntiForgeryToken]
public ActionResult CheckLogin(CheckLoginIn inModel)
{
	CheckLoginOut outModel = new CheckLoginOut();
	outModel.ErrMsg = "";
	if (inModel.MOTP == null || inModel.MOTP.Length != 6)
	{
		outModel.ErrMsg = "MOTP 长度需为 6 码";
	}
	if (inModel.UserKey.Length != 16)
	{
		outModel.ErrMsg = "密钥长度需为 16 码";
	}
	if (inModel.UserPin.Length != 4)
	{
		outModel.ErrMsg = "PIN 长度需为 4 码";
	}
	int t = 0;
	if (int.TryParse(inModel.UserPin, out t) == false)
	{
		outModel.ErrMsg = "PIN 需为数字";
	}

	if (outModel.ErrMsg == "")
	{
		outModel.CheckResult = "登入失败";

		String otpCheckValueMD5 = "";
		decimal timeWindowInSeconds = 60; //1 分钟前的 motp 都检查
		for (decimal i = timeStampEpoch - timeWindowInSeconds; i <= timeStampEpoch + timeWindowInSeconds; i++)
		{
			otpCheckValueMD5 = (Md5Hash(((i.ToString()).Substring(0, (i.ToString()).Length - 1) + inModel.UserKey + inModel.UserPin))).Substring(0, 6);
			if (inModel.MOTP.ToLower() == otpCheckValueMD5.ToLower())
			{
				outModel.CheckResult = "登入成功";
				break;
			}
		}
	}

	// 输出json
	ContentResult resultJson = new ContentResult();
	resultJson.ContentType = "application/json";
	resultJson.Content = JsonConvert.SerializeObject(outModel); ;
	return resultJson;
}

/// <summary>
/// MD5 编码
/// </summary>
/// <param name="inputString"></param>
/// <returns></returns>
public string Md5Hash(string inputString)
{
	using (MD5 md5 = MD5.Create())
	{
		byte[] input = Encoding.UTF8.GetBytes(inputString);
		byte[] hash = md5.ComputeHash(input);
		string md5Str = BitConverter.ToString(hash).Replace("-", "");
		return md5Str;
	}
}

在验证 OTP 时需要确认手机的时区和服务器时区是一样的,这样才能检查过去时间内有效的 OTP。
检核使用 timestamp 加上密钥及 Pin 用 MD5 加密,再取前 6 码做为一次性密码。
范例中以输入的 OTP 与过去 1 分钟内其中一组密码相等即为登入成功。

C# Model

public class CheckLoginIn
{
	public string UserID { get; set; }
	public string UserPin { get; set; }
	public string UserKey { get; set; }
	public string MOTP { get; set; }
}

public class CheckLoginOut
{
	public string ErrMsg { get; set; }
	public string CheckResult { get; set; }
}

重点整理

  1. 产生使用者注册 QR Code
  2. 安装 OTP Authenticator
  3. OTP 扫描 QR Code
  4. 网页登入验证身份

范例下载

付费後可下载此篇文章教学程序码

相关学习文章

如何避免 MS-SQL 暴力登入攻击 (尝试评估密码时发生错误、找不到符合所提供名称的登入)
[C#]QR Code 制作与 Base 64 编码应用 (附范例)


<<:  从盲人摸象到看见大象全貌

>>:  使用准确的 Microsoft MB-300 考试转储立即成功

CMoney工程师战斗营weekly2

在现实世界与抽象空间游走的一周 匆匆忙忙行军式的步伐迈向抽象类别的世界,老实说真的有点挫折,跟不太上...

注意!所以会点开来!

Day15 - 注意力连接情绪的发想 在教授丹尼尔,列维汀着作的《有序》一书当中,我发现很有趣的一点...

Day25-memo

前言 前面我们学习很多关於React生命周期、状态、取得DOM元素等等,今天我们要来改善React本...

Day28 资料流重新导向I

今天要来介绍的部分是资料流重新导向的部分,这个东西其实就是字面上的意思,就是将某个指令成功执行後所要...

Azure 管理资源方式比较差异

Azure 管理资源方式比较差异 首先对 Azure 管理资源的方式有个概念,好方便之後介绍 Azu...