这篇文章上次修改于 840 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。——百度百科

下面要介绍的单点登录的实现方式是我在业余项目中自己摸索出来的,可能跟主流的实现方式不一样,仅做参考。

假设我现在有一个博客服务和一个图片服务,我希望只用登录一次就能使用这两个服务。按照传统的开发方式,博客服务自己带一个登录模块,图片服务自己再带一个登录模块,加大了工作量不说,用户体验也很不好。

那么能不能将登录模块剥离出来成为一个独立的鉴权服务呢?答案是可以。

鉴权服务负责用户的注册、登录、注销等功能,其他服务需要鉴权时只需跳转到鉴权服务,完成鉴权后再跳转回服务页面即可。

下面简单介绍具体做法,假设:

  1. 登录页面:https://login.abc.com/sigin.html
  2. 博客服务页面:https://blog.abc.com/index.html
  3. 博客服务与鉴权服务均部署在内网中,通过 Web 服务器反向代理供客户端访问
  4. 关于跨域名共享 Cookie前一篇文章已经介绍,在此不再赘述

判断是否已经登录

当首次打开博客服务页面时,博客后台服务会首先判断用户是否登录,关键代码如下:

var token = Request.Cookies["token"];
var uid = Request.Cookies["uid"];

if (string.IsNullOrEmpty(uid) || string.IsNullOrEmpty(token))
{
    msg = "非法操作";
    return false;
}
HttpWebRequest request = WebRequest.CreateHttp($"http://localhost:5000/api/Session/{uid}/{token}");
HttpWebResponse response = (HttpWebResponse)request.GetResponse();
using (var sr = new StreamReader(response.GetResponseStream()))
{
    var json = sr.ReadToEnd();
    var result = JsonConvert.DeserializeObject<RequestResult>(json);
    if (result.Code == 200)
    {
        return true;
    }
    msg = result.Msg;
    return false;
}

这里假设鉴权服务和博客服务均部署在内网的同一台服务器上,若两者不在同一台服务器,请修改 localhost 为鉴权服务的内网 IP 即可。

同样,由于鉴权服务部署于内网,因此预先假设内部网络是安全的,所以直接将 uidtoken 作为 GET 请求参数进行传递。如果对安全要求性高,可将该请求修改为 POST 请求。

若用户已登录,则直接往下执行业务流程;若用户未登录,则跳转到登录页面,并在 url 中带上登录成功后的回调页面,比如:
https://login.abc.com/signin.html?redirect=https://blog.abc.com/index.html

执行登录

跳转到登录页面后,填写用户名、密码后向鉴权服务发起登录请求,鉴权服务执行登录验证,若验证通过,则向客户端返回登录成功的信息,同时将 uidtoken 写入 Cookie 返回给客户端供后续鉴权使用,然后客户端跳转到登录前页面;若验证失败,则向客户端返回登录失败信息。

var cookieOptions = new CookieOptions
{
    HttpOnly = true,
    Secure = false,
    Domain = "abc.com",
    Path = "/",
    Expires = DateTime.Now.AddDays(7)
};
Response.Cookies.Append("uid", user.Uid, cookieOptions);
Response.Cookies.Append("token", user.Token, cookieOptions);

保持登录状态

为了保持登录状态,可在每次请求时刷新 token 的时间戳。

退出登录

客户端发起注销请求:

$.ajax({
    type: 'DELETE',
    url: 'https://login.abc.com/api/Session',
    xhrFields: { withCredentials: true }, // 发送凭据
    dataType: "json",
    success: function (response) {
    },
    error: function (error) {
    }
});

服务端收到注销请求后,清理登录信息:

var uid = Request.Cookies["uid"];
var token = Request.Cookies["token"];
if (!Utils.IsLogon(_context, uid, token, _tokenExpiredTimeMin, out string msg))
{
    return Ok(new RequestResult(StatusCodes.Status401Unauthorized, msg));
}

var user = await _context.Userinfo.FindAsync(uid);
if (user == null)
{
    return Ok(new RequestResult(StatusCodes.Status404NotFound, "用户不存在"));
}

user.Token = null;
user.Tokenrefreshtime = null;

_context.Entry(user).State = EntityState.Modified;

await _context.SaveChangesAsync();

Response.Cookies.Delete("uid");
Response.Cookies.Delete("token");

return Ok(new RequestResult(StatusCodes.Status200OK, null));