เริ่มต้นกับ Microservice #3

CrossKnight
8 min readMar 27, 2020

--

จากบทความที่แล้วที่ได้รู้จักกับเครื่องมือต่างๆมากขึ้น เช่น Kong, Grafana ในบทความนี้จะเริ่มต้นกับการทำ OTP

OTP ?

One Time Password แปลตรงๆเลยก็คือ รหัสผ่านที่มีไว้ใช้สำหรับครั้งเดียว โดยมีไว้เพื่อเพิ่มความปลอดภัยนั้นเอง ซึ่งก็มีทั้งที่ส่งเข้า SMS หรือ Email และมีอายุในการใข้งานเพียงเวลาสั้นๆเท่านั้น แต่นั้นก็จะพบกับปัญหาว่าหากเราต้องการที่จะทำ Load Balance หรือ Scale Out ละก็ จะทำยังไงให้มันยังคง Sync กันอยู่

Redis

https://redis.io/

ซึ่งไอเจ้าตัว 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

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet

Write a response