REST API (RESTful) działające na systemie klasy Enterprise cz. 2 – autentykacja użytkownika

REST API (RESTful) działające na systemie klasy Enterprise

W poprzednim artykule z tej serii zaprezentowaliśmy, jak przy pomocy webowego frameworka, jakim jest flask, zbudować prosty serwer. Dziś pokażemy, jak rozszerzyć ten projekt i usprawnić go o autentykację użytkownika za pomocą plików cookies.

Wszystkich, którzy nie czytali poprzedniego artykułu, serdecznie do tego zachęcamy – REST API (RESTful) działające na systemie klasy Enterprise cz. 1. Jest on wprowadzeniem do serii oraz wiąże się bezpośrednio z działaniami, które dziś podejmiemy.

Struktura, którą do tej pory stworzyliśmy, prezentuje się następująco:

.
├── app
│   ├── __init__.py
│   └── app.py
├── httpd.conf
├── run.py
└── wsgi.py

W katalogu projektu mogą pojawić się też pliki oraz katalogi takie jak np bin, lib, __pycache__ i inne. Są to pliki pozostawione przez uruchomioną aplikację oraz pliki tworzące wirtualne środowisko pythonowe. Do naszych działań będziemy potrzebować flask-sqlalchemy oraz flask-login, które instalujemy przy pomocy:

pip install flask flask-sqlalchemy flask-login

Flask-login dostarcza nam narzędzia pozwalające na prostą obsługę autentykacji użytkownika przez login i hasło z automatyczną obsługą plików cookes.

1. Model użytkownika

Pierwszym krokiem będzie stworzenie modelu użytkownika. W tym celu w pliku UserModel.py tworzymy bazę danych przy pomocy metody SQLAlchemy() wraz z klasą UserModel dziedziczącą po UserMixin oraz db.Model. UserMixin jest klasą zawierającą metody podstawowej obsługi użytkownika, takie jak:

  • is_authenticated() – wykorzystamy ją do rozróżnienia zalogowanych użytkowników
  • get_id() – zwraca ID zalogowanego użytkownika
  • is_active() – można ją wykorzystać w celu weryfikacji adresu e-mail lub blokowania użytkowników
  • is_anonymous() – służy do identyfikowania użytkownika niezalogowanego lub anonimowego.

db.Model zajmuje się obsługą tabeli w bazie danych zawierającej informacje o użytkownikach. W ciele klasy definiujemy, jakie dane będzie przyjmował użytkownik:

class UserModel(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(80), unique=True)
    username = db.Column(db.String(20))
    password_hash = db.Column(db.String())

Następnie tworzymy metody przetwarzające i zapisujące hasło. W tym celu wykorzystamy wbudowane w pythona moduły oraz metody: hashlib.sha256 isecrets.compare_digest. Stworzony w ten sposób plik powinien wyglądać w następujący sposób:

from hashlib import sha256
from secrets import compare_digest
from flask_login import UserMixin
from flask_sqlalchemy import SQLAlchemy

 
db = SQLAlchemy()
 
class UserModel(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(80), unique=True)
    username = db.Column(db.String(20), unique=True)
    password_hash = db.Column(db.String())
 
    def set_password(self, password):
        self.password_hash = sha256(password.encode()).digest()
     
    def check_password(self, password):
        return compare_digest(self.password_hash, sha256(password.encode()).digest())

2. Obsługa sesji użytkownika

Flask wyposażony jest w proste narzędzia zapewniające obsługę sesji. W tym projekcie wykorzystamy LoginManager zaimportowany z modułu flask_login. W pliku o nazwie login.py tworzymy obiekt klasy LoginManager odpowiedzialny za obsługę sesji użytkownika:

from .UserModel import UserModel
from flask_login import LoginManager

lm = LoginManager()

@lm.user_loader
def load_user(id):
    return UserModel.query.get(int(id))

Przy pomocy dekoratora user_loader definiujemy funkcję odpowiedzialną za wczytanie użytkownika o danym ID z bazy danych.

3. Inicjowanie bazy danych oraz instancji klasy LoginManager

Teraz należy zainicjować bazę danych oraz stworzoną przez nas we wcześniejszym punkcie instancję.

Do przygotowanego w poprzednim artykule pliku app.py, który wygląda następująco:

#!/usr/bin/env python

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello world!"

dodajemy konfigurację bazy danych:

app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite'
app.config['SECRET_KEY'] = 'secret-key-goes-here'

oraz inicjujemy pustą tabelę przed otrzymaniem pierwszego zapytania do serwisu:

db.init_app(app)
@app.before_first_request
def create_table():
    db.create_all()

Inicjujemy także obiekt login zaimportowany ze stworzonego przez nas pliku:

lm.init_app(app)
lm.login_view = 'login'

Przy pomocy lm.login_view definiujemy adres strony, do której zostanie przekierowany niezalogowany użytkownik w przypadku próby otwarcia witryny, do której nie ma dostępu. Po tych operacjach plik app.py powinien wyglądać następująco:

from flask import Flask
from .UserModel import db
from .login import lm

def create_app():
    app = Flask(__name__)
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite'
    app.config['SECRET_KEY'] = 'secret-key-goes-here'

    db.init_app(app)
    @app.before_first_request
    def create_table():
        db.create_all()
    lm.init_app(app)
    lm.login_view = 'login'
    return app

app = create_app()

@app.route("/")
def hello():
    return "Hello world!"

4. Obsługa żądań

Teraz pozostało już tylko zdefiniować żądania i odpowiadające im strony html, które zostaną wyświetlone użytkownikowi.

index/hello

Zaczniemy od edycji funkcji hello, która wyświetla użytkownikowi napis hello world i jest naszą stroną startową. Umieścimy na niej przyciski przekierowujące do strony logowania oraz rejestracji. Na początku dodajmy kolejny dekorator route z adresem "/hello" oraz zmieńmy return "Hello world!" na return render_template('hello.html')

Żądanie wygląda teraz w następujący sposób:

@app.route("/")
@app.route("/hello")
def hello():
    return render_template('hello.html')

Funkcja render_template dostarczana przez moduł flask pozwala nam na wyrenderowanie strony html z szablonu, który umieszczamy w katalogu templates znajdującym się w folderze z aplikacją. Następnie tworzymy plik hello.html, w którym umieszczamy przekierowania do strony z logowaniem i rejestracją (stworzymy je w kolejnych krokach). Plik wygląda tak:

<h3>Hello world!</h3>
 
<form action = "{{url_for(login')}}" method = "GET">
    <input type = "submit" value = "Login">
</form>

<form action = "{{url_for(register')}}" method = "GET">
    <input type = "submit" value = "Register">
</form>

Uwaga. Zdefiniowanie przekierowań do nieistniejącego endpointu spowoduje błąd wewnętrzny aplikacji. Należy mieć to na uwadze w trakcie testowania działania dodawanych do niej elementów.

hi

Żądanie /hi będzie stroną wyświetlaną dla zalogowanego użytkownika. Definiujemy ją z dodaniem dekoratora login_required w następujący sposób:

@app.route("/hi")
@login_required
def hi():
    return render_template('hi.html')

Szablon html dla hi.html:

<h1>Hello {{ current_user.username }}!</h1>
 
<form action = "{{url_for('logout')}}" method = "GET">
    <input type = "submit" value = "Logout">
</form>

Jak widać, w szablonach możemy korzystać z makr takich jak current_user.username. Dzięki wykorzystaniu flask_login możemy wyświetlać dowolne informacje powiązane z naszym użytkownikiem.

login

Żądanie /login definiujemy z uwzględnieniem metody GET oraz POST. Żądanie GET będzie odpowiadać za wyświetlenie formularza, a POST za wysłanie znajdujących się w nim informacji z powrotem do serwisu. Rozpoczynamy od dekoratora z uwzględnieniem metod:

`@app.route('/login', methods = ['POST', 'GET'])`

Następnie sprawdzamy, czy użytkownik nie ma aktywnej sesji. Jeśli tak, przekierowujemy go do witryny użytkownika (w naszym przypadku będzie to /hi).

if current_user.is_authenticated:
        return redirect('/hi')

Teraz w zależności od tego, czy otrzymane żądanie jest POST, czy GET, zbieramy informacje z formularza i sprawdzamy, czy użytkownik oraz podane przez niego hasło pasują do tych znajdujących się w bazie:

username = request.form['username']
user = UserModel.query.filter_by(username = username).first()
if user is not None and user.check_password(request.form['password']):
    login_user(user)
    return redirect('/hi')
else:
    return "wrong password\n" + render_template('login.html')

lub renderujemy stronę login.html.

Jeżeli login i hasło są poprawne, to tworzymy sesję dla użytkownika przy pomocy polecenia login_user(user), po czym następuje przekierowanie go do witryny /hi. W przypadku gdy login lub hasło są błędne, użytkownik zostanie przekierowany z powrotem do witryny login.html z dopiskiem wrong password!.

Cała funkcja login prezentuje się następująco:

@app.route('/login', methods = ['POST', 'GET'])
def login():
    if current_user.is_authenticated:
        return redirect('/hi')
    if request.method == 'POST':
        username = request.form['username']
        user = UserModel.query.filter_by(username = username).first()
        if user is not None and user.check_password(request.form['password']):
            login_user(user)
            return redirect('/hi')
        else:
            return "wrong password\n" + render_template('login.html')
    return render_template('login.html')

Szablon dla strony login.html wygląda tak:

<form action = "" method = "POST">
    <label for = "username">Username:</label><br>
    <input type = "text" id = "username" name = "username"><br>
    <label for = "password">Password:</label><br>
    <input type = "password" id = "password" name = "password"><br>
    <input type = "submit" value = "Login">
</form>

<form action = "{{url_for('register') }}" method = "GET">
    <input type = "submit" value = "Register">
</form>

Oprócz formularza logowania znajduje się w nim także przycisk przekierowujący do witryny rejestracji.

register

Funkcja register jest analogiczna do funkcji login, z drobnymi różnicami. Zamiast sprawdzania, czy dany użytkownik istnieje i czy hasło jest poprawne, weryfikuje, czy e-mail lub podana nazwa użytkownika nie są zajęte:

if UserModel.query.filter_by(email=new_email).all():
    return ('Email already registered' + render_template('register.html'))
if UserModel.query.filter_by(username=new_username).all():
    return ('Username already registered' + render_template('register.html'))

Jeżeli wprowadzone dane są wolne, tworzony jest nowy użytkownik i dodawany do bazy:

 user = UserModel(email = new_email, username = new_username)
        user.set_password(new_password)
        db.session.add(user)
        db.session.commit()

Następnie użytkownik zostaje przekierowany do witryny logowania. Funkcja register prezentuje się następująco:

@app.route('/register', methods=['POST', 'GET'])
def register():
    if current_user.is_authenticated:
        return redirect('/hi')
     
    if request.method == 'POST':
        new_email = request.form['email']
        new_username = request.form['username']
        new_password = request.form['password']
        if UserModel.query.filter_by(email=new_email).all():
            return ('Email already registered' + render_template('register.html'))
        if UserModel.query.filter_by(username=new_username).all():
            return ('Username already registered' + render_template('register.html'))
        user = UserModel(email = new_email, username = new_username)
        user.set_password(new_password)
        db.session.add(user)
        db.session.commit()
        return redirect('/login')
    return render_template('register.html')

Szablon html dla witryny register także jest analogiczny do szablonu login.html:

<form action = "" method = "POST">
    <label for = "email">Email:</label><br>
    <input type = "email" id = "email" name = "email"><br>
    <label for = "username">Username:</label><br>
    <input type = "text" id = "username" name = "username"><br>
    <label for = "password">Password:</label><br>
    <input type = "password" id = "password" name = "password"><br>
    <input type = "submit" value = "Register">
</form>

<form action = "{{url_for('login')}}" method = "GET">
    <input type = "submit" value = "Login">
</form>

logout

Żądanie logout odpowiada za wylogowanie użytkownika i jest trywialną do napisania funkcją. Wystarczy użyć w niej polecenia: logout_user() dostarczonego przez moduł flask_login oraz przekierować użytkownika do strony startowej lub strony logowania:

@app.route('/logout')
def logout():
    logout_user()
    return redirect('/hello')

Podsumowanie

Ostateczna wersja pliku app.py wygląda tak:

#!/usr/bin/env python
from flask import Flask, request, render_template, redirect
from flask_login import current_user, login_user, login_required, logout_user
from .UserModel import UserModel, db 
from .login import lm

def create_app():
    app = Flask(__name__)
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite'
    app.config['SECRET_KEY'] = 'secret-key-goes-here'

    db.init_app(app)
    @app.before_first_request
    def create_table():
        db.create_all()
    lm.init_app(app)
    lm.login_view = 'login'
    return app

app = create_app()

@app.route("/")
@app.route("/hello")
def hello():
    return render_template('hello.html')

@app.route("/hi")
@login_required
def hi():
    return render_template('hi.html')

@app.route('/login', methods = ['POST', 'GET'])
def login():
    if current_user.is_authenticated:
        return redirect('/hi')
    if request.method == 'POST':
        username = request.form['username']
        user = UserModel.query.filter_by(username = username).first()
        if user is not None and user.check_password(request.form['password']):
            login_user(user)
            return redirect('/hi')
        else:
            return "wrong password\n" + render_template('login.html')
    return render_template('login.html')

@app.route('/register', methods=['POST', 'GET'])
def register():
    if current_user.is_authenticated:
        return redirect('/hi')
     
    if request.method == 'POST':
        new_email = request.form['email']
        new_username = request.form['username']
        new_password = request.form['password']
        if UserModel.query.filter_by(email=new_email).all():
            return ('Email already registered' + render_template('register.html'))
        if UserModel.query.filter_by(username=new_username).all():
            return ('Username already registered' + render_template('register.html'))
        user = UserModel(email = new_email, username = new_username)
        user.set_password(new_password)
        db.session.add(user)
        db.session.commit()
        return redirect('/login')
    return render_template('register.html')

@app.route('/logout')
def logout():
    logout_user()
    return redirect('/hello')

Natomiast struktura plików gotowej aplikacji prezentuje się w ten sposób:

.
├── app
│   ├── app.py
│   ├── db.sqlite
│   ├── __init__.py
│   ├── login.py
│   ├── templates
│   │   ├── hello.html
│   │   ├── hi.html
│   │   ├── login.html
│   │   └── register.html
│   └── UserModel.py
├── httpd.conf
├── run.py
└── wsgi.py

Powyższy materiał pokazuje prosty sposób na autentykację użytkownika opartą o pliki cookies przy pomocy frameworka flask z użyciem modułu flask_login. Oczywiście możliwe jest dalsze rozszerzanie funkcjonalności szablonów, porządkowanie aplikacji czy przenoszenie definicji żądań http do innych plików wykorzystując dostarczoną przez flask klasę Blueprint. Jest to jednak temat na osobny artykuł.