单点户登录解决方案

其实我不太清楚到底有什么专业术语来表述我的问题。这里还是简单的介绍一下问题:现在有一个web系统,系统需求中有要求一个账号只允许一个地方登录,如果另一个地方登录,则显示用户已经登录(和网上查到的把别人被迫下线不同)。第二个需求就是只允许同一台电脑只允许一个账号登录,如果一个号没有退掉,再等则提示不允许再次登录消息框。

首先我这里需要说明一点的是,我这里并没有全部的代码做出来放到项目中,这篇文章只是告诉你一个我认为可行的方案。如果你要完整的项目,那就不要浪费自己时间了。

问题一:(同一个账号只允许一个用户登录)

这里首先要知道一下一些基础的知识,http协议是一个无监听的协议,也就是说服务器不会去监视你的客服端的浏览器到底在干吗(用户是否关闭的浏览器,和我们的qq有些不同)。

问题的难点也就出来了,如果用户成功的登录了,但是非法的退出了(关闭浏览器,注销用户等等),但是我们服务器无法知道这个用户是否在线。网上有些人说添加一个Global.asax文件,然后再web.config中设置session的时间,在Global.asax中的 void Session_End(object sender, EventArgs e)中吧session关掉。本来以为这个方案不错,但是本来测试了一下问题来了。修改Global.asax文件中的 void Session_Start(object sender, EventArgs e)、void Session_End(object sender, EventArgs e)的函数代码如下:

//当发生会话的时候,触发此方法
void Session_Start(object sender, EventArgs e) 
{
       WriteLogToFile wltf = new WriteLogToFile();
       string endContext = "\r\n" + Session.SessionID + "has2 end  ----  " + DateTime.Now.ToString("yyyyMMdd hh:mm:ss");
       wltf.WriteTofile(Server.MapPath("~/Logs/"), endContext);
}

//当会话结束的时候发生此方法
void Session_End(object sender, EventArgs e) 
{
       WriteLogToFile wltf = new WriteLogToFile();
       string endContext = "\r\n"+ Session.SessionID + "has end  ----  " + DateTime.Now.ToString("yyyyMMdd hh:mm:ss");
       wltf.WriteTofile(Server.MapPath("~/Logs/"), endContext);
}

其中writeLogToFile的类库文件内容如下:

//此类用来自己定义的来写内容到文件中
public class WriteLogToFile
{
    public void CreateToFile(string fileName) {
        StreamWriter sw;
        sw = File.CreateText(fileName);
        sw.Close();
    }

    public void WriteTofile(string filepath,string txtContext) {
        string WriteText = txtContext;
        string FileName = filepath  +"sessionFileLog.log";
        if (!File.Exists(FileName)) {
            CreateToFile(FileName);
        }
        File.AppendAllText(FileName, txtContext);
    }
}

再设置一下web.config中的timeout时间为5分钟(你可以设置的更短一点)

 <sessionState mode="InProc" timeout="5" cookieless="false" cookieName="MyTest"></sessionState>

然后你的项目部署上iis上,随便打开一个网页,过5分中以后你去查找一下日志文件,发现了什么……竟然触发了session_start方法,但是却没有触发session_end方法。这个自己也不是很清楚,查了一些资料有的说是微软的一个bug,有的说为当会话结束的时候,服务器不会立即执行session_end函数,服务是客户端选择了安全退出,执行色session.Abandon()函数。这里我们先不谈到底是什么原因,就上面的方法是不可行的。

自己设计的解决办法一:
原先设计了一个方案,但是我们负责人说从性能考虑不是很合理,但是我还是想把这个思想拿出来分享一下,这里用到的技术ajax技术,好吧我想你应该知道了。具体的实现方案是在每个用户界面中需要放置一个ajax代码,这个ajax代码在一定的时间内利用js自动的触发,这里假设是5分中,并且设置session的timeout时间为20分钟(这里并没有吧timeout时间和ajax触发的时间设置的一样是因为我们需要考虑到用户提交丢数据包的问题),ajax的js代码大概如下

 <script type="text/javascript">
        function myAjaxFun() {
            //alert('1');
            var id = document.getElementById("sessionId").value;
            //alert(id);
            var xmlhttp;
            if (window.XMLHttpRequest) {
                xmlhttp = new XMLHttpRequest();
                if (xmlhttp.overrideMimeType) {
                    xmlhttp.overrideMimeType("text/xml");
                }
            }
            else if (window.ActiveXObject) {
                var activexName = ["MSXML2.XMLHTTP", "Microsoft.XMLHTTP"];
                for (var i = 0; i < activexName.length; i++) {
                    try {
                        xmlhttp = new ActiveXObject(activexName[i]);
                        break;
                    }
                    catch (e) {
                        alert(e);
                    }
                }
            }

            if (!xmlhttp) {
                alert('创建xmlhttp失败');
                return;
            } 
            xmlhttp.onreadystatechange = function() {
                if (xmlhttp.readyState == 4) {
                    if (xmlhttp.status == 200) {
                        var responseText = xmlhttp.responseText;
                        
                        document.getElementById("txt").value=(new Date()) + responseText;
                    }
                    else {
                        alert('内部错误');
                    }
                }
            }
            xmlhttp.open("GET", "myMethodHandler.ashx?id=" + id + "&time=" +new Date(), true);
            xmlhttp.send(null);

        }

        window.setInterval(myAjaxFun, 50000);//这里的时间单位是毫秒
</script>

如果你觉得麻烦,可以去jquery官网下个jquery版本,用他封装好的ajax方法即可。有一点需要注意"myMethodHandler.ashx?id=" + id + "&time=" +new Date(),这里第一个参数传递的是本次用户的sessionid,但是后面的time参数不可以少,这个事因为浏览器缓存的问题,我自己也测试了好久,就发现有第一次数据提交了,但是第二次就是没有的。

在myMethodHandler.ashx这个文件中我们只需要查找数据库中登录时保存用户信息表中对应的sessionid的LastLoginTime的时间即可。判断用户是否在线的方法也很简单,判断一下用户信息表中的LastLoginTime的时间是否比这次登录的时间小20分钟以上,如果是则判定这个用户是离线用户,修改用户的sessionid、lastLoginTime等信息。如果比这次登录的时间小于20分钟以内,则提示用户在线,不可以登录。

此方案的缺点:
1.ajax效果会影响客户端的性能。
2.如果有上千万个用户,则无疑增加了服务器的处理能力。
3.这里每次发生ajax效果,都会与数据库发生交互,增加了数据的负担等等。

自己设计的解决办法二:
由于第一种方法的不完美性,我们负责人让我又回去重新找到新的方案了。经过2天的思考还是让我找到了一个我比较满意的设计方案。这里当然要分享一下了。如果你有更好的解决方案,可以拿出来分享!
这里我还是没有放弃session,网上的办法不是不行的吗,但是我们也知道了那里的问题,我们只需要利用一个服务端的变量,这里变量可以根据我们设置的时间自动的消失问题就解决了。不知道你想到了没有那就是Cache。
如果用户成功的登录了以后,保存用户的信息的session里面,并且添加一个Cache的值为Cache["UName_Psw"]=True;
这里很容易误导别人,还是需要解释一下这里的key为UName和Psw都是数据库中的数据,比如说数据库中存在一个用户名为admin密码为123,还有一个用户名为test密码为123456,那么对应的Cache值为Cache["admin_123"]=True、Cache["test_123456"]=True,就是说这里的UName和Psw不是以这两个单词作为key。并且设置一下Cache的过期时间就是Session的timeout的时间。具体参考代码如下:

TimeSpan ts = new TimeSpan(0, 0, Session.Timeout, 0, 0);
//这里我假设我的用户名是admin密码为123
Cache.Insert("admin_123", True, null, DateTime.MaxValue, ts, CacheItemPriority.NotRemovable, null);

当用户再次登录的时候,首先去判断数据库中检查用户信息的正确性,如果是符合用户则再次建议这里用户的Cache是否为空string.IsNullOrEmpty(Convert.ToString(Cache["admin_123"]),如果是空的则说明用户可以登录,如果Cache不为空则为在线用户,返回提示信息。这里我想不用多解释了吧……
当然这里有一点还是比较麻烦的就是在每个成功登录以后在线操作的页面中需要每次都判断一下这个Cache是否为空,如果为空则跳转到登录界面要求重新登录,如果不为空则需要先Cache.Remove一下,然后再Cache.Remove一下,因为我们要时刻保持Cache的时间就是Session的timeout时间,可能有人会说这个多麻烦呀~我只能说你成功登录以后每一个页面中不也要先判断一下Session["key"]==null为空吗,你这样说这里也会很麻烦的!哈哈
当然这里负责人也跟我提出了一种情况就是如果用户修改密码的情况需要怎么处理呢?我想还是很简单的,用户成功吧密码修改到数据库以后,然后再执行Cache.Remove("UName_oldPsw"),在紧跟着Cache.Insert("Uname_NewPsw", True, null, DateTime.MaxValue, ts, CacheItemPriority.NotRemovable, null);这里我就不在解释了,因为我相信你懂得!

问题二:(一台电脑只允许一个用户登录)
我想很多人会对这个问题感兴趣,但是很遗憾的告诉你们这个问题是无解的。如果在一些附加条件下,那问题就有解了。就比如说算一个x+y=2的问题,求解x、y分别多少,这个都有无数个解大家心里都明白,但是如果我告诉你了x只能为1的时候,同样的问题就解就不同了。

当x=同一个局域网的时候,问题解方案
这个问题我一开的反应就是利用mac地址来表示每一台电脑,至少到现在还没有发现相同mac地址的两台电脑。于是设计了一个方案,在同事的电脑测试了一下,发现了一个很奇怪的问题,一个同事那边成功的显示了他的mac地址,但是另一个同事那边竟然没有显示mac地址,当时以为是电脑问题,别人的电脑都可以用,就一台电脑没有成功显示,那台电脑是笔记本用无限上网,我们其他人用的是台式机(公司的财产),用的是有线网。后来查看了一下他的IP地址,竟然不在同一个网段。我想说道这里内行人应该知道是什么原因了,外行人会越听越糊涂,还是先把代码粘出来吧:

[DllImport("Iphlpapi.dll")]
private static extern int SendARP(Int32 dest, Int32 host, ref Int64 mac, ref Int32 length);
[DllImport("Ws2_32.dll")]
private static extern Int32 inet_addr(string ip);
protected void Page_Load(object sender, EventArgs e)
{
// 在此处放置用户代码以初始化页面
    try
     {
            string userip = Request.UserHostAddress;
            string strClientIP = Request.UserHostAddress.ToString().Trim();
            Int32 ldest = inet_addr(strClientIP); //目的地的ip 
            Int32 lhost = inet_addr("");   //本地服务器的ip 
            Int64 macinfo = new Int64();
            Int32 len = 6;
            int res = SendARP(ldest, 0, ref macinfo, ref len);
            string mac_src = macinfo.ToString("X");
            if (mac_src == "0")
            {
                if (userip == "127.0.0.1")
                    Response.Write("正在访问Localhost!");
                else
                    Response.Write("欢迎来自IP为" + userip + "的朋友!" + "<br>");
                return;
            }

            while (mac_src.Length < 12)
            {
                mac_src = mac_src.Insert(0, "0");
            }

            string mac_dest = "";

            for (int i = 0; i < 11; i++)
            {
                if (0 == (i % 2))
                {
                    if (i == 10)
                    {
                        mac_dest = mac_dest.Insert(0, mac_src.Substring(i, 2));
                    }
                    else
                    {
                        mac_dest = "-" + mac_dest.Insert(0, mac_src.Substring(i, 2));
                    }
                }
            }

            Response.Write("欢迎来自IP为" + userip + "<br>" + ",MAC地址为" + mac_dest + "的朋友!"

             + "<br>");
        }
        catch (Exception err)
        {
            Response.Write(err.Message);
        }
}

代码抄字网上,我们来看一下这个方案的实现的过程吧,首先他先得到用户的IP地址,然后根据ARP表(如果不懂翻一下计算机网路的书),根据ARP表来得到用户的mac地址。但是如果不在同一个网段的话,就是无法得到客户端的mac地址了。我想你应该明白了吧,我们同事都用的是有线在同一个局域网,但是他用的是无限路由,不在同一个网站,就显示不了自己的网段了。如果客服端的机子都在同一个网段,那我们可以设计一个表,吧用户的mac地址保存下来,如果用户不现在且没有和此用户相同的mac地址的用户在线则可以成功登录。

方案的不合理性:真正的方案需求是需要放到外网上去的。我们不可能保证我们的服务对象都在同一个网段。

当x=用户设计js安全性,问题解方案

这里我没有去实现过,但是我大概的讲一下什么原理,做网站说白了,客服端收到的永远都是html、css、js图片等等文件,但是只有js算是脚本代码(你想让css去操作数据,我想css还没有强大到如此吧),那只有从js入手,但是有一点需要说明的是,他需要在ie中吧js的安全性改一下,你想想吧,会有谁去浏览一个网站吧时候去设置一下ie的设置呀!从用户体验完全不合理!代码我这里没有找,你们自己百度吧!

当x=限定用户的浏览器并且保证用户不去清除cookie
首先还是需要解释一下,并不是所有的浏览器的内核都是相同的,比如说现在主流的浏览器中IE、火狐、谷歌这些内核都是不同的,有些人可能会问遨游和360呢?其实这些都是IE的内核,只是换了一个皮肤罢了,你可以百度一下做一个自己的浏览器就明白了什么是遨游什么是360了。
这里还是讲一下设计的思路吧,用户成功登录以后,我们需要保存一个用户信息到浏览器中:这里我假设我们的Cookie的名字叫myCook,并且设置时间为SessionTimeOut的时间,代码如下:

HttpCookie hc = new HttpCookie("myCook");
DateTime dt = DateTime.Now;
//设置过期的时间,这里根据你自己的session的timeout时间
TimeSpan sp = new TimeSpan(0, 0, 5, 0, 0);
//设置hc的过期时间
hc.Expires = dt.Add(sp);
//标志用户已经登录
hc.Values.Add("IsLogin", true);
Response.AppendCookie(hc);

好了,如果用户在次登录的时候,那么去cookie中myCook的值,如果存在则说明用户重复的登录。其实思路和Cache的思路差不多,只不过Cache吧用户数据还是保存在服务端中,但是Cookie的内容保存在浏览器中。

设计的不合理性:首先我们不能保证用户只用一个浏览器来登录,其次稍微有点电脑知识的用户会用清楚缓存的方案来避开我们的bug。最重要的一点我们把一条重要的数据放到了客服端那边(标记用户是否登录)。

这里插几句"题外话":

1.当我们不同的浏览器登录的时候,服务器会自动的为这里浏览器保存了一个以sessionid为内容的cookie值到浏览器,具体的你可以利用Fiddler2来看一下这个数据。
2.如果我们在IE中成功的登录了到一个后台管理界面中,我们把管理界面的URL复制到不同内核的浏览器中,比如谷歌,则会提示你是未登录用户!这个是因为不同浏览器访问,会产生不同的SessionId。服务器根据你的sessionid判断你是否登录过。
3.还是如果你在IE中成功的登录到后台管理界面,你把管理界面的URL保存到记事本中,然后关闭IE浏览器,过一分钟以后,在吧这个URL复制到IE浏览器中,你会发现你还是登录的用户。因为http是不监听的协议,他不会去监听你的操作(关闭浏览器)。在相同的浏览器中操作,如果上次的Session没有销毁,则服务器认为你还是以这个这个sessionid在登录。
这里就剩下微软MSDN的参考内容了:来自:http://msdn.microsoft.com/zh-cn/library/h6bb9cz9(VS.80).aspx

会话由一个唯一标识符标识,可使用 SessionID 属性读取此标识符。为 ASP.NET 应用程序启用会话状态时,将检查应用程序中每个页面请求是否有浏览器发送的 SessionID 值。如果未提供任何 SessionID 值,则 ASP.NET 将启动一个新会话,并将该会话的 SessionID 值随响应一起发送到浏览器。
默认情况下,SessionID 值存储在 Cookie 中。但也可以将应用程序配置为在“无 Cookie”会话的 URL 中存储 SessionID 值。
只要一直使用相同的 SessionID 值来发送请求,会话就被视为活动的。如果特定会话的请求间隔超过指定的超时值(以分钟为单位),则该会话被视为已过期。使用过期的 SessionID 值发送的请求将生成一个新的会话。

总结:
对于一些比较“简单”的问题,我们往往比较容易忽略其中的难点,是有自己不断去尝试,才会发现什么是可行什么是不可行,我自己也花了比较多少时间去测试,用Fiddler2去抓包,去查看浏览器发送和接受的你内容,思考问题的突破口等等。就写到这里吧,希望高手多多指导其中的不对之处!

有参考的部分网站:
http://www.cnblogs.com/zhb6022/archive/2010/04/08/1707426.html
http://www.cnblogs.com/humors/archive/2008/10/18/1314247.html
http://blog.csdn.net/etongchina/article/details/4665204
http://www.cnblogs.com/wenanry/archive/2009/08/06/1540777.html
http://www.cnblogs.com/wdfrog/archive/2011/03/16/1985952.html

posted @ 2013-01-06 15:40  唯吴独尊  阅读(4525)  评论(25编辑  收藏  举报