转载来源: 基于Token认证的多点登录和WebApi保护

一天张三,李四,王五,赵六去动物园。

  • 张三没买票
  • 李四制作了个假票
  • 王五买了票
  • 赵六要直接翻墙进动物园。

到了门口,验票的时候。

  • 张三没有买票被拒绝进入动物园
  • 李四因为买假票而被补
  • 赵六被执勤人员抓获
  • 只有张三进去了动物园

后来大家才知道,当一个用户带着自己的信息去买票的时候,验证自己的信息是否正确,那真实的身份证(正确的用户名和密码),验证通过以后通过身份证信息和票据打印时间(用户登录时间)生成一个新的动物园参观票(Token令牌),给了用户一个,在动物园门口也保存了票据信息(相当与客户端和服务端都保存一份),在进动物园的时候两个票据信息对比,正确的就可以进动物园玩了。这就是我理解的Token认证。当然可能我的比喻不太正确,望大家多多谅解。

下面是我们在服务端定义的授权过滤器,思路是根据切面编程的思想,相当于二战时期城楼门口设立的卡。
当用户想api发起请求的时候,授权过滤器在api执行动作之前执行,获取到用户信息,如果发现用户没有登录,我们会判断用户要访问的页面是否允许匿名访问,用户没有登录但是允许匿名访问,放行客户端的请求,用户没有登录且不允许匿名访问,不允许通过,告诉客户端,状态码403或401,请求被拒绝了。如果发现用户登录,判断用户的良民证(Token令牌)是真的还是假的,用户登录,且良民证是真的,放行,如果发现良民证造价,抓起来,不允许访问,当然,这里可以加权限,验证是否有某个操作的权限

public class UserTokenAttribute : AuthorizeAttribute 
{
	protected override void HandleUnauthorizedRequest(HttpActionContext filterContext) 
	{
		base.HandleUnauthorizedRequest(filterContext);
		var response = filterContext.Response = filterContext.Response ?? new HttpResponseMessage();
		response.StatusCode = HttpStatusCode.Forbidden;
		ResultModel model = new ResultModel();
		model.result_code = ResultCode.Error;
		model.result_data = null;
		model.result_mess = "您没有授权!";
		response.Content = new StringContent(Newtonsoft.Json.JsonConvert.SerializeObject(model), Encoding.UTF8, "application/json");
	}
	public override void OnAuthorization(HttpActionContext actionContext) 
	{
		//从http请求的头里面获取身份验证信息,验证token值是否有效
		string token = GetToken(actionContext);
		if (!string.IsNullOrEmpty(token)) 
		{
			if (UserService.Instance.ValidateToken(token)) 
			{
				base.IsAuthorized(actionContext);
			} else 
			{
				HandleUnauthorizedRequest(actionContext);
			}
		} else//如果取不到token值,并且不允许匿名访问,则返回未验证401 
		{
			var attributes = actionContext.ActionDescriptor.GetCustomAttributes<AllowAnonymousAttribute>().OfType<AllowAnonymousAttribute>();
			bool isAnonymous = attributes.Any(a => a is AllowAnonymousAttribute);
			//验证当前动作是否允许匿名访问
			if (isAnonymous) 
			{
				base.OnAuthorization(actionContext);
			} else 
			{
				HandleUnauthorizedRequest(actionContext);
			}
		}
	}
	public string GetToken(HttpActionContext actionContext) 
	{
		if (!string.IsNullOrEmpty(System.Web.HttpContext.Current.Request["token"]))
		                return System.Web.HttpContext.Current.Request["token"].ToString();
		if (System.Web.HttpContext.Current.Request.InputStream.Length > 0) 
		{
			string json = new System.IO.StreamReader(System.Web.HttpContext.Current.Request.InputStream).ReadToEnd();
			Dictionary<string, object> di = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
			if (di.Keys.Contains("token")) return di["token"].ToString();
		}
		return "";
	}
}

好了,服务端有验证了,客户端也不能落下啊,客户端使用了动作过滤器,在用户操作之前或用户操作之后验证登录信息(这里可以加权限,验证是否有某个操作的权限),客户端验证思路和服务端验证差不多,下面是客户端验证代码:

public class LoginVerificationAttribute : ActionFilterAttribute 
{
	public override void OnActionExecuting(System.Web.Mvc.ActionExecutingContext filterContext) 
	{
		// region 是否登录
		var userInfo = UserManager.GetLoginUser();
		if (userInfo != null) 
		{
			base.OnActionExecuting(filterContext);
		} else 
		{
			if (filterContext.ActionDescriptor.IsDefined(typeof(AllowAnonymousAttribute), true)
			               || filterContext.ActionDescriptor.ControllerDescriptor.IsDefined(typeof(AllowAnonymousAttribute), true)) 
			{
				base.OnActionExecuting(filterContext);
			} else 
			{
				System.Web.HttpContext.Current.Session["token"] = null;
				filterContext.HttpContext.Response.Redirect("/Common/Login", true);
				filterContext.HttpContext.Response.End();
			}
		}
		// endregion
	}
}

但是有良民证也不能也不能无限制的待在城里啊,我们做了一个时效性,在城市里什么时也不做到达一定的时长后得驱逐出城啊(类似与游戏中的挂机超过一定时间后T出本局游戏),在这里使用的Redis记录良民证(Token),思路是用户登录之后生成的新的Token保存在Redis上,设定保存时间20分钟,当有用户有动作之后更新Redis保存有效期。下面是服务端验证token的,token有效,从新写入到Redis

public UserInfoDetail GetUserIdInCachen(string token) 
{
	SimpleTokenInfo tokenInfo = JwtUtil.Decode<SimpleTokenInfo>(token);
	if (tokenInfo == null) return null;
	RedisHelper redisHelper = new RedisHelper();
	string userJson = redisHelper.Get<string>(tokenInfo.UserName);
	if (!string.IsNullOrEmpty(userJson)) 
	{
		UserAndToken userAndToken = JsonConvert.DeserializeObject<UserAndToken>(userJson);
		if (userAndToken.Token.Equals(token)) 
		{
			redisHelper.Set(tokenInfo.UserName, userJson, DateTime.Now.AddSeconds(expireSecond));
			return userAndToken;
		}
		return null;
	}
	return null;
}

以上就是Token认证

  • 单点登录的思路
    张三登录了qq: 123456,生成了一个Token以键值对的方式保存在了数据库,键就是qq号,值就是qq信息和登录时间生成的一个Token。
    李四也登录了qq: 123456,qq信息和张三登录是一致的,但是qq登录时间不同,生成了一个新的Token,在保存的时候发现Redis里已经存在这个qq的键了,说明这是已经有人登录了,在这里可以判断是否继续登录,登录后新的Token信息覆盖了张三登录QQ生成的Token,张三的Token失效了,当他再次请求的时候发现Token对应不上,被踢下线了。
  • 多点登录的思路
    可以通过qq号加客户端类型作为键,这样手机qq登录的键是"123456_手机",电脑登录的键是"123456_电脑",这样在保存到Redis的时候就不会发生冲突,可以保持手机和电脑同时在线。但是有一个人用手机登录qq: 123456了,就会覆盖redis中键为"123456_手机"的Token信息,导致原先登录那个人的信息失效,被强制下线。

来展示代码,判断是否可以登录

public string GetToken(string userName, string passWord, ClientType clientType) 
{
	UserInfoDetail user = GetModel(userName);
	if (user != null) 
	{
		if (user.Pwd.ToUpper() == StringMd5.Md5Hash32Salt(passWord).ToUpper()) 
		{
			return GetToken(user, clientType);
		}
	}
	return "";
}

客户端类型实体,这是我们的客户端类型:

public enum ClientType 
{
	/// <summary>
	/// 主站
	/// </summary>
	MasetSize,
	/// <summary>
	/// 微信小程序
	/// </summary>
	WeChatSmallProgram,
	/// <summary>
	/// WinForm端
	/// </summary>
	WinForm,
	/// <summary>
	/// App端
	/// </summary>
	App
}

获取token需要的用户信息和登录时间的实体Model,这是我们的用户Model

public partial class UserInfoDetail 
{
	public UserInfoDetail() 
	{
	}
	public string Admin 
	{
		get;
		set;
	}
	public string Pwd 
	{
		get;
		set;
	}
}

生成Token用的实体

public class SimpleTokenInfo 
{
	public SimpleTokenInfo() 
	{
		CreateTimeStr = DateTime.Now.ToString("yyyyMMddhhmmss");
	}
	/// <summary>
	/// 创建日期
	/// </summary>
	public string CreateTimeStr 
	{
		get;
		set;
	}
	/// <summary>
	/// 用户账户
	/// </summary>
	public string UserName 
	{
		get;
		set;
	}
}

登录成功,通过JWT非对称加密生成Token

/// <summary>
/// 获取token
/// </summary>
/// <param name="user">用户</param>
/// <returns></returns>
public string GetToken(UserInfoDetail user, ClientType clientType) 
{
	RedisHelper redisHelper = new RedisHelper();
	SimpleTokenInfo tokenInfo = new SimpleTokenInfo();
	tokenInfo.UserName = user.Admin + "_" + ((int)clientType).ToString();
	string token = JwtUtil.Encode(tokenInfo);
	UserAndToken userAndToken = new UserAndToken();
	userAndToken.Token = token;
	ModelCopier.CopyModel(user, userAndToken);
	string userJson = JsonConvert.SerializeObject(userAndToken);
	redisHelper.Set(tokenInfo.UserName, userJson, DateTime.Now.AddSeconds(expireSecond));
	return token;
}

下面是JWT加密和解密的代码

public class JwtUtil 
{
	private static string secret = "***********";
	//这个服务端加密秘钥 属于私钥
	public static string Encode(object obj) 
	{
		IJwtAlgorithm algorithm = new HMACSHA256Algorithm();
		IJsonSerializer serializer = new JsonNetSerializer();
		IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
		IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder);
		var token = encoder.Encode(obj, secret);
		return token;
	}
	public static T Decode<T>(string token) 
	{
		string json;
		try 
		{
			IJsonSerializer serializer = new JsonNetSerializer();
			IDateTimeProvider provider = new UtcDateTimeProvider();
			IJwtValidator validator = new JwtValidator(serializer, provider);
			IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
			IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder);
			json = decoder.Decode(token, secret, verify: false);
			//token为之前生成的字符串
			T model = JsonConvert.DeserializeObject<T>(json);
			//对时间和用户账户密码进行认证
			return model;
		}
		catch (Exception) 
		{
			return default(T);
		}
	}
}

将获取到的Token保存到Redis

/// <summary>
/// 存储数据到hash表
/// </summary>
public bool Set(string key, string value, DateTime tmpExpire) 
{
	try 
	{
		Redis.Set(key, value, tmpExpire);
		Save();
	}
	catch 
	{
		return false;
	}
	return true;
}

展示一下,在服务端,继承控制器实现登录验证:

/// <summary>
/// 验证登录,继承此控制器限制登录才可以访问
/// </summary>
[UserToken]
public class LoginVerificationController : BaseApiController 
{
}

在服务端的验证方法,在方法前加[AllowAnonymous]是允许匿名访问,也就是不登录访问,下面以用户中心控制器为例

public class UserController : LoginVerificationController 
{
	/// <summary>
	/// 用户登录,获取token
	/// </summary>
	/// <param name="userName">用户名</param>
	/// <param name="passWord">密码</param>
	/// <param name="clientType">客户端类型</param>
	/// <returns></returns>
	[HttpGet]
	[AllowAnonymous]
	public ResultModel GetToken(string userName, string passWord, int clientType) 
	{
		ResultModel model = new ResultModel();
		if (!Enum.IsDefined(typeof(ClientType), clientType)) 
		{
			model.result_code = ResultCode.Error;
			model.result_data = "";
			model.result_mess = "客户端类型不正确!";
			return model;
		}
		string token = UserService.Instance.GetToken(userName, passWord, (ClientType)clientType);
		if (string.IsNullOrEmpty(token)) 
		{
			model.result_code = ResultCode.Fail;
			model.result_data = "";
			model.result_mess = "获取token失败!";
		} else 
		{
			model.result_code = ResultCode.Ok;
			model.result_data = token;
			model.result_mess = "获取token成功!";
		}
		return model;
	}
	/// <summary>
	/// 获取用户
	/// </summary>
	/// <param name="token">令牌</param>
	/// <returns></returns>
	public ResultModel GetUserInfo(string token) 
	{
		ResultModel model = new ResultModel();
		try 
		{
			UserInfoDetail user = UserService.Instance.GetUser(token);
			if (user != null) 
			{
				model.result_code = ResultCode.Ok;
				model.result_data = user;
				model.result_mess = "获取用户成功!";
			} else 
			{
				model.result_code = ResultCode.Fail;
				model.result_data = "";
				model.result_mess = "获取用户失败!";
			}
		}
		catch (Exception ex) 
		{
			model.result_code = ResultCode.Error;
			model.result_data = null;
			model.result_mess = ex.ToString();
		}
		return model;
	}
	/// <summary>
	/// 验证用户名有效性
	/// </summary>
	/// <param name="user">用户model  包含用户名和token</param>
	/// <returns></returns>
	[HttpPost]
	public ResultModel VUserName([FromBody]User user) 
	{
		ResultModel model = new ResultModel();
		try 
		{
			if (string.IsNullOrEmpty(user.username)) 
			{
				model.result_code = ResultCode.Ok;
				model.result_data = "";
				model.result_mess = "用户名可以使用!";
			} else 
			{
				model.result_code = ResultCode.Fail;
				model.result_data = "";
				model.result_mess = "用户名已经存在!";
			}
		}
		catch (Exception ex) 
		{
			model.result_code = ResultCode.Error;
			model.result_data = null;
			model.result_mess = ex.ToString();
		}
		return model;
	}
}
public class User 
{
	public string username 
	{
		get;
		set;
	}
	public string token 
	{
		get;
		set;
	}
}

客户端使用也类似,在方法前加[AllowAnonymous]是该方法允许匿名访问,下面是客户端我封装的Base控制器

public class BaseController : Controller 
{
	public BaseController() 
	{
		UserInfoDetail userinfo= GetUserInfo();
		ViewBag.User=userinfo?.Admin??"请登陆";
		ViewBag.UserId = GetToken();
	}
	public string GetAPI(string method, string uri,object o=null) 
	{
		string str = string.Empty;
		HttpContent content = new StringContent(JsonConvert.SerializeObject(o));
		using (HttpClient client = new HttpClient()) 
		{
			client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
			content.Headers.ContentType =new MediaTypeHeaderValue("application/json");
			HttpResponseMessage response=null;
			if(method.ToLower()=="get") 
			{
				response = client.GetAsync(uri).Result;
			} else if(method.ToLower()=="post") 
			{
				response = client.PostAsync(uri, content).Result;
			} else if(method.ToLower()=="put") 
			{
				response = client.PutAsync(uri, content).Result;
			} else if(method.ToLower()=="delete") 
			{
				response = client.DeleteAsync(uri).Result;
			}
			if(response.IsSuccessStatusCode) 
			{
				str = response.Content.ReadAsStringAsync().Result;
			}
		}
		return str;
	}
	/// <summary>
	/// 获取用户信息
	/// </summary>
	/// <returns></returns>
	public UserInfoDetail GetUserInfo() 
	{
		return UserManager.GetLoginUser();
	}
	/// <summary>
	/// 获取Token
	/// </summary>
	/// <returns></returns>
	public string GetToken() 
	{
		return UserManager.GetLoginToken();
	}
}

使用方法如下

/// <summary>
/// 继承此控制器表示登录验证
/// </summary>
[LoginVerification]
public class LoginVerificationController : BaseController 
{
	//[AllowAnonymous]//该特性表示允许匿名访问该方法
	//public void Test()
	//{
	//}
}

Q.E.D.


做一个热爱生活的人