Welcome to my blog. You can post whatever you like!
The blog post is using markdown, and here is a simple guide. Markdown Guide
LOGIN TO POST A BLOG登录系统的设计

Author: Satori
Posted: 2018-09-19 20:11:19
Views: 944 Comments: 1
## 一、背景
登录系统的设计可以说是网站的基本功能之一。之前在搭网站的时候用的是Django相关的一个包,django-registrition-redux实现注册功能和登录功能。但是自从我准备把网站重构后,这部分功能就需要自己去实现了。因此我准备自己写一套登录系统。(注册系统还没有实现)
## 二、总体方案
总体方案是请求任何api的时候都带上cookie,然后服务端解析cookie得到用户信息。
所以根据以上方案,只要解决以下几个问题就可以完成。
第一个是cookie是什么时候生成的,第二个是cookie是怎么生成的,解析的,第三个是cookie怎么销毁的。
## 三、具体实现
### 1. 用户登录
cookie一定是在用户完成用户名密码校验之后返回响应带给客户端的。首先我们需要校验用户的用户名密码是否正确。这部分我使用了django内置的User模块(这部分在`django.contrib.auth`中)。它提供一个authenticate方法校验用户是否验证成功。如果成功了,就构造一个cookie,在response一起返回给客户端,并设置max age过期时间。如果失败,则返回验证失败。这部分的代码如下所示。
```
class LoginView(BaseView):
def __init__(self):
super(LoginView, self).__init__()
self.http_method_names = ['post']
self.attrs = {
"user_name": {
"type": str,
"required": True,
},
"user_password": {
"type": str,
"required": True,
}
}
def get_clean_params(self, request):
try:
params = post_params(request)
clean_params = trans_params(params, self.attrs)
except Exception as e:
logging.error(e)
return None, e
return clean_params, None
def post(self, request, *args, **kwargs):
params, err = self.get_clean_params(request)
if err is not None:
resp = build_response(ReturnCode.ParamError, message=str(err))
return resp
name = params.get("user_name")
password = params.get("user_password")
user = authenticate(username=name, password=password)
if user is not None:
handler = UserHandler()
user_id = user.id
token = handler.get_token_str_for_current_user(user_id)
# return response
code = ReturnCode.Success
message = "Login Success"
data = {"user_name": user.username, "user_id": user.id}
resp = build_response(code, message=message, data=data)
# set cookie
max_age = settings.LOGIN_TOKEN_EXPIRE_DAYS * 86400
resp.set_cookie(USER_TOKEN_COOKIE_NAME, token, max_age=max_age)
return resp
else:
# the user fail the authentication
code = ReturnCode.AuthenticationFail
resp = build_response(code)
return resp
```
### 2. cookie的生成与存储
在验证成功之后, 需要生成cookie返回客户端,下次客户端好带上这个cookie来判断用户是谁。
传统的方式是使用一个session id来区分。但是我设计的前后分离的做法使我想到一些其他的做法。首先cookie不能直接暴露用户的信息。比如不能直接把用户的user id塞到cookie里面。否则其他用户可以轻易伪造这个cookie来做一些不可告人的事情。所以cookie需要加密。
我们先设计一下Cookie的结构,首先在服务端生成一个token,然后加上用户id,组成一个字典,然后将这个数据进行jwt编码。jwt编码采用HS256,这时候需要指定一个密钥来进行HS256加密,这个密钥放在django的settings文件比较合适。加密之后得到jwt字符串,就把这个字符串作为cookie值给客户端。
HS256加密的代码如下,非常简单
```
def jwt_encode(payload):
"""
Encode jwt by payload
:param payload: type: dict, the data to be encoded
:return: type: str, the result
"""
try:
key = settings.LOGIN_SECRET
encoded_bytes = jwt.encode(payload, key, algorithm='HS256')
encoded = bytes.decode(encoded_bytes)
except Exception:
logging.error("jwt encode error, for payload={}".format(payload))
return None
return encoded
```
同时还附上解码,后期从cookie识别用户也会用到。
```
def jwt_decode(encoded_data):
"""
Decode the data of encoded_data
:param encoded_data: str, the encoded string
:return: dict, the data
"""
try:
key = settings.LOGIN_SECRET
payload = jwt.decode(encoded_data, key)
except Exception:
logging.error("jwt decode error, for encoded_data={}".format(encoded_data))
return None
return payload
```
我们jwt encode的payload结构就是之前提到的字典,包括两部分,一个是用户id,一个是自动生成的token。先说token生成方法,首先先规定token长度,然后在一个字符集合里面随机取字符组成字符串。代码如下,很简单。
```
def generate_token():
"""
Generate a login token
:param
:return: token: str
"""
char_set = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
length = settings.LOGIN_TOKEN_LENGTH
token = ""
for _ in range(length):
ind = random.randrange(0, len(char_set))
token += char_set[ind]
return token
```
然后就是将这个token和用户id进行打包,然后进行jwt encode,最后发送给用户,这部分代码如下。
```
def get_jwt_token_from_login_info(self, user_id, user_token):
"""
Encode jwt token from login info
:param user_id: int
:param user_token: str
:param expire_time: float
:return:
"""
user_token_info = {}
user_token_info[USER_TOKEN_USER_ID_NAME] = user_id
user_token_info[USER_TOKEN_VALID_TOKEN_NAME] = user_token
user_token_str = jwt_encode(user_token_info)
return user_token_str
```
自此cookie生成已经结束了。但是还有一个工作没有做,那就是需要找一个地方把用户的token信息存下来,后期验证用户需要。我选择把这部分信息存到redis里面。并设置一个过期时间,用来避免用户能永久使用一个cookie登陆,这个后期验证用户会用到。这部嗯代码如下。
```
def set_login_info_to_redis(self, user_id, token, expire_time):
"""
Save token to redis
:param user_id: int
:param token: str
:param expire_time: float
:return:
"""
redis_token_info = {}
redis_token_info[REDIS_TOKEN_VALID_TOKEN_NAME] = token
redis_token_info[REDIS_TOKEN_EXPIRE_TIME_NAME] = expire_time
redis_token_info_str = json.dumps(redis_token_info)
redis_hset(USER_TOKEN_HSET_NAME, user_id, redis_token_info_str)
```
### 3. 用户验证
前面第二部分已经提到用户验证的前期工作了,现在把用户验证这块进行详细说明。用户在登陆得到cookie后,之后发送的ajax请求都会带上这个cookie,解析这个cookie就能得知是哪个用户。
首先获得用户的cookie,然后对这个cookie进行jwt decode,从cookie字串得到一个字典,包含用户id还有cookie。当然jwt decode可能失败,因为用户那个字串是可能随便瞎搞的,相当于反作弊,直接就认为用户未登录。得到用户token以及id之后,去redis找那个用户当时存下的token,判断两个token是否一样,一样就认为是真的这个用户登陆,否则就认为作弊,也会认为用户未登录。如果token是匹配的,然后再从redis取得过期时间,如果当前时间超过过期时间,认为用户登陆过期了,需要重新登陆。
从redis取用户登陆信息代码如下。
```
def get_login_info_from_redis(self, user_id):
"""
Get a user's login info from redis
:param user_id: int
:return: {token, expire_time}
"""
redis_token_info_byte = redis_hget(USER_TOKEN_HSET_NAME, user_id)
try:
redis_token_info_str = bytes.decode(redis_token_info_byte)
redis_token_info = json.loads(redis_token_info_str)
redis_token = redis_token_info[REDIS_TOKEN_VALID_TOKEN_NAME]
expire_time = redis_token_info[REDIS_TOKEN_EXPIRE_TIME_NAME]
return redis_token, expire_time
except Exception:
return None, None
```
判断用户是否登陆的代码如下。
```
def _check_user_login(self, user_id, user_token):
"""
Check the user with user_id to be login according to the token
:param user_id: int
:param user_token: dict
:return: bool
"""
# check the valid token
redis_token, expire_time = self.user_service.get_login_info_from_redis(user_id)
if redis_token is None or expire_time is None:
return False
if redis_token != user_token:
return False
current_timestamp = get_current_timestamp()
if current_timestamp > expire_time:
return False
return True
```
## 四、集成BaseView
现在已经有了这些工具之后,可以把这部分集成进公共的baseview里面,在dispatch的时候,调用一个公共的方法获得当前用户。入口是用户的cookie,返回就是当前的用户。这部分代码如下。
```
def get_current_user_from_token_str(self, user_token_str):
"""
Get the current user from the request
:param request: HttpRequest, the request
:return: User, the user
"""
user_id, user_token = self.user_service.get_login_info_from_jwt_token(user_token_str)
if user_id is None or user_token is None:
return None
if self._check_user_login(user_id, user_token):
user = self.user_service.get_user_by_id(user_id)
return user
else:
return None
```
总体来说实现方案就是这样,过程还是比较繁琐,但是反作弊必须要加的,不然就可能冒充用户。只要用户把cookie保护好,就不会造成问题。
Read More