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

登录系统的设计

Satori

Author: Satori

Posted: 2018-09-19 20:11:19

Category: Django Nuxt SSR 技术

Views: 301 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

使用Django和Nuxt实现前后端分离

Satori

Author: Satori

Posted: 2018-09-19 20:10:24

Category: Django Nuxt 技术

Views: 325 Comments: 0

## 一、背景 在我搭建这个网站到后期的时候,明显感觉前端的代码维护难度太高。因为Django自带模板渲染是基于Jinja渲染的,最大的特点是模板中的数据从服务端一次取出,然后再渲染页面。而数据改变需要手动去维护依赖该数据的html段落。而且研发到后期发现很多前端模块都是可以抽象成一个组件,但是通过模板的include会带来各种问题。在有机会接触到一些前端的mvvm框架后我就起手打算改造网站,实现前后端分离。 ## 二、整体思路 前端页面部分使用Nuxt,它是一个基于Vue的Server-Side-Render的解决方案,自动集成了vue, vue-router, vuex等等组件,能让前端的开发效率明显增高。同时后端使用Django作为服务的提供者。在服务端渲染的时候,如果需要异步数据,则先发请求给Django,然后的得到数据后再渲染成页面返回给浏览器。之后客户端如果有任何异步数据,都通过ajax的方式向Django发送请求来获得数据。 ## 三、实现细节 1. 请求的发送 前端使用axios来发送请求。我的做法是封装一个api的基础client,这个client被各个业务线的其他client继承。这个基础的client主要负责封装请求的发送,以及异常的处理。 ```js import axios from 'axios'; class ApiClient { get_axios_client () { let axios_client = axios.create(this.client_config); axios_client.interceptors.response.use(function (response) { if (response.status !== 200) { return Promise.reject(response.data); } return response.data; }, function (error) { error = error.response || error; console.log('error', error); if (!error.status) { return Promise.reject({ code: 999, message: 'Server Error!', }); } var message = ''; if (error.status >= 400 && error.status < 500) { message = 'request error, message: ' + error.status + ': ' + error.statusText + '. ' + error.data.message; } else if (error.status === 502 || error.status === 504) { message = 'The server is busy or restarting'; } else { message = 'Internal Server Error'; } return Promise.reject({ code: 999, message: message, }); }); return axios_client; } constructor (config) { this.client_config = config; this.client = this.get_axios_client(); } get (url, params, config) { let mergedConfig = Object.assign({}, config, this.client_config, {url: url, params: params, method: 'get'}); return this.request(mergedConfig); } post (url, data, config) { let mergedConfig = Object.assign({}, config, this.client_config, {url: url, data: data, method: 'post'}); return this.request(mergedConfig); } request (config) { return this.client.request(config) .then(response => { return response; }) .catch(response => { console.log('Error Occurs. Response: ' + response); return Promise.reject(response); }); } } export default ApiClient; ``` 这里主要封装了两个方法,一个是get方法,一个是post方法,都返回一个promise,并且增加一个拦截器,拦截response,在返回正常的情况下把data部分返回,在有问题的时候返回reject,并给出错误码和信息。 同时config里面可以设置请求一些参数,比如超时时间等等。 接着封装一个带有默认配置的BaseClient,设置了请求的基准url以及超时时间,代码如下。 ``` import ApiClient from '@/utils/api_client'; const client_config = { baseURL: '/api/', timeout: 20000, }; class BaseApiClient { constructor (sub_url) { const baseURL = client_config.baseURL + sub_url; this.client = new ApiClient({...client_config, baseURL}); } } export default BaseApiClient; ``` 之后所有业务都可以继承这个BaseClient即可实现请求的发送。 2. 请求的处理 请求处理这边就使用Django,通过cookie来识别用户,这部分的技术细节会在接下来的文章里提到。然后Django把请求处理完之后返回给前端数据,最后前端拿到数据做渲染。无论是服务器渲染还是客户端发的请求,都能够处理。 3. 前后端连接 前面都在讲前端如何发请求,后端怎么处理请求上面,最关键的地方还没有讲到,那就是怎么把前后端连接起来。 按照我现在的方案,node的端口在3000,Django的端口在8000(后期当然可以使用linux socket方式,不通过http来进行通信),那么从node的请求都会走3000这个端口,那Django怎么接收这个请求呢?答案就是nginx。nginx可以作为一个强大的反向代理服务器,我们可以这么操作。 先让nginx去监听某一个端口(比如80),然后根据url前缀进行请求分发。如果是前端的请求(页面请求),那么就代理到node开启的3000端口,其他的都丢给Django。 区分前端路由以及后端请求的方法很简单。对于api请求,我都在路由前面加一个`/api`,这个所有以这个请求开头的都是后端请求,其他都是前端请求。这样做也避免了跨域的问题。不过我这里想使用Django的Admin界面进行数据库的一些管理。因此我需要特别匹配一下`admin`这个路由到Django,同时Admin页面也会依赖一些静态资源,路由以`/static`开头,因此也需要匹配路由到Django。因此,nginx的配置文件大概是下面这样。 ``` server { listen 80; server_name 127.0.0.1; charset utf-8; location /admin { proxy_pass http://127.0.0.1:8000; } location /static { alias /home/satori/new_mysite/new_mysite/backend/static; # This should be changed to your own static file folder } location /api { proxy_pass http://127.0.0.1:8000; } location / { proxy_pass http://127.0.0.1:3000; } } ``` 这样配置所有的请求都能做正确的分发,因此前后端请求就连接上了,也就能通信了。

Read More