SHUHARI 的博客

流光飞舞

用 C# 自己动手编写一个 Web 服务器,第六部分——用户验证

本博客文章除特殊说明外,均系原创,允许转载,但请注明来源。
2017-12-04
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 为我们提供的 免费电子书,也感谢读者能够耐心阅读到最后。

Web Servers Succinctly

系列文章

评论
评论须知:
  1. 本博客只接受纯文本形式的评论;
  2. 评论须经管理员审核通过后才会显示;
  3. 若评论内容涉及灌水、广告、不文明用语及违反法律规定的内容,管理员有权直接删除,而无需通知您;
  4. 如果您对评论处理有异议,请联系站长。
shuhari (shuhari.dev@outlook.com) 2018-03-05 08:14 None
@ransurotto 感谢你的意见。我的博客布局是从 Hexo 拷贝的,也考虑过自己重新设计,但因为业余时间有限一直没提上日程。我会把你的意见放到 TODO 里面去的。
zicong.zheng@qq.com ransurotto (zicong.zheng@qq.com) 2018-03-02 14:45
您的博客和文章很有意思,我经常会看这些有趣的内容。不过我只能看懂您所表达的介绍信息,您的 Pre 标签的样式过于影响阅读,很打击我的阅读体验。我只想看有趣的内容,我并不会去Clone项目。所以我希望您能给我更好的阅读体验。谢谢您