ASP.NET MVC 表单认证
2021-03-14

开始之前,先发一句牢骚,平时我们用微软技术栈(ASP.NETC#.NET Core),就是因为看中了绝大多数公司办公环境是 Windows 桌面系统,使用微软技术栈能够更好的与 OASMTP 邮箱、ERP 等企业内部系统深度集成。省去了用户权限设计不说(和其他系统共用一个 AD 域账户),还能方便开发者快速搭建原型。所以,我之前编写的 ASP.NET 网站基本上都是使用的 Windows 认证。然而,前几天某客户 IT 的标准要求,不能使用 Windows 认证。具体来说,不是不能使用 Windows 认证,是不能使用各浏览器自带的用户权限输入框来填写用户名密码。我们写的 APP 必须有自己的登陆/登出页面,同时为了使用 AD 域账户,我们的 APP 拿到用户名和密码后去 AD 里验证合法性。当时听到这个整个,心中一百个 CNM 在奔腾。你们真的确定吗???

先不说这个标准是谁定的,脑子瓦塌了?你真的放心让我来接触域账户密码(明文)?!我自己都害怕。。。不担心我私自存储用户密码?不担心用户填写信息时候被窃听?至少你使用 SSO (Single-Sign On,单点登录)都比这个强啊!

更改 web.config

打开目标站点的 web.config 文件,更新 authenticationauthorization 节点配置如下:

1
2
3
4
5
6
7
8
9
10
<system.web>
<httpCookies requireSSL="true" httpOnlyCookies="true" />
<authentication mode="Forms">
<forms name="at" requireSSL="true" loginUrl="~/v3/Home/Login" slidingExpiration="true" timeout="32" defaultUrl="/v3/app/en/index.html" path="/v3">
</forms>
</authentication>
<authorization>
<deny users="?"/>
</authorization>
</system.web>

更改 IIS 配置

运行 inetmgr 命令,进入 IIS,选中需要更改的目标站点,点击认证(Authentication),启用匿名认证(Anonymous Authentication)和表单认证(Forms Authentication),禁用其他认证方式(如 Windows 认证、Basic 认证等等)。

更新相关代码

新建/打开 Views/Home/Login.cshtml 文件,更新代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@{
Layout = null;
}
<!DOCTYPE html>

<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Please login</title>
<style>
#login-form {
width: 16em;
margin: 0 auto;
padding: 1em;
box-shadow: 0 0 0.5em 0.125em gray;
}

#login-form img {
width: 4em;
}

#login-form h2 {
text-align: center;
}

#login-form fieldset {
border: none;
}

#login-form input {
width: 100%;
box-sizing: border-box;
border: none;
border-bottom: 1px solid gray;
outline: none;
font-size: 1.125em;
}

#login-form input[type=submit] {
border: none;
background-color: skyblue;
color: white;
cursor: pointer;
padding: 0.5em;
font-size: 1em;
}

#login-form footer {
text-align: center;
}
</style>
</head>
<body>
<div id="login-form">
<h2>XXXX Platform</h2>
@using (Html.BeginForm("Login", "Home", FormMethod.Post))
{
@Html.AntiForgeryToken()
<fieldset> Login </fieldset>
<p>
<input type="text" name="username" placeholder="user name" required maxlength="16" minlength="4" />
</p>
<p>
<input type="password" name="password" placeholder="password" required maxlength="32" minlength="10" />
</p>
<input type="hidden" name="ts" />
<input type="submit" value="→ LOGIN" />
}
<footer>
<p>Copyright &copy; XXXX @(DateTime.Now.Year)</p>
</footer>
</div>
<script>
document.forms[0].addEventListener('submit', function () {
document.forms[0].ts.value = Date.now();
});
</script>
</body>
</html>

新建/打开 Controllers/HomeController.cs,更新代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
/// <summary>
/// default controller
/// </summary>
public class HomeController : Controller
{
/// <summary>
/// default action for HomeController
/// </summary>
/// <returns></returns>
public ActionResult Index()
{
ViewBag.Title = "Home Page";
return View();
}
/// <summary>
/// browse login page
/// </summary>
/// <returns></returns>
[AllowAnonymous]
public ActionResult Login()
{
if (User.Identity != null && User.Identity.IsAuthenticated)
{
// user already logged in
this.clearUserState();
// return Redirect(FormsAuthentication.DefaultUrl);
}
return View();
}
/// <summary>
/// authenticate user with Authorization header
/// </summary>
/// <returns></returns>
[HttpPost,ValidateAntiForgeryToken]
public ActionResult Login(LoginFormData form)
{
// login form validation
if (form == null)
throw new AuthenticationException("empty login form");
if (string.IsNullOrWhiteSpace(form.username))
throw new AuthenticationException($"empty {nameof(form.username)}");
if (string.IsNullOrWhiteSpace(form.password))
throw new AuthenticationException($"empty {nameof(form.password)}");
if (form.ts == 0)
throw new AuthenticationException($"empty {nameof(form.ts)}(timestamp)");
// permit only the characters required and field length necessary
if (!System.Text.RegularExpressions.Regex.Match(form.username, @"^[a-zA-Z0-9\-\.]{4,16}$").Success)
throw new AuthenticationException($"invalid {nameof(form.username)}");
if (!(
/* a mimumum of 10 and maximum of 32 characters */
/* printable characters, including space */
System.Text.RegularExpressions.Regex.Match(form.password, @"^[\s!-~]{10,32}$").Success
/* at least 1 lower case alpha character (A-Z) */
/* at least 1 upper case alpha character (A-Z) */
/* at least 1 numberic character(0-9) */
&& System.Text.RegularExpressions.Regex.Match(form.password, @"^(?=.*[a-z]+)(?=.*[A-Z]+)(?=.*[0-9]+)").Success
))
throw new AuthenticationException($"invalid {nameof(form.password)}");
// prevent form replay attach
long unixTime = ((DateTimeOffset)DateTime.UtcNow).ToUnixTimeSeconds();
if (Math.Abs(unixTime - (form.ts) / 1000) > 10)
// the timestamp gap between client and server is greater than 10 seconds
throw new AuthenticationException("NO Form Replay Attach!!!");

using (var pCtx = new PrincipalContext(ContextType.Domain, Constants.ADDomain))
{
// validate credential against AD server
bool flag = pCtx.ValidateCredentials(form.username, form.password);
if (flag)
{
// validate successfully
var ticket = new FormsAuthenticationTicket
(
1,
form.username,
DateTime.Now,
DateTime.Now.AddMinutes(Constants.SessionDurationInMinutes),
Constants.CreatePersistentCookie,
"salt", // user specified data
FormsAuthentication.FormsCookiePath
);
var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, FormsAuthentication.Encrypt(ticket))
{
Secure = true,
HttpOnly = true,
Path = ticket.CookiePath
};
Response.Cookies.Add(cookie);
// save login ticket
// to prevent concurrent logins from same user
Biz.Helper.UACHelper.UserLogin(cookie.Value);
Logger.Info("Audit Success", form.username, this.Request?.UserHostAddress); // success login audit
return Redirect(FormsAuthentication.DefaultUrl);
}
else
{
// validate failed
Logger.Info("Audit Failure", form.username, this.Request?.UserHostAddress); // failed login audit
return RedirectToAction("Login");
}
}
}
/// <summary>
/// logout page
/// </summary>
/// <returns></returns>
[AllowAnonymous]
public ActionResult Logout()
{
this.clearUserState();
Logger.Info("logout audit", this.User?.Identity?.Name, this.Request?.UserHostAddress); // logout audit
return View();
}
private void clearUserState()
{
FormsAuthentication.SignOut();
// clear authentication cookie
HttpCookie authTokenCookie = new HttpCookie(FormsAuthentication.FormsCookieName, string.Empty) { Secure = true, HttpOnly = true };
authTokenCookie.Expires = DateTime.Now.AddYears(-1);
Response.Cookies.Add(authTokenCookie);
// clear session
Session.Clear();
Session.Abandon();
//Session.RemoveAll();
//HttpContext.User = new System.Security.Principal.GenericPrincipal(new System.Security.Principal.GenericIdentity(string.Empty), null);
}
}

备注

  1. 应整改要求,Cookie Path 没有特殊批复,不得设置为根路径(/),故此处的 Cookie Path 设置为 /v3 (所有页面放置于 /v3/app 目录下,API 在 /v3/api 路径下);
  2. 默认开启 Secure 和 HttpOnly 属性,防止 CSRF;
  3. 此处示例代码,表面上启用表单验证,其实后台服务器将用户填写的表单发送至服务器的域服务器(AD Server)验证。各位看官可以根据自身实际情况调整;
  4. 待调整事项:此处 Login.cshtml 使用了内联脚本。但如果公司启用了严格 CSP,该内联脚本将会被禁用,可考虑移至外部脚本中。

参考链接

本文链接:
content_copy https://zxs66.github.io/2021/03/14/ASP-NET-MVC-Forms-Authentication/