65.9K
CodeProject 正在变化。 阅读更多。
Home

在不使用 Cookie 的情况下识别用户计算机

starIconstarIcon
emptyStarIcon
starIcon
emptyStarIconemptyStarIcon

2.64/5 (6投票s)

2007 年 9 月 3 日

CPOL

5分钟阅读

viewsIcon

43390

downloadIcon

627

在不使用 Cookie 的情况下识别用户计算机

Screenshot - IdentifyMachine.jpg

引言

几天前,我接到一项任务,要求找到一种不使用 cookie 或 IP 地址即可识别用户计算机的特殊方法。当用户首次访问 Web 应用程序时,用户会收到“不,您以前未连接过,是新用户”的消息,否则,如果不是第一次访问,用户将收到“是,您以前连接过”的消息。因此,我研究了 HTTP/1.0 协议以获得一些线索,并根据以下缓存信息提出了识别计算机的思路。

背景

Web 浏览器(HTTP 客户端应用程序)与 Web 服务器(HTTP 服务器应用程序)之间的通信以请求和响应的形式进行。请求从 Web 浏览器发送到 Web 服务器,响应从 Web 服务器返回到 Web 浏览器。每个请求或响应都包含两个部分:标头和数据。标头提供有关请求或响应的额外信息和设置。数据部分包含实际需要的数据。

大多数浏览器会缓存它们从 Web 服务器接收到的 Internet 数据。因此,当用户再次访问之前访问过的内容时,Web 浏览器会询问 Web 服务器自上次接收以来内容是否已更改,并将上次访问的时间戳与“内容请求”一起发送到 Web 服务器,这称为条件 GET。只有当内容自浏览器接收到时间戳以来发生修改时,Web 服务器才会返回内容。通过这种方式,浏览速度得以加快,因为如果缓存的数据未修改,则无需下载。在将内容传递给浏览器的过程中,Web 服务器可能会告知 Web 浏览器内容的最后修改日期,如果这样做,Web 浏览器(如 MSIE 和 Mozilla Firefox 等著名浏览器)会将相同的时间戳返回给 Web 服务器,以查询自该时间以来内容是否已修改。

内容的最后修改日期通过 Last-Modified 标头从 Web 服务器发送到 Web 浏览器。在第二次访问时,Web 浏览器会通过在 If-Modified-Since 标头中指定最后修改时间戳来请求内容。

Using the Code

这是一个包含 `static` 函数的小程序。代码将在下面简要解释。以下系统命名空间被使用

using System;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Net;
using System.IO;

`System.Collections.Generic` 命名空间用于包含 `Dictionary` 对象。`System.Net` 和 `System.Net.Socket` 用于实现 TCP/IP 网络功能。`System.IO` 用于包含 `TextReader` 和 `TextWriter` 功能。

主过程描述如下

public static Dictionary<string, string> headers = null;
static void Main(string[] args)
{
    TcpListener listener = null;
    try
    {
        IPAddress address = Dns.GetHostEntry( Dns.GetHostName() ).AddressList[0];
        Console.WriteLine("Info: server start at IP: " + address + " Port: 80");
        listener = new TcpListener(address, 80);
        listener.Start();
        while (true)
        {
            try
            {
                Socket conn = listener.AcceptSocket();
                Console.WriteLine("*************************************");
                Console.WriteLine("Info: Connection established, 
		Connected to IP: " + ((IPEndPoint)conn.RemoteEndPoint).Address +
                    " Port: " + ((IPEndPoint)conn.RemoteEndPoint).Port);
                Console.WriteLine("*************************************");
                NetworkStream stream = new NetworkStream(conn);
                TextReader reader = new StreamReader(stream);
                TextWriter writer = new StreamWriter(stream);
                if (ParseRequest(reader))
                {
                    if ("GET" == method)
                    {
                        headers = new Dictionary<string, string>();
                        while (ReadNParseHeader(reader)) ;
                        if ("/" == resource)
                        {
                            SendHTMLIdentifyUser(writer);
                        }
                        else
                        {
                            Console.WriteLine("Warning: Invalid resource: \"" 
				+ resource + "\" requested");
                        }
                    }
                    else
                    {
                        Console.WriteLine
			("Warning: Only GET method supported, Closing connection");
                    }
                }
                
                Console.WriteLine("Info: Closing Connection Successfully");
                Console.WriteLine("-------------------------------------");
                writer.Close();
                reader.Close();
                stream.Close();
                conn.Close();
            }
            catch (Exception exception)
            {
                Console.WriteLine("Warning: " + exception.Message);
            }
        }
    }
    catch (Exception exception)
    {
        Console.WriteLine("ERROR: " + exception.Message);
    }
    finally
    {
        if (null != listener)
        {
            Console.WriteLine("Info: Stopping listener");
            listener.Stop();
            listener = null;
        }
    }
    Console.WriteLine("Program Ended, Press ENTER to exit");
    Console.ReadLine();
}

`'header'` 对象是 `Dictionary` 类型,其键和值都是 `string` 类型;它将在程序稍后使用。

首先,获取 IP 地址并与端口一起打印到控制台,以便用户知道服务器正在监听哪个套接字(以避免混淆,以防计算机分配了多个 IP 地址)。然后,在套接字上创建并启动监听器。然后获取远程套接字以服务它。然后创建 `TextReader` 和 `TextWriter` 对象,以便使用文本流进行网络通信;因为大多数 HTTP 通信通常是纯文本。

第一个元素是来自 HTTP 客户端(Web 浏览器)的基本请求。这包括方法、内容标识符和位置,以及 HTTP 版本。`ParseRequest` 过程解析此基本请求,并将方法、资源和协议分别放入 `method`、`resource` 和 `httpProtocol` `static string` 对象中。

HTTP 服务器有三种基本类型的请求:`GET`、`HEAD` 和 `PUT`。这里,简单的程序仅支持 `GET` 请求。

在服务远程套接字时,首先使用 `ReadNParseHeader` 读取和解析请求标头,它将每个标头的标题和值放入 header 字典对象中。“/”资源指定了这里唯一支持的默认内容。然后使用 `SendHTMLIdentifyUser` 过程将 HTML 发送给客户端。

下面描述 `ParseRequest` 过程

public static string method,
                resourceLoc,
                resource,
                queryString,
                httpProtocol; // HTTP/1.0, always assuming it
private static bool ParseRequest(TextReader reader)
{
    string request = ReadUntilCRLF(reader);
    Console.WriteLine("Info: Request received: \"" + request + "\"");
    string[] tokens = request.Split(new string[] { " " }, 
		StringSplitOptions.RemoveEmptyEntries);
    if (3 != tokens.Length)
    {
        Console.WriteLine("Warning: Request must split in 3 tokens");
        return false;
    }
    // method
    method = tokens[0].ToUpper();
    // query string
    queryString = "";
    int indexEnd = tokens[1].IndexOf('?');
    if (indexEnd < 0)
    {
        indexEnd = tokens[1].Length;
    }
    else
    {
        queryString = tokens[1].Substring(indexEnd, tokens[1].Length - indexEnd);
    }
    // resource
    int indexLastSeperator = tokens[1].LastIndexOf('/');
    int resLen = indexEnd - indexLastSeperator;
    resource = tokens[1].Substring(indexLastSeperator, resLen);
    // resourceLocation
    if (0 == tokens[1].ToLower().IndexOf("http://")) // absolute path in request
    {
        int indexSeperator = tokens[1].IndexOf('/', 7); // http:// are 7 chars
        resourceLoc = tokens[1].Substring(indexLastSeperator, 
		indexEnd - indexLastSeperator - resLen);
    }
    else
    {
        resourceLoc = tokens[1].Substring(0, indexEnd - resLen);
    }
    // protocol
    httpProtocol = tokens[2].ToUpper();
    Console.WriteLine("Info: Method: " + method);
    Console.WriteLine("Info: Resource Location: " + resourceLoc);
    Console.WriteLine("Info: Resource: " + resource);
    Console.WriteLine("Info: Query String: " + queryString);
    Console.WriteLine("Info: Protocol: " + httpProtocol);
    return true;
}

正如我之前所说,Web 浏览器发送给 Web 服务器的第一个实体是基本请求,它由请求方法、资源标识符和 HTTP 版本组成。查询字符串中的其他内容可能包括资源位置和查询字符串。资源位置可以是相对的或绝对的。`ParseRequest` 函数仅分离此信息并将其存储在相应的 `static string` 对象中,即 `method`、`resourceLoc`、`resource`、`queryString` 和 `httpProtocol`。

下面描述 `ReadNParseHeader` 过程

private static bool ReadNParseHeader(TextReader reader)
{
    string header = ReadUntilCRLF(reader);
    if (header.Length > 0)
    {
        Console.WriteLine("Info: Header received: \"" + header + "\"");
        string[] tokens = header.Split(new string[] { ": " }, 
		StringSplitOptions.RemoveEmptyEntries);
        if (tokens.Length == 2)
        {
            headers.Add(tokens[0].ToUpper(), tokens[1]);
        }
        else
        {
            Console.WriteLine("Warning: Cannot Parse header");
        }
        return true; // headers follow
    }
    else
    {
        return false; // end of headers
    }
}

HTTP 请求和响应标头遵循严格的格式。每个标头由标头标题后跟冒号 `:` 然后是空格,最后是该标头的值组成。每个标头都以回车符和换行符 '\r\n' 终止。标头部分的结束由最后一个标头后的额外回车符和换行符指定。`ReadNParseHeader` 解析每个标头并将标头标题和标头值存储在字典中。如果存在更多标头,此过程将返回 `true`,否则返回 `false`。

`ParseRequest` 和 `ReadNParseHeader` 过程使用下面描述的 `ReadUntilCRLF` 过程

private static string ReadUntilCRLF(TextReader reader)
{
    string strLine = "";
    char prevChar = '\0',
            currChar = (char)reader.Read();
    while (!('\r' == prevChar && '\n' == currChar))
    {
        strLine += currChar;
        prevChar = currChar;
        currChar = (char)reader.Read();
    }
    strLine = strLine.Substring(0, strLine.Length - 1); // remove prevChar = "\r"
    return strLine;
}

此函数逐个字符地读取文本流,直到找到回车符和换行符。它返回直到遇到的分隔符 ('\r\n') 之前的所有字符串。

实际导致识别计算机的函数是 `SendHTMLIdentifyUser`;它在下面描述

private static void SendHTMLIdentifyUser(TextWriter writer)
{
    string html = "<HTML><BODY>Hello! How are you? ";
    bool userNew = true;
    string keyIfModifiedSince = "IF-MODIFIED-SINCE";
    foreach (string key in headers.Keys)
    {
        if (key == keyIfModifiedSince)
        {
            userNew = false;
            break;
        }
    }
    int currentId = -1;
    string lastModified = DateTime.Now.ToString("R");
    writer.Write("HTTP/1.0 200 OK\r\n");
    writer.Write("Content-Type: text/HTML\r\n");
    writer.Write("Last-Modified: " + lastModified + "\r\n");
    string strIden = "";
    if (userNew)
    {
        currentId = machineId++;
        strIden = "No, you have not connected before and are a new user";
    }
    else
    {
        string lastDate = headers[keyIfModifiedSince];
        int indexSep = lastDate.IndexOf(';');
        if (indexSep < 0)
        {
            indexSep = lastDate.Length;
        }
        lastDate = lastDate.Substring(0, indexSep);
        try
        {
            currentId = machineIdentification[lastDate];
            machineIdentification.Remove(lastDate);
            strIden = "Yes, you have connected before";
        }
        catch (Exception)
        {
            currentId = machineId++;
            strIden = "No, you have not connected before and are a new user";
        }
    }
    html += strIden + "</BODY></HTML>";
    machineIdentification.Add(lastModified, currentId);
    writer.Write("Content-Length: " + html.Length + "\r\n");
    writer.Write("\r\n");
    writer.Write(html);
    Console.WriteLine("Info: Machine Id: " + currentId);
}

上述函数中应用的技巧是,首先,在标头字段中搜索 IF-MODIFIED-SINCE 标头,它的存在意味着用户之前已经访问过;另一方面,它的不存在意味着用户是第一次访问。如果用户已经访问过该网站,IF-MODIFIED-SINCE 请求标头中的时间戳将帮助我们获取用户配置文件(在本例中为用户 ID);否则,如果用户是第一次访问,我们就必须为用户创建一个新配置文件(在本例中为新用户 ID)。获取当前时间戳,并将用户配置文件与此时间戳进行映射,然后将时间戳作为 Last-Modified 标头发送给用户;以及用户请求的资源。这样就可以借助 IF-MODIFIED-SINCE 请求标头和 Last-Modified 响应标头来识别用户。

关注点

值得注意的是,MSIE 还会将收到的最后一个内容长度连同时间戳一起发送给 Web 服务器。

历史

  • 2007 年 9 月 2 日:首次发布
© . All rights reserved.