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

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

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.

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ł.

Autorzy

Artykuły na blogu są pisane przez osoby z zespołu EuroLinux. 80% treści zawdzięczamy naszym developerom, pozostałą część przygotowuje dział sprzedaży lub marketingu. Dokładamy starań, żeby treści były jak najlepsze merytorycznie i językowo, ale nie jesteśmy nieomylni. Jeśli zauważysz coś wartego poprawienia lub wyjaśnienia, będziemy wdzięczni za wiadomość.