เริ่มต้นกับ Microservice #3
จากบทความที่แล้วที่ได้รู้จักกับเครื่องมือต่างๆมากขึ้น เช่น Kong, Grafana ในบทความนี้จะเริ่มต้นกับการทำ OTP
OTP ?
One Time Password แปลตรงๆเลยก็คือ รหัสผ่านที่มีไว้ใช้สำหรับครั้งเดียว โดยมีไว้เพื่อเพิ่มความปลอดภัยนั้นเอง ซึ่งก็มีทั้งที่ส่งเข้า SMS หรือ Email และมีอายุในการใข้งานเพียงเวลาสั้นๆเท่านั้น แต่นั้นก็จะพบกับปัญหาว่าหากเราต้องการที่จะทำ Load Balance หรือ Scale Out ละก็ จะทำยังไงให้มันยังคง Sync กันอยู่
Redis
ซึ่งไอเจ้าตัว Redis มีการทำงานคล้ายๆกับ Database ที่เป็น NoSQL แต่ก็ไม่เชิงซะทีเดียวนะ ซึ่งมันเก็บข้อมูลลง RAM เลยทำได้เปรียบได้เรื่องของความเร็ว ซึ่งโดยส่วนใหญ่แล้วจะนำไปทำเป็น Cache เพื่อเพิ่มประสิทธิภาพให้กับเว็ปของเรานั้นเอง แต่ในที่นี้เราจะนำมันมาทำเป็นส่วนหนึ่งของระบบ OTP ของเรานั้นเอง ซึ่งอีกหนึ่งความสามารถนั้นคือมันสามารถ Set Expire Key ได้นั้นเอง
OTP Service
เป็นที่เก็บ List Email ที่อนุญาติให้ใช้ระบบ และเป็นตัวจัดการกับระบบ OTP เช่น Generate OTP/Create Email
otp_dock
|__ docker-compose.yml
|__ python/
|__ Dockerfile
|__ requirements.txt
|__ rpc.py
docker-compose.yml
version: '3'
services:
otp:
container_name: otp
build: python/
restart: always
depends_on:
- otp_redis
networks:
- microservice
- default
otp_redis:
container_name: otp_redis
image: "redis:alpine"
networks:
default:
external:
name: otp_network
microservice:
external:
name: microservice_network
Dockerfile
FROM python:3.7.3-alpine3.8
RUN apk add --no-cache build-base
WORKDIR /app
COPY rpc.py .
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
CMD nameko run rpc --broker amqp://guest:guest@rabbitmq:5672
requirements.txt
redis
nameko
validate_email
rpc.py
from nameko.rpc import rpc
import math, random
from datetime import timedelta
from validate_email import validate_email
import redis
email_list_key = 'register_email'
def connect():
redisConnect = redis.Redis(host='otp_redis', port=6379)
return redisConnect
redisConnect = connect()
def generate_otp(email):
digits = "0123456789"
otp = ""
for i in range(6) :
otp += digits[math.floor(random.random() * 10)]
return otp
def exist_key(email):
data = redisConnect.exists(email)
if data == 1:
exist = True
else:
exist = False
return exist
def exist_email(email):
set_email = redisConnect.smembers(email_list_key)
email = bytes(email, 'utf-8')
if email in set_email:
exist = True
else:
exist = False
return exist
def val_email(email):
is_valid = validate_email(email)
return is_valid
class OTPService:
name = "otp"
@rpc
def create(self, email):
otp = 0
if exist_email(email):
otp = generate_otp(email)
redisConnect.setex(email, timedelta(minutes=3), str(otp))
return otp
@rpc
def delete(self, email):
success = 0
if exist_email(email):
redisConnect.srem(email_list_key, email)
success = 1
return success
@rpc
def create_email_list(self, emails):
val = {}
setExpire = False
for email in emails:
if val_email(email) and not exist_email(email):
redisConnect.sadd(email_list_key, email)
val[email] = 'done'
setExpire = True
else:
val[email] = 'error'
if setExpire:
redisConnect.expire(email_list_key, 60*60*24*30)
return val
@rpc
def authen(self, email, otp):
success = 0
if exist_key(email):
test = redisConnect.get(email)
otp = bytes(otp, 'utf-8')
if test == otp:
success = 1
return success
Send Mail OTP Service
เมื่อมีการกรอก Email ที่ถูกต้อง จะมีการส่ง OTP ไปให้ยังทาง Email ปลายทาง
send_email_otp_dock
|__ docker-compose.yml
|__ python/
|__ Dockerfile
|__ requirements.txt
|__ rpc.py
docker-compose.yml
version: '3'
services:
send_email_otp:
container_name: send_email_otp
build: python/
restart: always
networks:
- microservice
- default
smtp_otp:
container_name: smtp_otp
image: bytemark/smtp
restart: always
environment:
RELAY_HOST: smtp.live.com
RELAY_PORT: 587
RELAY_USERNAME: yourEmail
RELAY_PASSWORD: yourPassword
networks:
default:
external:
name: email_otp_network
microservice:
external:
name: microservice_network
อย่าลืมเปลี่ยน Email/Password หรือ SMTP HOST ด้วยนะ
Dockerfile
FROM python:3.7.3-alpine3.8
RUN apk add --no-cache build-base
WORKDIR /app
COPY rpc.py .
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
CMD nameko run rpc --broker amqp://guest:guest@rabbitmq:5672
requirements.txt
nameko
rpc.py
import smtplib
from email.message import EmailMessage
from nameko.rpc import rpc
def send_email(otp, email):
msg = EmailMessage()
text = "OTP สำหรับการลงทะเบียนนักศึกษาใหม่ของท่าน คือ " + str(otp) + ", OTP ของท่านจะหมดอายุใน 2 นาที"
msg.set_content(text)
msg['Subject'] = 'Register OTP'
msg['To'] = email
s = smtplib.SMTP("smtp_otp",25)
s.ehlo()
s.sendmail(from_addr = 'yourEmail', to_addrs = email, msg = msg.as_string())
s.quit()
class Email:
name = "email_otp"
@rpc
def send(self, otp, email):
send_email(otp, email)
OTP Gateway Service
เป็นตัวกลางที่คอยควบคุม Service ต่างๆที่กล่าวไว้ด้านบน
getotp ทำหน้าที่สร้าง OPT ส่งกลับทาง Email ตามบัญชีผู้ผ่านการสอบสัมภาษณ์
create_email_list ทำหน้าที่สร้างบัญชี Email ของผู้ผ่านการสอบสัมภาษณ์ โดยจะกำหนดอายุของบัญชีไว้ 30 วัน
authen ทำหน้าที่ยืนยันตัวตนในงานทะเบียนนักศึกษาด้วย Email และ OTP
otp_gateway_dock
|__ docker-compose.yml
|__ python/
|__ Dockerfile
|__ requirements.txt
|__ api.py
docker-compose.yml
version: '3'
services:
register:
container_name: otp_gateway
build: python/
restart: always
ports:
- "7002:80"
networks:
default:
external:
name: microservice_network
Dockerfile
FROM python:3.7.3-alpine3.8
RUN apk add --no-cache build-base
WORKDIR /app
COPY api.py .
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
CMD uvicorn api:app --host 0.0.0.0 --port 80
requirements.txt
fastapi
uvicorn
pydantic
nameko
api.py
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List
from nameko.rpc import rpc
from nameko.standalone.rpc import ClusterRpcProxy
class Email(BaseModel):
email:str
class EmailList(BaseModel):
emails:List[str] = []
class Authen(BaseModel):
email:str
otp:str
app = FastAPI()
broker_cfg = {'AMQP_URI': "amqp://guest:guest@rabbitmq"}
@app.post("/getotp/")
def get_otp(email: Email):
with ClusterRpcProxy(broker_cfg) as rpc:
otp = rpc.otp.create(email.email)
if otp != 0:
rpc.email_otp.send.call_async(otp, email.email)
return {'results': str(otp)}
@app.post("/create_email_list/")
def create_email_list(email_list: EmailList):
with ClusterRpcProxy(broker_cfg) as rpc:
val = rpc.otp.create_email_list(email_list.emails)
return {'results': val}
@app.post("/authen/")
def authen(authen: Authen):
with ClusterRpcProxy(broker_cfg) as rpc:
success = rpc.otp.authen(authen.email, authen.otp)
if success == 1:
rpc.otp.delete.call_async(authen.email)
return {'results': success}
และสามารถทดสอบ OTP Gateway Service ได้ด้วย URL ด้านล่าง
http://lab10.cpsudevops.com:7002/docs
ไปยัง create_email_list เพื่อ create Email ผู้ใช้งาน
และทดลอง get_otp และใส่ Email ที่ ระบบไม่อนุญาติ
ทดลอง Email ที่ถูกต้อง
และสุดท้าย authen ลองด้วย OTP ที่ไม่ถูกต้อง
OTP ที่ถูกต้อง
API Authentication and Rate Limiting
Config Kong ผ่าน Konga ผ่าน URL https://konga.lab10.cpsudevops.com สร้าง Create Email List, Get OTP และ Authen Service ผ่านเมนู SERVICES
Name: ["createEmailList", "getOTP", "authen"]
Protocol: http
Host: your private ip
Port: 7002
Path: ["/create_email_list", "getotp", "authen"]
กรอกข้อมูลตามด้านบนตามลำดับ และทำการสร้าง Route สำหรับ Service ทั้ง 3 ตัวที่เพิ่งสร้างขึ้นมาผ่านทาง Service -> Routes -> ADD ROUTE
Name: ["createEmailList", "getOTP", "authen"]
Path: ["/create_email_list", "getotp", "authen"]
Method: POST
Protocols: http
หลังจากเพิ่ม Route เรียบร้อยแล้ว ต่อไปคือให้เพิ่ม Plugins Rate Limiting, Basic Authen และ Key Authen ทั้ง 3 Route
Rate Limiting กำหนด Hour = 5000
Disabled key-auth เพื่อทดลองส่ง Request ไปยัง https://service.lab10.cpsudevops.com/create_email_list/ โดยให้ Kong ทำ Authen แบบ basic-auth
ที่มี Error เนื่องเพราะมี Email ซ้ำกับในระบบ
Session Server
การฝาก Session ไว้ที่ Redis Server นั้นทำให้ง่ายต่อการ Scale Out โดย Session ไม่หลุด ซึ่งผู้ใช้สามารถใช้งาน Web Application ได้อย่างต่อเนื่อง โดยเราจะติดตั้ง Redis Server ตามขั้นตอน ดังนี้
session_server_dock
|__ docker-compose.yml
docker-compose.yml
version: '3'
services:
session_server:
container_name: session_server
image: "redis:alpine"
restart: always
networks:
default:
external:
name: microservice_network
Register UI with Flask
เราจะสร้างหน้า UI ทั้งหมด 3 หน้า ได้แก่
1หน้าขอ OPT
2หน้ายืนยันตัวตน
3หน้าลงทะเบียนนักศึกษาใหม่
โดยใช้ Flask ซึ่งเป็น Web Framework สำหรับ Python ตามขั้นตอนดังนี้
register_ui_dock
|__ docker-compose.yml
|__ python/
|__ Dockerfile
|__ requirements.txt
|__ ui.py
|__ model.py
|__ templates/
|__ otp.html
|__ authen.html
|__ reg.html
docker-compose.yml
version: '3'
services:
register_ui:
container_name: register_ui
build: python/
restart: always
expose:
- "80"
environment:
VIRTUAL_HOST: www.lab10.cpsudevops.com
LETSENCRYPT_HOST: www.lab10.cpsudevops.com
networks:
- webproxy
- default
networks:
webproxy:
external:
name: webproxy
default:
external:
name: microservice_network
Dockerfile
FROM python:3.7.3-alpine3.8
RUN apk add --no-cache build-base
WORKDIR /app
COPY ui.py .
COPY model.py .
COPY requirements.txt .
COPY templates templates
RUN pip install --no-cache-dir -r requirements.txt
CMD ["gunicorn", "--bind", ":80", "ui:app"]
requirements.txt
gunicorn
requests
Flask
Flask-Bootstrap
Flask-WTF
WTForms
redis
flask_session
ui.py โดยเจ้าตัวนี้จะมีการฝาก Session ไว้กับ Redis และมีการยืนยันตัวตนแบบ Basic Authen
from flask import Flask, request, render_template, redirect, session
from model import OTPForm, AuthenForm, RegForm
from flask_bootstrap import Bootstrap
from requests.auth import HTTPBasicAuth
import requests
import json
import redis
from flask_session import Session
auth = HTTPBasicAuth('admin', 'devops101')
app = Flask(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = redis.from_url('redis://session_server:6379')
sess = Session()
sess.init_app(app)
def getSession(key):
return session.get(key, 'none')
def setSession(key, value):
session[key] = value
app.config.from_mapping(
SECRET_KEY=b'\xd6\x04\xbdj\xfe\xed$c\x1e@\xad\x0f\x13,@G')
Bootstrap(app)
@app.route('/', methods=['GET', 'POST'])
def otp():
form = OTPForm(request.form)
if request.method == 'POST' and form.validate_on_submit():
headers = {'content-type': 'application/json'}
URL = 'https://service.lab10.cpsudevops.com/getotp/'
data = {'email': form.email.data}
res = requests.post(URL, data = json.dumps(data), headers=headers, auth=auth)
result = res.json().get('results')
if result != '0':
setSession('email', form.email.data)
return redirect('https://www.lab10.cpsudevops.com/authen')
else:
return 'Email ของคุณไม่ถูกต้อง/คุณเคยลงทะเบียนแล้ว'
return render_template('otp.html', form=form)
@app.route('/authen', methods=['GET', 'POST'])
def authen():
form = AuthenForm(request.form)
if request.method == 'POST' and form.validate_on_submit():
headers = {'content-type': 'application/json'}
URL = 'https://service.lab10.cpsudevops.com/authen/'
data = {'email': getSession('email'), 'otp': form.otp.data}
res = requests.post(URL, data = json.dumps(data), headers=headers, auth=auth)
result = res.json().get('results')
if result == 1:
setSession('authen', 'yes')
return redirect('https://www.lab10.cpsudevops.com/reg')
else:
return 'กรุณาใส่ OTP/Email ใหม่'
return render_template('authen.html', form=form)
@app.route('/reg', methods=['GET', 'POST'])
def registration():
if getSession('authen') != 'yes':
return 'คุณไม่ได้รับอนุญาตให้เข้าถึงหน้านี้'
form = RegForm(request.form)
if request.method == 'POST' and form.validate_on_submit():
headers = {'content-type': 'application/json'}
URL = 'https://service.lab10.cpsudevops.com/register/'
data = {'firstname': form.name_first.data, 'lastname': form.name_last.data, 'email': getSession('email')}
res = requests.post(URL, data = json.dumps(data), headers=headers, auth=auth)
setSession('authen', 'no')
return 'ระบบจะแจ้งยืนยันการลงทะเบียนทาง Email'
return render_template('reg.html', form=form)
model.py
from wtforms import SubmitField, BooleanField, StringField, PasswordField, validators
from flask_wtf import Form
class OTPForm(Form):
email = StringField('Email Address', [validators.DataRequired(), validators.Email(), validators.Length(min=6, max=35)])
submit = SubmitField('Submit')
class AuthenForm(Form):
otp = StringField('OTP', [validators.DataRequired(), validators.Length(min=6, max=6)])
submit = SubmitField('Submit')
class RegForm(Form):
name_first = StringField('ชื่อ', [validators.DataRequired()])
name_last = StringField('นามสกุล', [validators.DataRequired()])
submit = SubmitField('Submit')
otp.html
{% extends "bootstrap/base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block content %}
<div class="container">
<h3>ลงทะเบียนนักศึกษาใหม่</h3>
<hr>
<form action="" method="post" class="form" role="form">
{{ form.csrf_token() }}
<div class="form-group">
{{ wtf.form_field(form.email, type='email', class='form-control', placeholder='Email Address') }}
</div>
<button type="submit" class="btn btn-primary">ขอ OTP</button>
</form>
<hr>
<p>Devops and Cloud Engineering - CPSU Next</p>
</div>
{% endblock %}
authen.html
{% extends "bootstrap/base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block content %}
<div class="container">
<h3>ลงทะเบียนนักศึกษาใหม่ - ยืนยันตัวตน</h3>
<hr>
<form action="" method="post" class="form" role="form">
{{ form.csrf_token() }}
<div class="form-group">
{{ wtf.form_field(form.otp, class='form-control', placeholder='OTP') }}
</div>
<button type="submit" class="btn btn-primary">ยืนยัน</button>
</form>
<hr>
<p>Devops and Cloud Engineering - CPSU Next</p>
</div>
{% endblock %}
reg.html
{% extends "bootstrap/base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block content %}
<div class="container">
<h3>ลงทะเบียนนักศึกษาใหม่ - OTP</h3>
<hr>
<form action="" method="post" class="form" role="form">
{{ form.csrf_token() }}
<div class="row">
<div class="form-group col-md-6">
{{ wtf.form_field(form.name_first, class='form-control', placeholder='ชื่อ') }}
</div>
<div class="form-group col-md-6">
{{ wtf.form_field(form.name_last, class='form-control', placeholder='นามสกุล') }}
</div>
</div>
<button type="submit" class="btn btn-primary">ลงทะเบียน</button>
</form>
<hr>
<p>Devops and Cloud Engineering - CPSU Next</p>
</div>
{% endblock %}
สามารถทดสอบได้ผ่าน URL ด้านล่างนี้
https://www.lab10.cpsudevops.com
โดยหลังจากกรอก Email ที่ถูกต้องแล้ว ระบบจะทำการส่ง OTP ไปยัง Email
และเพิ่มไปยัง Database