用户验证和授权
在上一篇文章中,我们添加了视图引擎支持,可以输出真正的动态页面了。再加上控制器(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 为我们提供的 免费电子书 ,也感谢读者能够耐心阅读到最后。
系列文章
注:本文内容来自互联网,旨在为开发者提供分享、交流的平台。如有涉及文章版权等事宜,请你联系站长进行处理。