Menjadi Developer Web dengan Python dan Flask Bagian V: User Login

Bagus Aji Santoso 29 Januari 2018

 Menjadi Developer Web dengan Python dan Flask Bagian V: User Login

Artikel ini merupakan terjemahan The Flask Mega-Tutorial Part V: User Logins dari seri The Flask Mega-Tutorial karya Miguel Grinberg yang akan dirilis secara gratis bab per bab sampai bulan Mei 2018. Dukung penulis aslinya dengan membeli buku/video tutorial lengkapnya di sini.

Di bab tiga kita telah belajar bagaimana membuat halaman login dan di bab empat kita belajar bagaimana bekerja dengan database. Bab ini akan mengajarkan pembaca bagaimana untuk mengombinasikan kedua topik tadi untuk membuat sistem login sederhana.

Tips Menjaga Kesehatan Bagi Programmer

Password Hashing

Di bab empat, model user diberikan field password_hash yang hingga saat ini belum terpakai. Tujuan pembuatan field ini ialah untuk menyimpan sebuah hash dari password user untuk melakukan verifikasi. Password hashing merupakan topik yang cukup kompleks sehingga dapat kita serahkan pada ahlinya, namun ada beberapa pustaka yang memberikan kemudahan jika kita ingin mengimplementasi sistem tersebut.

Satu package yang mengimplementasi password hashing adalah Werkzeug. Package ini mungkin sudah pernah pembaca lihat saat memasang Flask karena ia menjadi salah satu dependensi utama. Karena menjadi dependensi utama, Werkzeug sudah terpasang di virtual environment kita. Kode Python berikut mendemokan bagaimana melakukan hashing password:

>>> from werkzeug.security import generate_password_hash
>>> hash = generate_password_hash('foobar')
>>> hash
'pbkdf2:sha256:50000$vT9fkZM8$04dfa35c6476acf7e788a1b5b3c35e217c78dc04539d295f011f01f18cd2175f'

Pada contoh ini, password foobar diubah menjadi sebuah string baru (encoded string) melalui serangkai operasi kriptografik yang belum diketahui penangkalnya. Artinya orang yang memiliki password ter-hash tidak akan mungkin bisa menggunakannya untuk mendapatkan password asli. Jika kita melakukang hashing password yang sama beberapa kali, kita akan mendapatkan hasil yang berbeda sehingga membuat hal yang mustahil untuk mencari dua user dengan password yang sama hanya dari kode hash-nya.

Proses verifikasi dilakukan dengan fungsi yang kedua dari Werkzeug, sebagai berikut:

>>> from werkzeug.security import check_password_hash
>>> check_password_hash(hash, 'foobar')
True
>>> check_password_hash(hash, 'barfoo')
False

Fungsi verifikasi ini meminta sebuah password hash yang sebelumnya sudah kita buat, dan sebuah password yang akan dimasukkan oleh user saat login. Fungsi ini akan mengembalikan nilai True jika password yang ditulis dengan kode hash cocok atau False jika sebaliknya.

Logika keseluruhan password hashing dapat diimplementasi sebagai dua method baru di model user:

from werkzeug.security import generate_password_hash, check_password_hash

# ...

class User(db.Model):
    # ...

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

Dengan dua method ini, sebuah objek user sekarang dapat melakuakn verifikasi password dengan aman tanpa pernah menyimpan password aslinya. Berikut ini contoh penggunaan dua method yang baru tadi:

>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('mypassword')
>>> u.check_password('anotherpassword')
False
>>> u.check_password('mypassword')
True

Introduction to Flask-Login

Di bab ini penulis akan mengenalkan sebuah ekstensi Flask yang sangat populer bernama Flask-Login. Ekstensi ini mengatur status login user, sehingga saat mereka login dan pindah ke halaman yang berbeda, aplikasi masih ingat bahwa user tersebut sedang login. Ia juga memiliki fitur "remember me" yang memungkinkan user tetap dalam status login meski browser telah ditutup. Untuk memulai, silahkan pasang Flask-Login di virtual environment:

(venv) $ pip install flask-login

Seperti ekstensi lainnya, Flask-Login perlu dideklarasi dan diinisialisasi di app/init.py:

# ...
from flask_login import LoginManager

app = Flask(__name__)
# ...
login = LoginManager(app)

# ...

Skill yang Harus Dimiliki Programmer Masa Kini (Bagian 2) — Backend

Preparing The User Model for Flask-Login

Ekstensi Flask-Login bekerja dengan model user aplikasi kita lalu akan meminta beberapa method dan properti untuk diimplementasi didalamnya. Karena pendekatan ini, selama method dan properti yang diminta ada di dalam model, apapun sistem database yang dipakai kita tetap bisa bekerja dengan ekstensi ini.

Empat komponen yang diminta adalah:

  • is_authenticated: sebuah properti yang akan bernilai True jika user memiliki kredensial yang valid atau False jika sebaliknya.
  • is_active: adalah properti yang akan bernilai True jika akun user aktif atau False jika sebaliknya.
  • is_anonymous: sebuah properti yang akan bernilai False untuk user biasa dan True untuk user khusus berjenis, anonymous user.
  • get_id(): sebuah method yang akan mengirimkan string yang berisi identifikasi unik setiap user (unicode, jika menggunakan Python 2).

Kita dapat mengimplementasi keempat method ini dengan mudah, namun karena implementasinya cukup umum, Flask-Login memberikan sebuah kelas mixin bernam UserMixin yang didalamnya terdapat implementasi untuk sebagian besar kelas model user. Berikut ini bagaimana kelas mixin ditambah ke dalam model:

# ...
from flask_login import UserMixin

class User(UserMixin, db.Model):
    # ...

User Loader Function

Flask-Login menyimpan user yang login dengan menyimpan identifikasi unik-nya di dalam user session Flask, sebuah ruang khusus yang diberikan kepada tiap user yang terhubung ke aplikasi kita. Setiap kali user yang sudah login pindah halaman, Flask-Login akan mengambil ID dari user dari sesi tersebut, dan memuat user dengan ID yang sama ke memori.

Karena Flask-Login tidak tahu apapun tentang database, ia memerlukan bantuan aplikasi kita untuk mengambil data user. Untuk alasan itu, ekstensi ini meminta kita untuk mengatur fungsi user loader, yang akan dipanggil saat akan mengambil user berdasarkan ID. Fungsi ini dapat ditambahkan di modul app/models.py:

from app import login
# ...

@login.user_loader
def load_user(id):
    return User.query.get(int(id))

Fungsi user loader terdaftar didalam Flask-Login menggunakan dekorator @login.user_loader. Properti id yang dikirim oleh Flask-Login dikirim ke fungsi sebagai sebuah argumen akan berupa string, sehingga database yang menggunakan ID angka harus mengubah string tadi menjadi integer seperti yang terlihat di di atas.

Logging Users In

Mari kita lihat lagi view login dimana kita mengimplementasi fake login yang hanya menampilkan pesan flash(). Sekarang karena aplikasi sudah memiliki akses ke database user dan tahu bagaimana membuat dan memverifikasi password hash, view ini dapat dilanjutkan.

# ...
from flask_login import current_user, login_user
from app.models import User

# ...

@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        if user is None or not user.check_password(form.password.data):
            flash('Invalid username or password')
            return redirect(url_for('login'))
        login_user(user, remember=form.remember_me.data)
        return redirect(url_for('index'))
    return render_template('login.html', title='Sign In', form=form)

Dua baris pertama di dalam fungsi login() akan membawa user ke halaman index jika dia sudah login dan mencoba mengakses URL /login (karena kita tidak ingin user yang sudah login, melakukan login lagi kan?). Variabel current_user datang dari dalam Flask_Login dan dapat dipanggil kapan saja saat ingin memanggil objek user yang login. Isi dari variabel ini dapat berupa objek user dari database atau user anonymous apabila user belum login. Ingat properti yang diminta oleh Flask-Login untuk ditambahkan di model user? salah satunya adalah is_authenticated yang berguna untuk memeriksa apakah user melakukan login atau belum.

Sebagai pengganti flash() yang kita gunakan dulu, sekarang kita akan betul-betul me-login-kan user. Langkah pertama ialah untuk memuat user dari database. Bagian username datang dari form yang ada sehingga kita bisa mencari user yang ada di database dengan username ini. Untuk itu kita menggunakan method filter_by() didalam objek SQLAlchemy. Hasil dari filter_by() adalah objek-objek dengan username yang sesuai. Karena penulis tahu bahwa hanya akan ada satu hasil atau tidak sama sekali, maka kueri ini dapat diakhiri dengan pemanggilan first() yang artinya kita akan mendapat objek user jika ada atau None jika tidak ada. Di bab empat kita sudah melihat bagaimana pemanggil all() akan mengambil seluruh hasil yang sesuai dengan kueri. Method first() adalah cara lain yang sering dipakai untuk mengeksekusi kueri dimana kita hanya membutuhkan satu data.

Jika kita mendapatkan username yang sesuai dengan data dari form, kita bisa melakukan pemeriksaan apakah password yang dikirimkan juga sesuai dengan user yang ada di database. Hal ini bisa kita lakukan dengan memanggil check_password() yang ada di datas. Method ini akan mengambil nilai hash yang disimpan di database lalu membandingkannya dengan password yang dikirim dari form. Jika username tidak ada atau password salah, maka akan muncul sebuah pesan dan membawa user kembali ke halaman login sehingga mereka bisa mencoba lagi.

Jika username dan password keduanya benar, maka kita memanggil fugnsi login_user() yang datang dari Flask-Login. Fungsi ini akan mendaftarkan user sebagai user yang login sehingga current_user akan menyimpan nilai user tersebut.

Untuk menyelesaikan proses login ini, kita mengarahkan user ke halaman index.

Logging Users Out

Kita tentu memberikan user opsi untuk logout dari aplikasi. Ini bisa kita lakukan dengan fungsi logout_user() di Flask-Login. Ini kodenya:

# ...
from flask_login import logout_user

# ...

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

Untuk menampilkan link ini ke user, kita bisa membuat link Login berubah menjadi Logout setelah user login. Peruabhan ini bisa dilakukan dengan sebuah kondisi di template base.html:

    <div>
        Microblog:
        <a href="{{ url_for('index') }}">Home</a>
        {% if current_user.is_anonymous %}
        <a href="{{ url_for('login') }}">Login</a>
        {% else %}
        <a href="{{ url_for('logout') }}">Logout</a>
        {% endif %}
    </div>

Properti is_anonymous merupakan salah satu atribut Flask-Login yang ada berkat kelas UserMixin di kelas model kita. Perintah current_user.is_anonymous akan bernilai True hanya jika user tidak login.

Requiring Users To Login

Flask-Login memberikan sebuah fitur yang sangat berguna untuk memaksa user login sebelum mereka dapat mengunjungi halaman tertentu. Jika seorang user yang belum login mencoba mengunjungi halaman yang tertentu, Flask-Login dapat secara otomatis membawanya ke halaman login dan baru akan membawanya ke halaman khusus tadi apabila sudah menyelesaikan proses login.

Untuk bisa mengimplementasi fitur ini, Flask-Login harus tahu fungsi view mana yang melakukan proses login. Tambahkan kode berikut ke app/init.py:

# ...
login = LoginManager(app)
login.login_view = 'login'

Bagian 'login' di atas merupakan nama fungsi (atau endpoint) untuk view login kita. Artinya adalah nama yang akan kita tulis saat memanggil url_for() jika ingin mendapatkan URL-nya.

Cara Flask-Login mengamankan fungsi view dari user anonymous ialah dengan menambahkan dekorator bernama @login_required. Saat menambahkan dekorator ini ke fungsi view di bawah @app.route, fungsi ini menjadi aman dari user anonymous dan memaksa mereka untuk login sebelum bisa membukanya. Berikut ini bagaimana dekorator tadi diaplikasikan ke view index:

from flask_login import login_required

@app.route('/')
@app.route('/index')
@login_required
def index():
    # ...

Sisa yang harus diimplementasi sekarang adalah bagaimana membawa user yang sudah berhasil login ke halaman yang sebelumnya ingin ia buka. Saat seorang user belom login dan mencoba mengakses halaman yang diberikan @login_required, dekorator tersebut akan membawa user kembali ke halaman login, tapi ia juga akan memberikan informasi tambahan yang dapat membantu mengarahkan user ke halaman yang ia inginkan. Misal, saat user mencoba mengakses /index, @login_required akan mencegat request, mengarahkannya ke /login, namun ada satu query string ditambahkan ke URL ini, sehingga membuat URL lengkapnya menjadi /login?next=/index. Bagian next menunjukkan URL asli yang ingin dituju sehingga aplikasi bisa memakainya untuk mengarahkan user ke sana setelah login.

Berikut ini kode yang menunjukkan pembacaan kueri next yang penggunaannya:

from flask import request
from werkzeug.urls import url_parse

@app.route('/login', methods=['GET', 'POST'])
def login():
    # ...
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        if user is None or not user.check_password(form.password.data):
            flash('Invalid username or password')
            return redirect(url_for('login'))
        login_user(user, remember=form.remember_me.data)
        next_page = request.args.get('next')
        if not next_page or url_parse(next_page).netloc != '':
            next_page = url_for('index')
        return redirect(next_page)
    # ...

Tepat setelah user masuk dengan memanggil fungsi login_user() milik Flask-Login, isi dari next dapat diambil di bawahnya. Flask memberikan variabel request yang memiliki semua informasi klien saat melakukan request. Secara umum atribut request.args menampilkan konten query string dalam format dictionary yang mudah di baca. Ada tiga kasus yang mungkin terjadi saat akan membawa user ketujuan asli setelah login yang berhasil:

  • Jika URL login tidak memiliki argumen next, maka user akan dibawa ke halaman index.
  • Jika URL login memiliki argumen next yang menggunakan relative path (atau dengan kata lain, URL tanpa bagian domain), maka user akan diarahkan ke URL tersebut.
  • Jika URL login memiliki argumen next yang menggunakan URL utuh termasuk nama domainnya, maka user tersebut akan diarahkan ke halaman index.

Kasus pertama dan kedua sudah cukup jelas. Untuk kasus yang ketiga, dimaksudkan agar aplikasi menjadi lebih aman. Apabila ada orang yang menyisipkan URL dan mengarahkannya ke situs aneh di argumen next, maka user tetap aman dari orang itu. Untuk menentukan apakah URL tersebut relatif atau absolut, penulis menggunakan fungsi url_parse() dari Wekzeug untuk memeriksa apakah ia memiliki komponen netloc atau tidak.

Tips Memilih Bahasa Pemrograman Backend untuk Dipelajari

Showing The Logged In User in Templates

Bila pembaca ingat di bab dua, kita telah membuat fake user untuk membantu mendesain halaman home sebelum memiliki sistem user management. Karena sekarang aplikasi kita sudah memiliki user asli, maka sekarang kita bisa menghapus fake user dan mulai bekerja dengan user asli. Mari kita ganti fake user dengan current_user-nya Flask-Login di template:

{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    {% for post in posts %}
    <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>
    {% endfor %}
{% endblock %}

Lalu kita bisa menghapus argumen user di view function berikut:

@app.route('/')
@app.route('/index')
def index():
    # ...
    return render_template("index.html", title='Home Page', posts=posts)

Sekarang adalah waktu yang tepat untuk memeriksa bagaimana login dan logout bekerja. Karena belum ada sistem registrasi, satu-satunya cara adalah untuk menambah seorang user ke database melalui Python shell, jadi jalankan flask shell dan masukkan perintah-perintah di bawah untuk membuat user baru:

>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()

Jika pembaca sudah menjalankan aplikasi cobalah untuk mengakses http://localhost:5000/ atau http://localhost:5000/index, pembaca akan langsung dibawa ke halaman login dan setelah melakukan login, akhirnya kita akan dibawa ke halaman yang sebelumnya kita akses.

User Registration

Bagian terakhir yang akan kita selsaikan di bab ini adalah halaman registrasi sehingga user bisa mendaftar melalui form di web. Mari mulai dengan membuat kelas form baru di app/forms.py:

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from app.models import User

# ...

class RegistrationForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    email = StringField('Email', validators=[DataRequired(), Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    password2 = PasswordField(
        'Repeat Password', validators=[DataRequired(), EqualTo('password')])
    submit = SubmitField('Register')

    def validate_username(self, username):
        user = User.query.filter_by(username=username.data).first()
        if user is not None:
            raise ValidationError('Please use a different username.')

    def validate_email(self, email):
        user = User.query.filter_by(email=email.data).first()
        if user is not None:
            raise ValidationError('Please use a different email address.')

Ada beberapa hal menarik untuk validasi form yang baru kita buat. Pertama, field email penulis tambahkan sebuah validator kedua setelah DataRequired, bernama Email. Ia adalah validator bawaan lainnya di WTForms yang memastikan bahwa user menulis struktur alamat email yang benar.

Sebagai form registrasi, tentu kita ingin user menulis password dua kali untuk menghindari typo. Oleh karena itu penulis disini memiliki password dan password2. Field password yang kedua menggunakan validator bawaan lain bernama EqualTo. Validator ini memastikan bahwa nilai yang ditulis sama persis dengan yang ada di field password.

Penulis juga menambahkan dua method baru ke kelas ini bernama validate_username() dan validate_email(). Ketiak kita menambahkan menthod yang strukturnya validate_<nama_field>, WTForms akan menganggapnya sebagai validator kustom dan memanggilnya setelah validator bawaan. Dalam kasus ini, penulis ingin memastikan username dan alamat email yang dimasukkan belum ada di database, sehingga dua method tadi akan melakukan kueri database yang mengharapkan tidak ada hasil (belum ada usernya). Jika ternyata user yang sama sudah ada, maka akan dipanggil ValidationError. Pesan yang ditulis di dalamnya akan tampil di halaman berikutnya sehingga dapat dilihat oleh user.

Untuk menampilkan form di dalam sebuah halaman web, saya perlu memiliki template HTML baru yang akan disimpan di app/templates/register.html. Template ini akan dibuat mirip seperti form login:

{% extends "base.html" %}

{% block content %}
    <h1>Register</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.username.label }}<br>
            {{ form.username(size=32) }}<br>
            {% for error in form.username.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.email.label }}<br>
            {{ form.email(size=64) }}<br>
            {% for error in form.email.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.password.label }}<br>
            {{ form.password(size=32) }}<br>
            {% for error in form.password.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.password2.label }}<br>
            {{ form.password2(size=32) }}<br>
            {% for error in form.password2.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

Template form login memerlukan sebuah link yang bisa membawa user ke halaman registrasi. Tambahkan link ini dibagian bawah form:

    <p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>

Lalu terakhir, kita perlu menulis fungsi view baru yang akan menangani registrasi user di app/routes.py:

from app import db
from app.forms import RegistrationForm

# ...

@app.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(username=form.username.data, email=form.email.data)
        user.set_password(form.password.data)
        db.session.add(user)
        db.session.commit()
        flash('Congratulations, you are now a registered user!')
        return redirect(url_for('login'))
    return render_template('register.html', title='Register', form=form)

Apa yang dilakukan oleh view ini cukup jelas. Pertama kita memastikan user yang memanggil view ini belum melakukan login. Form ditangani dengan cara yang sama saat melakukan login. Logika yang ditulis di dalam if validate_on_submit() ialah membuat user baru dengan username, email dan password yang dikirim lalu menyimpannya ke database. Terakhir membawa user ke halaman login.

Registration Form

Dengan perubahan-perubahan tadi, user sekarang sudah bisa membuat akun baru, melakukan login dan logout. Pastikan pembaca mencoba semua fitur validasi yang ditambahkan di form registrasi untuk lebih memahami bagaimana cara kerjanya. Kita akan mengunjungi lagi sistem otentikasi user di masa mendatang untuk menambahkan fitur lain misalnya untuk mengijinkan user me-reset password saat lupa. Tapi untuk saat ini, apa yagn tadi kita tulis sudah mencukupi.