Building a Tornado-based User Authentication Flow with SMS, Registration, and JWT Login
This guide walks through a complete user lifecycle—SMS verification, reigstration, and login—implemented in a non-blocking Tornado application. We will integrate Yunpian for text messages, Redis for transient codes, Peewee-async for database access, and PyJWT for stateless authentication.
- Sending SMS via Yunpian
1.1 Synchronous client (for scripts or tests)
import requests
class YunpianClient:
def __init__(self, key: str):
self.key = key
def send_code(self, mobile: str, code: str) -> requests.Response:
payload = {
"apikey": self.key,
"mobile": mobile,
"text": f"【FreshMart】Your verification code is {code}. Ignore if not requested."
}
return requests.post(
"https://sms.yunpian.com/v2/sms/single_send.json",
data=payload
)
if __name__ == "__main__":
cli = YunpianClient("<your-key>")
resp = cli.send_code("13800000000", "5729")
print(resp.json())
1.2 Asynchronous client (for Tornado coroutines)
import json
from urllib.parse import urlencode
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
class AsyncYunpianClient:
def __init__(self, key: str):
self.key = key
async def send_code(self, mobile: str, code: str) -> dict:
client = AsyncHTTPClient()
body = urlencode({
"apikey": self.key,
"mobile": mobile,
"text": f"Your verification code is {code}. Ignore if not requested."
})
req = HTTPRequest(
url="https://sms.yunpian.com/v2/sms/single_send.json",
method="POST",
body=body
)
resp = await client.fetch(req)
return json.loads(resp.body.decode())
- Tornado handler for SMS codes
2.1 Form validation with WTForms-Tornado
from wtforms_tornado import Form
from wtforms import StringField
from wtforms.validators import DataRequired, Regexp
PHONE_RE = r"^1[3-9]\d{9}$"
class SmsForm(Form):
mobile = StringField(validators=[
DataRequired(message="Mobile required"),
Regexp(PHONE_RE, message="Invalid mobile format")
])
2.2 Redis-backed hendler
import json
import random
from tornado.web import RequestHandler
import redis
class BaseHandler(RequestHandler):
def prepare(self):
self.redis = redis.StrictRedis(**self.settings["redis"])
class SmsHandler(BaseHandler):
def _random_code(self) -> str:
return "".join(random.choices("0123456789", k=4))
async def post(self):
params = json.loads(self.request.body.decode())
form = SmsForm.from_json(params)
if not form.validate():
self.set_status(400)
self.finish(form.errors)
return
mobile = form.mobile.data
code = self._random_code()
yun = AsyncYunpianClient(self.settings["yunpian_key"])
res = await yun.send_code(mobile, code)
if res["code"] != 0:
self.set_status(400)
self.finish({"mobile": res["msg"]})
return
self.redis.setex(f"{mobile}_{code}", 600, 1) # 10 min TTL
self.finish({})
- Registration endpoint
3.1 User model with bcrypt passwords
from peewee import *
from bcrypt import hashpw, gensalt
from MxForm.models import BaseModel
class PasswordHash(bytes):
def check(self, raw: str) -> bool:
return hashpw(raw.encode(), self) == self
class PasswordField(BlobField):
def db_value(self, value):
if isinstance(value, PasswordHash):
return bytes(value)
salt = gensalt(12)
return hashpw(value.encode(), salt)
def python_value(self, value):
return PasswordHash(value)
class User(BaseModel):
mobile = CharField(max_length=11, unique=True)
password = PasswordField()
nickname = CharField(max_length=20, null=True)
avatar = CharField(max_length=255, null=True)
3.2 Registraiton handler
class RegisterHandler(BaseHandler):
async def post(self):
params = json.loads(self.request.body.decode())
form = RegisterForm.from_json(params)
if not form.validate():
self.set_status(400)
self.finish(form.errors)
return
mobile = form.mobile.data
code = form.code.data
if not self.redis.exists(f"{mobile}_{code}"):
self.set_status(400)
self.finish({"code": "Invalid or expired code"})
return
try:
await self.application.objects.get(User, mobile=mobile)
self.set_status(400)
self.finish({"mobile": "Already registered"})
return
except User.DoesNotExist:
user = await self.application.objects.create(
User, mobile=mobile, password=form.password.data
)
self.finish({"id": user.id})
- Login and JWT issuance
4.1 Login form
class LoginForm(Form):
mobile = StringField(validators=[
DataRequired(), Regexp(PHONE_RE)
])
password = StringField(validators=[DataRequired()])
4.2 Login handler
import jwt
from datetime import datetime, timedelta
class LoginHandler(BaseHandler):
async def post(self):
params = json.loads(self.request.body.decode())
form = LoginForm.from_json(params)
if not form.validate():
self.set_status(400)
self.finish(form.errors)
return
try:
user = await self.application.objects.get(User, mobile=form.mobile.data)
if not user.password.check(form.password.data):
raise ValueError
except (User.DoesNotExist, ValueError):
self.set_status(400)
self.finish({"non_field_errors": "Wrong credentials"})
return
payload = {
"uid": user.id,
"nick": user.nickname or user.mobile,
"exp": datetime.utcnow() + timedelta(days=7)
}
token = jwt.encode(payload, self.settings["jwt_secret"], algorithm="HS256")
self.finish({
"id": user.id,
"nick": payload["nick"],
"token": token.decode()
})
- Routing and CORS
from tornado.web import url, Application
from tornado.ioloop import IOLoop
from MxForm.settings import settings
routes = [
url(r"/code/", SmsHandler),
url(r"/register/", RegisterHandler),
url(r"/login/", LoginHandler),
]
app = Application(routes, **settings)
app.listen(8888)
IOLoop.current().start()
Enable CORS globally by subclassing RequestHandler and overriding set_default_headers to inject the appropriate Access-Control-* headers.