Skip to content

Commit d807011

Browse files
user logins
1 parent 675c978 commit d807011

File tree

9 files changed

+621
-0
lines changed

9 files changed

+621
-0
lines changed

docs/api.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ Authentication
5252
:special-members: __call__
5353
:members:
5454

55+
User Logins
56+
-----------
57+
58+
.. automodule:: microdot.login
59+
:inherited-members:
60+
:special-members: __call__
61+
:members:
62+
5563
Cross-Origin Resource Sharing (CORS)
5664
------------------------------------
5765

docs/extensions.rst

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ Authentication
288288
* - Examples
289289
- | `basic_auth.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/auth/basic_auth.py>`_
290290
| `token_auth.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/auth/token_auth.py>`_
291+
291292
The authentication extension provides helper classes for two commonly used
292293
authentication patterns, described below.
293294

@@ -355,6 +356,91 @@ protect your routes::
355356
@auth
356357
async def index(request):
357358
return f'Hello, {request.g.current_user}!'
359+
360+
User Logins
361+
~~~~~~~~~~~
362+
363+
.. list-table::
364+
:align: left
365+
366+
* - Compatibility
367+
- | CPython & MicroPython
368+
369+
* - Required Microdot source files
370+
- | `login.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/auth.py>`_
371+
| `session.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/session.py>`_
372+
* - Required external dependencies
373+
- | CPython: `PyJWT <https://pyjwt.readthedocs.io/>`_
374+
| MicroPython: `jwt.py <https://github.com/micropython/micropython-lib/blob/master/python-ecosys/pyjwt/jwt.py>`_,
375+
`hmac.py <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/hmac/hmac.py>`_
376+
* - Examples
377+
- | `login.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/login/login.py>`_
378+
379+
The login extension provides user login functionality. The logged in state of
380+
the user is stored in the user session cookie, and an optional "remember me"
381+
cookie can also be added to keep the user logged in across browser sessions.
382+
383+
To use this extension, create instances of the
384+
:class:`Session <microdot.session.Session>` and :class:`Login <microdot.login.Login>`
385+
class::
386+
387+
Session(app, secret_key='top-secret!')
388+
login = Login()
389+
390+
The ``Login`` class accept an optional argument with the URL of the login page.
391+
The default for this URL is */login*.
392+
393+
The application must represent users as objects with an ``id`` attribute. A
394+
function decorated with ``@login.user_loader`` is used to load a user object::
395+
396+
@login.user_loader
397+
async def get_user(user_id):
398+
return database.get_user(user_id)
399+
400+
The application must implement the login form. At the point in which the user
401+
credentials have been received and verified, a call to the
402+
:func:`login_user() <microdot.login.Login.login_user>` function must be made to
403+
record the user in the user session::
404+
405+
@app.route('/login', methods=['GET', 'POST'])
406+
async def login(request):
407+
# ...
408+
if user.check_password(password):
409+
return await login.login_user(request, user, remember=remember_me)
410+
return redirect('/login')
411+
412+
The optional ``remember`` argument is used to add a remember me cookie that
413+
will log the user in automatically in future sessions. A value of ``True`` will
414+
keep the log in active for 30 days. Alternatively, an integer number of days
415+
can be passed in this argument.
416+
417+
Any routes that require the user to be logged in must be decorated with
418+
:func:`@login <microdot.login.Login.__call__>`::
419+
420+
@app.route('/')
421+
@login
422+
async def index(request):
423+
# ...
424+
425+
Routes that are of a sensitive nature can be decorated with
426+
:func:`@login.fresh <microdot.login.Login.fresh>`
427+
instead. This decorator requires that the user has logged in during the current
428+
session, and will ask the user to logged in again if the session was
429+
authenticated through a remember me cookie::
430+
431+
@app.get('/fresh')
432+
@login.fresh
433+
async def fresh(request):
434+
# ...
435+
436+
To log out a user, the :func:`logout_user() <microdot.auth.Login.logout_user>`
437+
is used::
438+
439+
@app.post('/logout')
440+
@login
441+
async def logout(request):
442+
await login.logout_user(request)
443+
return redirect('/')
358444

359445
Cross-Origin Resource Sharing (CORS)
360446
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

examples/login/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This directory contains examples that demonstrate user logins.

examples/login/login.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from microdot import Microdot, redirect
2+
from microdot.session import Session
3+
from microdot.login import Login
4+
from pbkdf2 import generate_password_hash, check_password_hash
5+
6+
# this example provides an implementation of the generate_password_hash and
7+
# check_password_hash functions that can be used in MicroPython. On CPython
8+
# there are many other options for password hashisng so there is no need to use
9+
# this custom solution.
10+
11+
12+
class User:
13+
def __init__(self, id, username, password):
14+
self.id = id
15+
self.username = username
16+
self.password_hash = self.create_hash(password)
17+
18+
def create_hash(self, password):
19+
return generate_password_hash(password)
20+
21+
def check_password(self, password):
22+
return check_password_hash(self.password_hash, password)
23+
24+
25+
USERS = {
26+
'user001': User('user001', 'susan', 'hello'),
27+
'user002': User('user002', 'david', 'bye'),
28+
}
29+
30+
app = Microdot()
31+
Session(app, secret_key='top-secret!')
32+
login = Login()
33+
34+
35+
@login.user_loader
36+
async def get_user(user_id):
37+
return USERS.get(user_id)
38+
39+
40+
@app.route('/login', methods=['GET', 'POST'])
41+
async def login_page(request):
42+
if request.method == 'GET':
43+
return '''
44+
<!doctype html>
45+
<html>
46+
<body>
47+
<h1>Please Login</h1>
48+
<form method="POST">
49+
<p>
50+
Username<br>
51+
<input name="username" autofocus>
52+
</p>
53+
<p>
54+
Password:<br>
55+
<input name="password" type="password">
56+
<br>
57+
</p>
58+
<p>
59+
<input name="remember_me" type="checkbox"> Remember me
60+
<br>
61+
</p>
62+
<p>
63+
<button type="submit">Login</button>
64+
</p>
65+
</form>
66+
</body>
67+
</html>
68+
''', {'Content-Type': 'text/html'}
69+
username = request.form['username']
70+
password = request.form['password']
71+
remember_me = bool(request.form.get('remember_me'))
72+
73+
for user in USERS.values():
74+
if user.username == username:
75+
if user.check_password(password):
76+
return await login.login_user(request, user,
77+
remember=remember_me)
78+
return redirect('/login')
79+
80+
81+
@app.route('/')
82+
@login
83+
async def index(request):
84+
return f'''
85+
<!doctype html>
86+
<html>
87+
<body>
88+
<h1>Hello, {request.g.current_user.username}!</h1>
89+
<p>
90+
<a href="/fresh">Click here</a> to access the fresh login page.
91+
</p>
92+
<form method="POST" action="/logout">
93+
<button type="submit">Logout</button>
94+
</form>
95+
</body>
96+
</html>
97+
''', {'Content-Type': 'text/html'}
98+
99+
100+
@app.get('/fresh')
101+
@login.fresh
102+
async def fresh(request):
103+
return f'''
104+
<!doctype html>
105+
<html>
106+
<body>
107+
<h1>Hello, {request.g.current_user.username}!</h1>
108+
<p>This page requires a fresh login session.</p>
109+
<p><a href="/">Go back</a> to the main page.</p>
110+
</body>
111+
</html>
112+
''', {'Content-Type': 'text/html'}
113+
114+
115+
@app.post('/logout')
116+
@login
117+
async def logout(request):
118+
await login.logout_user(request)
119+
return redirect('/')
120+
121+
122+
if __name__ == '__main__':
123+
app.run(debug=True)

examples/login/pbkdf2.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import os
2+
import hashlib
3+
4+
# PBKDF2 secure password hashing algorithm obtained from:
5+
# https://codeandlife.com/2023/01/06/how-to-calculate-pbkdf2-hmac-sha256-with-
6+
# python,-example-code/
7+
8+
9+
def sha256(b):
10+
return hashlib.sha256(b).digest()
11+
12+
13+
def ljust(b, n, f):
14+
return b + f * (n - len(b))
15+
16+
17+
def gethmac(key, content):
18+
okeypad = bytes(v ^ 0x5c for v in ljust(key, 64, b'\0'))
19+
ikeypad = bytes(v ^ 0x36 for v in ljust(key, 64, b'\0'))
20+
return sha256(okeypad + sha256(ikeypad + content))
21+
22+
23+
def pbkdf2(pwd, salt, iterations=1000):
24+
U = salt + b'\x00\x00\x00\x01'
25+
T = bytes(64)
26+
for _ in range(iterations):
27+
U = gethmac(pwd, U)
28+
T = bytes(a ^ b for a, b in zip(U, T))
29+
return T
30+
31+
32+
# The number of iterations may need to be adjusted depending on the hardware.
33+
# Lower numbers make the password hashing algorithm faster but less secure, so
34+
# the largest number that can be tolerated should be used.
35+
def generate_password_hash(password, salt=None, iterations=100000):
36+
salt = salt or os.urandom(16)
37+
dk = pbkdf2(password.encode(), salt, iterations)
38+
return f'pbkdf2-hmac-sha256:{salt.hex()}:{iterations}:{dk.hex()}'
39+
40+
41+
def check_password_hash(password_hash, password):
42+
algorithm, salt, iterations, dk = password_hash.split(':')
43+
iterations = int(iterations)
44+
if algorithm != 'pbkdf2-hmac-sha256':
45+
return False
46+
return pbkdf2(password.encode(), salt=bytes.fromhex(salt),
47+
iterations=iterations) == bytes.fromhex(dk)

examples/sessions/login.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# This is a simple example that demonstrates how to use the user session, but
2+
# is not intended as a complete login solution. See the login subdirectory for
3+
# a more complete example.
14
from microdot import Microdot, Response, redirect
25
from microdot.session import Session, with_session
36

0 commit comments

Comments
 (0)