用 C# 自己动手编写一个 Web 服务器,第六部分——用户验证
用户验证和授权
在 上一篇文章中,我们添加了视图引擎支持,可以输出真正的动态页面了。再加上控制器(Controller)的支持,现在应用程序开发者可以自由执行业务逻辑,并输出想要的页面效果,可以说,一个真正的 Web 服务器已经基本成型了。不过,大多数业务系统还需要用户验证(Authentication)和授权(Authorization)的功能,允许用户在系统中登录和注销,并根据用户权限判断他(她)能够执行的操作。
我们在这里不考虑用户授权(Authorization)的实现。因为对于授权机制的处理和资源的划分,各个系统存在较大差异,不太适合放到底层来实现,留给业务层决定是更合理的作法。另一方面,用户的登录/注销几乎是任何系统的基本功能,功能上不会有太大变化,如果也放到业务层来实现,就意味着每个系统都需要重复实现一遍,这明显是对开发资源的浪费。在前面的文章中,我们已经实现了 Session 的支持,现在我们可以在 Session 基础之上再增加处理 User 的功能。
在本文系列的 中间件 部分我们也说过,Web 流水管线由一系列中间件(Middleware)构成,每个中间件应该各自实现一个相对独立的功能,各个中间件最好设计成彼此互相独立的,以最大程度地减少耦合,避免引入隐晦的错误。但部分中间件还是存在彼此依赖的关系,典型的例如 Session,假如没有 Session 可用的话,那么用户验证(以及其他一些应用功能)就无从谈起。在配置中间件的时候需要注意这个问题(如果设计完善的话,应考虑一旦发现 Session 不可用,就抛出错误提示,供应用程序开发者排错)。
包含社交功能的 Web 应用大多也会包含第三方登录的机制(基于 OpenID 或 OAuth)。但 OpenID/OAuth 的登录流程和基于服务器自身的验证机制流程上差别极大,基本上可以看成两套完全不同的登录方法。因此也有的设计方法会将登录机制从服务器自身独立出来,作成同样基于 OpenID/OAuth 的服务接口,这样在登录流程上可以和社交登录方法比较统一(当然在界面上还是有所差别)。由于第三方登录的难点主要在于对协议的理解,和 Web 服务器自身关系不大,所以我们这里也不作考虑。
这里再说一点题外话。在前面的文章中,只要可能,我会尽量模仿 ASP.NET MVC 的接口,以便于让读者更贴近实际的服务器实现。但本文中我不会试图模仿框架的接口。在我看来,ASP.NET MVC 最新引入的 Identity 等机制实在设计得过于复杂了。微软的本意可能是想设计一个无所不包的强大架构,但对于大多数需求相对简单的普通 Web 应用来说,有着过度设计的嫌疑,使用并不方便,而所谓的灵活性则未必有设计者预期的那么高。当然,这是我个人的看法,你完全可以有不同的意见。
代码
本文的示例代码已经全部放到 Github,每篇文章关联的代码放在独立的分支,方便读者参考。因此,要获取本文示例代码,请使用如下命令:
git clone https://github.com/shuhari/web-server-succinctly-example.git
git checkout -b 06-authentication origin/06-authentication
实现
我们已经实现了 Session,在其中加入用户信息是很容易的事(如果不考虑安全性问题的话)。不过,为了让用户能够登录,我们还必须处理 Web 服务器发送来的 POST 请求,而 HttpListenerRequest 并未给我们提供这个接口。因此,我们还需要一个中间件来处理 POST 请求,我把它叫做 BodyParser (这个名字是从 NodeJs/Express 那里借用来的)。
HttpListenerRequest 并未包含 POST 过来的表单信息,因此首先对其再次进行封装:
public class HttpServerRequest
{
public HttpServerRequest(HttpListenerRequest request)
{
_innerRequest = request;
Form = new NameValueCollection();
}
private readonly HttpListenerRequest _innerRequest;
public CookieCollection Cookies => _innerRequest.Cookies;
public Uri Url => _innerRequest.Url;
public string HttpMethod => _innerRequest.HttpMethod;
public IPEndPoint RemoteEndPoint => _innerRequest.RemoteEndPoint;
public Stream InputStream => _innerRequest.InputStream;
public NameValueCollection Form { get; private set; }
}
这是一个很简单的封装——大部分属性只是将已经提供的功能再次暴露出来。但 Form 则是新增的,用来包含提交的表单信息。
接下来,HttpServerContext 也稍作修改,让它返回包装后的请求:
public class HttpServerContext
{
public HttpServerContext(HttpListenerContext context)
{
_innerContext = context;
Request = new HttpServerRequest(context.Request);
}
private readonly HttpListenerContext _innerContext;
public HttpServerRequest Request { get; private set; }
...
}
属性已经准备好,可以实现中间件了:
public class BodyParser : IMiddleware
{
public MiddlewareResult Execute(HttpServerContext context)
{
var request = context.Request;
if (request.HttpMethod.Equals("POST", StringComparison.InvariantCultureIgnoreCase))
{
using (var reader = new StreamReader(request.InputStream, Encoding.UTF8))
{
string postData = reader.ReadToEnd();
foreach (var kv in postData.Split('&'))
{
int index = kv.IndexOf('=');
if (index > 0)
{
string key = kv.Substring(0, index);
string value = HttpUtility.UrlDecode(kv.Substring(index + 1));
request.Form[key] = value;
}
}
}
}
return MiddlewareResult.Continue;
}
}
这里的中间件实现了普通的 POST 请求,但并未处理更加复杂的格式(如 multipart/formdata)。如果要实现文件上传,那么你还需要根据协议格式多做一些工作。
为了在控制器和视图中能够访问当前用户,我们再添加几个属性。此外,当用户登录成功之后需要重定向页面,因此再增加一个 ActionResult 的子类(RedirectResult):
public abstract class Controller : IController
{
public HttpServerContext HttpContext { get; internal set; }
protected ISession Session => HttpContext.Session;
protected IPrincipal User => HttpContext.User;
protected HttpServerRequest Request => HttpContext.Request;
protected RedirectResult Redirect(string url)
{
return new RedirectResult(url);
}
}
public class RedirectResult : ActionResult
{
public RedirectResult(string url)
{
_url = url;
}
private readonly string _url;
public override void Execute(HttpServerContext context)
{
context.Response.StatusCode = 301;
context.Response.AddHeader("Location", _url);
context.Response.OutputStream.Close();
}
}
我们原来在控制器里声明了一个计数器 counter 用来检查 Session 工作是否正常,现在这个信息不重要了。相应的,我们需要访问当前用户:
public class HomeController : Controller
{
public ActionResult Index()
{
...
var model = new
{
title = "Homepage",
user = User,
};
return View("Index", model);
}
}
在视图中,我们检查当前用户。如果尚未登录,则显示登录表单;否则,显示注销按钮。
@if (Model.user.Identity.IsAuthenticated)
{
<a href="/Home/Logout">Logout</a>
}
else
{
<form method="post" action="/Home/Login">
<div>
<label for="username">User name:</label>
<input type="text" name="username"/>
</div>
<div>
<label for="password">Password:</label>
<input type="password" name="password"/>
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
}
</html>
Login 方法需要从表单中提取登录信息,如果登录成功,则执行重定向;否则返回原来的表单。Logout 方法则将当前用户注销:
public class HomeController : Controller
{
public ActionResult Login()
{
string userName = Request.Form["username"];
string password = Request.Form["password"];
if (userName == "admin" && password == "1234")
{
Authentication.Login(HttpContext, userName);
}
return Redirect("/Home/Index");
}
public ActionResult Logout()
{
Authentication.Logout(HttpContext);
return Redirect("/Home/Index");
}
}
当然,这里还有一个验证逻辑(Authentication)需要我们去实现。要记录当前用户,再声明两个对象,分别对应于登录用户和匿名用户(未登录)。这是一种 Null Object 模式:即便用户尚未登录,我们也可以获得一个有效的 User 对象,而不至于抛出 NPE。作为示例,我们并不希望真的建立一个数据库去保存用户,所以这里只是一个模拟的用户信息判断。
public class AnonymousUser : IPrincipal
{
public bool IsInRole(string role)
{
return false;
}
public IIdentity Identity => new AnonymousIdentity();
}
class AnonymousIdentity : IIdentity
{
public string Name => "Anonymouse";
public string AuthenticationType => "";
public bool IsAuthenticated => false;
}
public class User : IPrincipal
{
public User(string name)
{
_name = name;
}
private readonly string _name;
public bool IsInRole(string role)
{
return false;
}
public IIdentity Identity => new GenericIdentity(_name, "");
}
然后实现 Authentication。这也是一个中间件,并且提供接口,以便在用户登录/注销时,在 Session 中设置或清除对应的用户信息。实践上可能把接口和实现放在两个类更加合理,这里为了简便就写在一起了:
public class Authentication : IMiddleware
{
private const string _cookieName = "_userid_";
private const string _sessionKey = "_user_";
public MiddlewareResult Execute(HttpServerContext context)
{
IPrincipal user = null;
var cookie = context.Request.Cookies[_cookieName];
if (cookie != null)
{
user = context.Session[_sessionKey] as User;
}
user = user ?? new AnonymousUser();
context.User = user;
return MiddlewareResult.Continue;
}
public static void Login(HttpServerContext context, string userName)
{
var user = new User(userName);
context.Session[_sessionKey] = user;
context.User = user;
var cookie = new Cookie(_cookieName, userName);
context.Response.SetCookie(cookie);
}
public static void Logout(HttpServerContext context)
{
context.Session.Remove(_sessionKey);
context.User = new AnonymousUser();
var cookie = new Cookie(_cookieName, "");
cookie.Expired = true;
context.Response.SetCookie(cookie);
}
}
最后来配置中间件。正如前面提到过的,Authentication 必须在 Session 之后添加:
class Program
{
....
static void RegisterMiddlewares(IWebServerBuilder builder)
{
builder.Use(new HttpLog());
// builder.Use(new BlockIp("::1", "127.0.0.1"));
builder.Use(new SessionManager());
builder.Use(new BodyParser());
builder.Use(new Authentication());
...
}
}
一切完成!现在你可以打开浏览器,体验登录和注销了(当然是写死的用户)。
后记
本系列文章到此结束了。从原理上讲,我们实现的 Web 服务器几乎已经具备了生产服务器应当包含的绝大多数功能,当然,还有很多细节方面是不够完善的。代码统计(下图)表明,除去空白和注释,我们总共用了大约 600 多行代码(以及少量 HTML),实现了一个 Web 服务器的基本骨架。相信大多数读者写出这些代码也并不困难(再重复一次,要正确处理安全性并不容易)。
我相信本文的大多数读者并不会真的自己动手去撸一个服务器(用于生产环境)。不过,自己把路走上一遍,有助于你自己建立信心————这并不需要什么神奇的技能,也不是只有资深大牛才能完成的壮举,而是普通人也可以做到的(据说,包括 JUnit 和 ASP.NET MVC 在内的许多著名框架,都是在飞机上短暂的时间内写下来的)。此外,你对现有的框架也能够有更好的理解,这样当有一天需要你去扩展服务器、或者排除问题时,你也能够有更大的信心。
最后还是感谢 Syncfusion 为我们提供的 免费电子书,也感谢读者能够耐心阅读到最后。