Merge pull request #233 from SamR1/blacklist-token-on-logout

Blacklist token on logout
This commit is contained in:
Sam 2022-09-15 16:51:43 +02:00 committed by GitHub
commit 3c31245bc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 545 additions and 39 deletions

View File

@ -17,4 +17,5 @@ Authentication
auth.request_password_reset,
auth.update_user_account,
auth.update_password,
auth.update_email
auth.update_email,
auth.logout_user

View File

@ -65,6 +65,23 @@ Remove tokens expired for more than provided number of days
Users
~~~~~
``ftcli users clean_tokens``
""""""""""""""""""""""""""""
.. versionadded:: 0.7.0
Remove blacklisted tokens expired for more than provided number of days.
.. cssclass:: table-bordered
.. list-table::
:widths: 25 50
:header-rows: 1
* - Options
- Description
* - ``--days``
- Number of days.
``ftcli users update``
""""""""""""""""""""""
.. versionadded:: 0.6.5

View File

@ -1148,6 +1148,65 @@ character “_” allowed</p></li>
</dl>
</dd></dl>
<dl class="http post">
<dt class="sig sig-object http" id="post--api-auth-logout">
<span class="sig-name descname"><span class="pre">POST</span> </span><span class="sig-name descname"><span class="pre">/api/auth/logout</span></span><a class="headerlink" href="#post--api-auth-logout" title="Permalink to this definition"></a></dt>
<dd><p>User logout.
If a valid token is provided, it will be blacklisted.</p>
<p><strong>Example request</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="nf">POST</span> <span class="nn">/api/auth/logout</span> <span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
</pre></div>
</div>
<p><strong>Example responses</strong>:</p>
<ul class="simple">
<li><p>successful logout</p></li>
</ul>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
<span class="p">{</span><span class="w"></span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;successfully logged out&quot;</span><span class="p">,</span><span class="w"></span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;success&quot;</span><span class="w"></span>
<span class="p">}</span><span class="w"></span>
</pre></div>
</div>
<ul class="simple">
<li><p>error on logout</p></li>
</ul>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">401</span> <span class="ne">UNAUTHORIZED</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
<span class="p">{</span><span class="w"></span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;provide a valid auth token&quot;</span><span class="p">,</span><span class="w"></span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;error&quot;</span><span class="w"></span>
<span class="p">}</span><span class="w"></span>
</pre></div>
</div>
<dl class="field-list simple">
<dt class="field-odd">Request Headers<span class="colon">:</span></dt>
<dd class="field-odd"><ul class="simple">
<li><p><span><a class="reference external" href="https://tools.ietf.org/html/rfc7235#section-4.2">Authorization</a></span> OAuth 2.0 Bearer Token</p></li>
</ul>
</dd>
<dt class="field-even">Status Codes<span class="colon">:</span></dt>
<dd class="field-even"><ul class="simple">
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.1">200 OK</a></span> successfully logged out</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2">401 Unauthorized</a></span> <ul>
<li><p>provide a valid auth token</p></li>
<li><p>The access token provided is expired, revoked, malformed, or invalid
for other reasons.</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.5.1">500 Internal Server Error</a></span> <ul>
<li><p>error on token blacklist</p></li>
</ul>
</p></li>
</ul>
</dd>
</dl>
</dd></dl>
</section>

View File

@ -91,6 +91,7 @@
</ul>
</li>
<li><a class="reference internal" href="#users">Users</a><ul>
<li><a class="reference internal" href="#ftcli-users-clean-tokens"><code class="docutils literal notranslate"><span class="pre">ftcli</span> <span class="pre">users</span> <span class="pre">clean_tokens</span></code></a></li>
<li><a class="reference internal" href="#ftcli-users-update"><code class="docutils literal notranslate"><span class="pre">ftcli</span> <span class="pre">users</span> <span class="pre">update</span></code></a></li>
</ul>
</li>
@ -213,6 +214,29 @@ Commands:
</section>
<section id="users">
<h2>Users<a class="headerlink" href="#users" title="Permalink to this heading"></a></h2>
<section id="ftcli-users-clean-tokens">
<h3><code class="docutils literal notranslate"><span class="pre">ftcli</span> <span class="pre">users</span> <span class="pre">clean_tokens</span></code><a class="headerlink" href="#ftcli-users-clean-tokens" title="Permalink to this heading"></a></h3>
<div class="versionadded">
<p><span class="versionmodified added">New in version 0.7.0.</span></p>
</div>
<p>Remove blacklisted tokens expired for more than provided number of days.</p>
<table class="table-bordered docutils align-default">
<colgroup>
<col style="width: 33.3%" />
<col style="width: 66.7%" />
</colgroup>
<thead>
<tr class="row-odd"><th class="head"><p>Options</p></th>
<th class="head"><p>Description</p></th>
</tr>
</thead>
<tbody>
<tr class="row-even"><td><p><code class="docutils literal notranslate"><span class="pre">--days</span></code></p></td>
<td><p>Number of days.</p></td>
</tr>
</tbody>
</table>
</section>
<section id="ftcli-users-update">
<h3><code class="docutils literal notranslate"><span class="pre">ftcli</span> <span class="pre">users</span> <span class="pre">update</span></code><a class="headerlink" href="#ftcli-users-update" title="Permalink to this heading"></a></h3>
<div class="versionadded">

View File

@ -268,6 +268,11 @@
<td>
<a href="api/auth.html#post--api-auth-login"><code class="xref">POST /api/auth/login</code></a></td><td>
<em></em></td></tr>
<tr>
<td></td>
<td>
<a href="api/auth.html#post--api-auth-logout"><code class="xref">POST /api/auth/logout</code></a></td><td>
<em></em></td></tr>
<tr>
<td></td>
<td>

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -17,4 +17,5 @@ Authentication
auth.request_password_reset,
auth.update_user_account,
auth.update_password,
auth.update_email
auth.update_email,
auth.logout_user

View File

@ -65,6 +65,23 @@ Remove tokens expired for more than provided number of days
Users
~~~~~
``ftcli users clean_tokens``
""""""""""""""""""""""""""""
.. versionadded:: 0.7.0
Remove blacklisted tokens expired for more than provided number of days.
.. cssclass:: table-bordered
.. list-table::
:widths: 25 50
:header-rows: 1
* - Options
- Description
* - ``--days``
- Number of days.
``ftcli users update``
""""""""""""""""""""""
.. versionadded:: 0.6.5

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"/><link rel="stylesheet" href="/static/css/leaflet.css"/><title>FitTrackee</title><script defer="defer" src="/static/js/chunk-vendors.63f8c863.js"></script><script defer="defer" src="/static/js/app.790a0762.js"></script><link href="/static/css/app.fe042cee.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"/><link rel="stylesheet" href="/static/css/leaflet.css"/><title>FitTrackee</title><script defer="defer" src="/static/js/chunk-vendors.63f8c863.js"></script><script defer="defer" src="/static/js/app.5ef60870.js"></script><link href="/static/css/app.fe042cee.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
"use strict";(self["webpackChunkfittrackee_client"]=self["webpackChunkfittrackee_client"]||[]).push([[193],{7885:function(e,s,t){t.r(s),t.d(s,{default:function(){return A}});t(6699);var a=t(6252),r=t(2262),l=t(3577),o=t(3324),n=t(4998);const c={class:"chart-menu"},i={class:"chart-arrow"},u={class:"time-frames custom-checkboxes-group"},d={class:"time-frames-checkboxes custom-checkboxes"},p=["id","name","checked","onInput"],m={class:"chart-arrow"};var v=(0,a.aZ)({__name:"StatsMenu",emits:["arrowClick","timeFrameUpdate"],setup(e,{emit:s}){const t=(0,r.iH)("month"),o=["week","month","year"];function n(e){t.value=e,s("timeFrameUpdate",e)}return(e,r)=>((0,a.wg)(),(0,a.iD)("div",c,[(0,a._)("div",i,[(0,a._)("i",{class:"fa fa-chevron-left","aria-hidden":"true",onClick:r[0]||(r[0]=e=>s("arrowClick",!0))})]),(0,a._)("div",u,[(0,a._)("div",d,[((0,a.wg)(),(0,a.iD)(a.HY,null,(0,a.Ko)(o,(s=>(0,a._)("div",{class:"time-frame custom-checkbox",key:s},[(0,a._)("label",null,[(0,a._)("input",{type:"radio",id:s,name:s,checked:t.value===s,onInput:e=>n(s)},null,40,p),(0,a._)("span",null,(0,l.zw)(e.$t(`statistics.TIME_FRAMES.${s}`)),1)])]))),64))])]),(0,a._)("div",m,[(0,a._)("i",{class:"fa fa-chevron-right","aria-hidden":"true",onClick:r[1]||(r[1]=e=>s("arrowClick",!1))})])]))}}),k=t(3744);const _=(0,k.Z)(v,[["__scopeId","data-v-22d55de2"]]);var S=_,w=t(631);const f={class:"sports-menu"},h=["id","name","checked","onInput"],U={class:"sport-label"};var b=(0,a.aZ)({__name:"StatsSportsMenu",props:{userSports:null,selectedSportIds:{default:()=>[]}},emits:["selectedSportIdsUpdate"],setup(e,{emit:s}){const t=e,{t:n}=(0,o.QT)(),c=(0,a.f3)("sportColors"),{selectedSportIds:i}=(0,r.BK)(t),u=(0,a.Fl)((()=>(0,w.xH)(t.userSports,n)));function d(e){s("selectedSportIdsUpdate",e)}return(e,s)=>{const t=(0,a.up)("SportImage");return(0,a.wg)(),(0,a.iD)("div",f,[((0,a.wg)(!0),(0,a.iD)(a.HY,null,(0,a.Ko)((0,r.SU)(u),(e=>((0,a.wg)(),(0,a.iD)("label",{type:"checkbox",key:e.id,style:(0,l.j5)({color:e.color?e.color:(0,r.SU)(c)[e.label]})},[(0,a._)("input",{type:"checkbox",id:e.id,name:e.label,checked:(0,r.SU)(i).includes(e.id),onInput:s=>d(e.id)},null,40,h),(0,a.Wm)(t,{"sport-label":e.label,color:e.color},null,8,["sport-label","color"]),(0,a._)("span",U,(0,l.zw)(e.translatedLabel),1)],4)))),128))])}}});const I=b;var g=I,T=t(9318);const y={key:0,id:"user-statistics"};var C=(0,a.aZ)({__name:"index",props:{sports:null,user:null},setup(e){const s=e,{t:t}=(0,o.QT)(),{sports:l,user:c}=(0,r.BK)(s),i=(0,r.iH)("month"),u=(0,r.iH)(v(i.value)),d=(0,a.Fl)((()=>(0,w.xH)(s.sports,t))),p=(0,r.iH)(_(s.sports));function m(e){i.value=e,u.value=v(i.value)}function v(e){return(0,T.aZ)(new Date,e,s.user.weekm)}function k(e){u.value=(0,T.FN)(u.value,e,s.user.weekm)}function _(e){return e.map((e=>e.id))}function f(e){p.value.includes(e)?p.value=p.value.filter((s=>s!==e)):p.value.push(e)}return(0,a.YP)((()=>s.sports),(e=>{p.value=_(e)})),(e,s)=>(0,r.SU)(d)?((0,a.wg)(),(0,a.iD)("div",y,[(0,a.Wm)(S,{onTimeFrameUpdate:m,onArrowClick:k}),(0,a.Wm)(n.Z,{sports:(0,r.SU)(l),user:(0,r.SU)(c),chartParams:u.value,"displayed-sport-ids":p.value,fullStats:!0},null,8,["sports","user","chartParams","displayed-sport-ids"]),(0,a.Wm)(g,{"selected-sport-ids":p.value,"user-sports":(0,r.SU)(l),onSelectedSportIdsUpdate:f},null,8,["selected-sport-ids","user-sports"])])):(0,a.kq)("",!0)}});const F=(0,k.Z)(C,[["__scopeId","data-v-30799d13"]]);var Z=F,x=t(5630),D=t(5801),H=t(9917);const E={id:"statistics",class:"view"},R={key:0,class:"container"};var W=(0,a.aZ)({__name:"StatisticsView",setup(e){const s=(0,H.o)(),t=(0,a.Fl)((()=>s.getters[D.YN.GETTERS.AUTH_USER_PROFILE])),o=(0,a.Fl)((()=>s.getters[D.O8.GETTERS.SPORTS].filter((e=>t.value.sports_list.includes(e.id)))));return(e,s)=>{const n=(0,a.up)("Card");return(0,a.wg)(),(0,a.iD)("div",E,[(0,r.SU)(t).username?((0,a.wg)(),(0,a.iD)("div",R,[(0,a.Wm)(n,null,{title:(0,a.w5)((()=>[(0,a.Uk)((0,l.zw)(e.$t("statistics.STATISTICS")),1)])),content:(0,a.w5)((()=>[(0,a.Wm)(Z,{class:(0,l.C_)({"stats-disabled":0===(0,r.SU)(t).nb_workouts}),user:(0,r.SU)(t),sports:(0,r.SU)(o)},null,8,["class","user","sports"])])),_:1}),0===(0,r.SU)(t).nb_workouts?((0,a.wg)(),(0,a.j4)(x.Z,{key:0})):(0,a.kq)("",!0)])):(0,a.kq)("",!0)])}}});const P=(0,k.Z)(W,[["__scopeId","data-v-2e341d4e"]]);var A=P}}]);
//# sourceMappingURL=statistics.381e14ea.js.map
//# sourceMappingURL=statistics.812c3136.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
"""add OAuth 2.0
"""add OAuth 2.0 and blacklisted tokens
Revision ID: 84d840ce853b
Revises: 5e3a3a31c432
@ -67,11 +67,21 @@ def upgrade():
)
op.create_index(op.f('ix_oauth2_token_refresh_token'), 'oauth2_token', ['refresh_token'], unique=False)
op.create_index(op.f('ix_oauth2_token_user_id'), 'oauth2_token', ['user_id'], unique=False)
op.create_table('blacklisted_tokens',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('token', sa.String(length=500), nullable=False),
sa.Column('expired_at', sa.Integer(), nullable=False),
sa.Column('blacklisted_on', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('blacklisted_tokens')
op.drop_index(op.f('ix_oauth2_token_user_id'), table_name='oauth2_token')
op.drop_index(op.f('ix_oauth2_token_refresh_token'), table_name='oauth2_token')
op.drop_table('oauth2_token')

View File

@ -1,13 +1,9 @@
import time
from fittrackee import db
from fittrackee.utils import clean
def clean_tokens(days: int) -> int:
limit = int(time.time()) - (days * 86400)
sql = """
DELETE FROM oauth2_token
WHERE oauth2_token.issued_at + oauth2_token.expires_in < %(limit)s;
"""
result = db.engine.execute(sql, {'limit': limit})
return result.rowcount
return clean(sql, days)

View File

@ -7,7 +7,7 @@ from fittrackee.cli.app import app
from .clean import clean_tokens
handler = logging.StreamHandler()
logger = logging.getLogger('fittrackee_clean_tokens')
logger = logging.getLogger('fittrackee_clean_oauth2_tokens')
logger.setLevel(logging.INFO)
logger.addHandler(handler)

View File

@ -1,6 +1,5 @@
import json
import time
from random import randint
from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import parse_qs
@ -18,7 +17,12 @@ from .custom_asserts import (
assert_errored_response,
assert_oauth_errored_response,
)
from .utils import TEST_OAUTH_CLIENT_METADATA, random_email, random_string
from .utils import (
TEST_OAUTH_CLIENT_METADATA,
random_email,
random_int,
random_string,
)
class RandomMixin:
@ -40,7 +44,7 @@ class RandomMixin:
@staticmethod
def random_int(min_val: int = 0, max_val: int = 999999) -> int:
return randint(min_val, max_val)
return random_int(min_val, max_val)
class OAuth2Mixin(RandomMixin):

View File

@ -8,7 +8,8 @@ import pytest
from flask import Flask
from freezegun import freeze_time
from fittrackee.users.models import User, UserSportPreference
from fittrackee import db
from fittrackee.users.models import BlacklistedToken, User, UserSportPreference
from fittrackee.users.utils.token import get_user_token
from fittrackee.workouts.models import Sport
@ -500,6 +501,22 @@ class TestUserProfile(ApiTestCaseMixin):
self.assert_invalid_token(response)
def test_it_returns_error_if_token_is_blacklisted(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
db.session.add(BlacklistedToken(token=auth_token))
db.session.commit()
response = client.get(
'/api/auth/profile',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_invalid_token(response)
def test_it_returns_user(self, app: Flask, user_1: User) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
@ -2562,3 +2579,85 @@ class TestResendAccountConfirmationEmail(ApiTestCaseMixin):
self.assert_404_with_message(
response, 'the requested URL was not found on the server'
)
class TestUserLogout(ApiTestCaseMixin):
def test_it_returns_error_when_headers_are_missing(
self, app: Flask
) -> None:
client = app.test_client()
response = client.post('/api/auth/logout', headers=dict())
self.assert_401(response, 'provide a valid auth token')
def test_it_returns_error_when_token_is_invalid(self, app: Flask) -> None:
client = app.test_client()
response = client.post(
'/api/auth/logout', headers=dict(Authorization='Bearer invalid')
)
self.assert_invalid_token(response)
def test_it_returns_error_when_token_is_expired(
self, app: Flask, user_1: User
) -> None:
now = datetime.utcnow()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
with freeze_time(now + timedelta(seconds=4)):
response = client.post(
'/api/auth/logout',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_invalid_token(response)
def test_user_can_logout(self, app: Flask, user_1: User) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/auth/logout',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'successfully logged out'
assert response.status_code == 200
def test_token_is_blacklisted_on_logout(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
client.post(
'/api/auth/logout',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
token = BlacklistedToken.query.filter_by(token=auth_token).first()
assert token.blacklisted_on is not None
def test_it_returns_error_if_token_is_already_blacklisted(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
db.session.add(BlacklistedToken(token=auth_token))
db.session.commit()
response = client.post(
'/api/auth/logout',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_invalid_token(response)

View File

@ -1,10 +1,14 @@
from datetime import datetime, timedelta
from typing import Dict
import pytest
from flask import Flask
from freezegun import freeze_time
from fittrackee import db
from fittrackee.tests.utils import random_string
from fittrackee.users.exceptions import UserNotFoundException
from fittrackee.users.models import User, UserSportPreference
from fittrackee.users.models import BlacklistedToken, User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout
@ -195,7 +199,7 @@ class TestUserRecords(UserModelAssertMixin):
assert serialized_user['total_distance'] == 10
assert serialized_user['total_duration'] == '1:00:00'
def test_it_returns_totals_when_user_has_mutiple_workouts(
def test_it_returns_totals_when_user_has_multiple_workouts(
self,
app: Flask,
user_1: User,
@ -284,6 +288,37 @@ class TestUserModelToken:
assert isinstance(auth_token, str)
assert User.decode_auth_token(auth_token) == user_1.id
def test_it_returns_error_when_token_is_invalid(
self, app: Flask, user_1: User
) -> None:
assert (
User.decode_auth_token(random_string())
== 'invalid token, please log in again'
)
def test_it_returns_error_when_token_is_expired(
self, app: Flask, user_1: User
) -> None:
auth_token = user_1.encode_auth_token(user_1.id)
now = datetime.utcnow()
with freeze_time(now + timedelta(seconds=4)):
assert (
User.decode_auth_token(auth_token)
== 'signature expired, please log in again'
)
def test_it_returns_error_when_token_is_blacklisted(
self, app: Flask, user_1: User
) -> None:
auth_token = user_1.encode_auth_token(user_1.id)
db.session.add(BlacklistedToken(token=auth_token))
db.session.commit()
assert (
User.decode_auth_token(auth_token)
== 'blacklisted token, please log in again'
)
class TestUserSportModel:
def test_user_model(

View File

@ -1,6 +1,7 @@
import time
from calendar import timegm
from datetime import datetime, timedelta
from typing import Dict
from typing import Dict, Optional
from unittest.mock import Mock, patch
import jwt
@ -9,13 +10,12 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from flask import Flask
from fittrackee import bcrypt
from fittrackee.tests.utils import random_string
from fittrackee import bcrypt, db
from fittrackee.users.exceptions import (
InvalidEmailException,
UserNotFoundException,
)
from fittrackee.users.models import User
from fittrackee.users.models import BlacklistedToken, User
from fittrackee.users.utils.admin import UserManagerService
from fittrackee.users.utils.controls import (
check_password,
@ -23,9 +23,13 @@ from fittrackee.users.utils.controls import (
is_valid_email,
register_controls,
)
from fittrackee.users.utils.token import decode_user_token, get_user_token
from fittrackee.users.utils.token import (
clean_blacklisted_tokens,
decode_user_token,
get_user_token,
)
from ..utils import random_email
from ..utils import random_email, random_int, random_string
class TestUserManagerService:
@ -511,3 +515,67 @@ class TestDecodeUserToken:
user_id = decode_user_token(token)
assert user_id == expected_user_id
class TestBlacklistedTokensCleanup:
@staticmethod
def blacklisted_token(expiration_days: Optional[int] = None) -> str:
token = get_user_token(user_id=random_int())
blacklisted_token = BlacklistedToken(token=token)
if expiration_days is not None:
blacklisted_token.expired_at = int(time.time()) - (
expiration_days * 86400
)
db.session.add(blacklisted_token)
db.session.commit()
return token
def test_it_returns_0_as_count_when_no_blacklisted_token_deleted(
self, app: Flask, user_1: User
) -> None:
count = clean_blacklisted_tokens(days=30)
assert count == 0
def test_it_does_not_delete_blacklisted_token_when_not_expired(
self, app: Flask, user_1: User
) -> None:
token = self.blacklisted_token()
clean_blacklisted_tokens(days=10)
existing_token = BlacklistedToken.query.filter_by(token=token).first()
assert existing_token is not None
def test_it_deletes_blacklisted_token_when_expired_more_then_provided_days(
self, app: Flask, user_1: User
) -> None:
token = self.blacklisted_token(expiration_days=40)
clean_blacklisted_tokens(days=30)
existing_token = BlacklistedToken.query.filter_by(token=token).first()
assert existing_token is None
def test_it_does_not_delete_blacklisted_token_when_expired_below_provided_days( # noqa
self, app: Flask, user_1: User
) -> None:
token = self.blacklisted_token(expiration_days=30)
clean_blacklisted_tokens(days=40)
existing_token = BlacklistedToken.query.filter_by(token=token).first()
assert existing_token is not None
def test_it_returns_deleted_rows_count(
self, app: Flask, user_1: User
) -> None:
self.blacklisted_token()
for _ in range(3):
self.blacklisted_token(expiration_days=30)
count = clean_blacklisted_tokens(
days=app.config['TOKEN_EXPIRATION_DAYS']
)
assert count == 3

View File

@ -35,6 +35,10 @@ def random_email() -> str:
return random_string(suffix='@example.com')
def random_int(min_val: int = 0, max_val: int = 999999) -> int:
return random.randint(min_val, max_val)
def random_short_id() -> str:
return encode_uuid(uuid4())

View File

@ -33,7 +33,7 @@ from fittrackee.responses import (
from fittrackee.utils import get_readable_duration
from fittrackee.workouts.models import Sport
from .models import User, UserSportPreference
from .models import BlacklistedToken, User, UserSportPreference
from .utils.controls import check_password, is_valid_email, register_controls
from .utils.token import decode_user_token
@ -1536,3 +1536,70 @@ def resend_account_confirmation_email() -> Union[Dict, HttpResponse]:
return response
except (exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/logout', methods=['POST'])
@require_auth()
def logout_user(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]:
"""
User logout.
If a valid token is provided, it will be blacklisted.
**Example request**:
.. sourcecode:: http
POST /api/auth/logout HTTP/1.1
Content-Type: application/json
**Example responses**:
- successful logout
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "successfully logged out",
"status": "success"
}
- error on logout
.. sourcecode:: http
HTTP/1.1 401 UNAUTHORIZED
Content-Type: application/json
{
"message": "provide a valid auth token",
"status": "error"
}
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: successfully logged out
:statuscode 401:
- provide a valid auth token
- The access token provided is expired, revoked, malformed, or invalid
for other reasons.
:statuscode 500:
- error on token blacklist
"""
auth_token = request.headers.get('Authorization', '').split(' ')[1]
try:
db.session.add(BlacklistedToken(token=auth_token))
db.session.commit()
except Exception:
return {
'status': 'error',
'message': 'error on token blacklist',
}, 500
return {
'status': 'success',
'message': 'successfully logged out',
}, 200

View File

@ -1,3 +1,4 @@
import logging
from typing import Optional
import click
@ -5,6 +6,12 @@ import click
from fittrackee.cli.app import app
from fittrackee.users.exceptions import UserNotFoundException
from fittrackee.users.utils.admin import UserManagerService
from fittrackee.users.utils.token import clean_blacklisted_tokens
handler = logging.StreamHandler()
logger = logging.getLogger('fittrackee_clean_blacklisted_tokens')
logger.setLevel(logging.INFO)
logger.addHandler(handler)
@click.group(name='users')
@ -60,3 +67,16 @@ def manage_user(
)
except Exception as e:
click.echo(f'An error occurred: {e}', err=True)
@users_cli.command('clean_tokens')
@click.option('--days', type=int, required=True, help='Number of days.')
def clean(
days: int,
) -> None:
"""
Clean blacklisted tokens expired for more than provided number of days.
"""
with app.app_context():
deleted_rows = clean_blacklisted_tokens(days)
logger.info(f'Blacklisted tokens deleted: {deleted_rows}.')

View File

@ -97,7 +97,11 @@ class User(BaseModel):
:return: integer|string
"""
try:
return decode_user_token(auth_token)
resp = decode_user_token(auth_token)
is_blacklisted = BlacklistedToken.check(auth_token)
if is_blacklisted:
return 'blacklisted token, please log in again'
return resp
except jwt.ExpiredSignatureError:
return 'signature expired, please log in again'
except jwt.InvalidTokenError:
@ -233,3 +237,30 @@ class UserSportPreference(BaseModel):
'is_active': self.is_active,
'stopped_speed_threshold': self.stopped_speed_threshold,
}
class BlacklistedToken(BaseModel):
__tablename__ = 'blacklisted_tokens'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
token = db.Column(db.String(500), unique=True, nullable=False)
expired_at = db.Column(db.Integer, nullable=False)
blacklisted_on = db.Column(db.DateTime, nullable=False)
def __init__(
self, token: str, blacklisted_on: Optional[datetime] = None
) -> None:
payload = jwt.decode(
token,
current_app.config['SECRET_KEY'],
algorithms=['HS256'],
)
self.token = token
self.expired_at = payload['exp']
self.blacklisted_on = (
blacklisted_on if blacklisted_on else datetime.utcnow()
)
@classmethod
def check(cls, auth_token: str) -> bool:
return cls.query.filter_by(token=str(auth_token)).first() is not None

View File

@ -4,6 +4,8 @@ from typing import Optional
import jwt
from flask import current_app
from fittrackee.utils import clean
def get_user_token(
user_id: int, password_reset: Optional[bool] = False
@ -45,3 +47,14 @@ def decode_user_token(auth_token: str) -> int:
algorithms=['HS256'],
)
return payload['sub']
def clean_blacklisted_tokens(days: int) -> int:
"""
Delete blacklisted tokens expired for more than provided number of days
"""
sql = """
DELETE FROM blacklisted_tokens
WHERE blacklisted_tokens.expired_at < %(limit)s;
"""
return clean(sql, days)

View File

@ -1,8 +1,11 @@
import time
from datetime import timedelta
from typing import Optional
import humanize
from fittrackee import db
def get_readable_duration(duration: int, locale: Optional[str] = None) -> str:
"""
@ -19,3 +22,9 @@ def get_readable_duration(duration: int, locale: Optional[str] = None) -> str:
if locale != 'en':
humanize.i18n.deactivate()
return readable_duration
def clean(sql: str, days: int) -> int:
limit = int(time.time()) - (days * 86400)
result = db.engine.execute(sql, {'limit': limit})
return result.rowcount

View File

@ -59,6 +59,13 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
)
context.dispatch(AUTH_USER_STORE.ACTIONS.GET_USER_PROFILE)
}
// after logout in another tab
if (
!window.localStorage.authToken &&
context.getters[AUTH_USER_STORE.GETTERS.IS_AUTHENTICATED]
) {
removeAuthUserData(context)
}
},
[AUTH_USER_STORE.ACTIONS.CONFIRM_ACCOUNT](
context: ActionContext<IAuthUserState, IRootState>,
@ -182,7 +189,17 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
[AUTH_USER_STORE.ACTIONS.LOGOUT](
context: ActionContext<IAuthUserState, IRootState>
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
authApi
.post('auth/logout')
.then((res) => {
if (res.data.status === 'success') {
removeAuthUserData(context)
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
},
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_PROFILE](
context: ActionContext<IAuthUserState, IRootState>,

View File

@ -1,7 +1,7 @@
import { AxiosError } from 'axios'
import { ActionContext } from 'vuex'
import { ROOT_STORE } from '@/store/constants'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { IAuthUserState } from '@/store/modules/authUser/types'
import { IOAuth2State } from '@/store/modules/oauth2/types'
import { IRootState } from '@/store/modules/root/types'
@ -28,6 +28,15 @@ export const handleError = (
error: AxiosError | null,
msg = 'UNKNOWN'
): void => {
// if stored token is blacklisted
if (
error?.response?.status === 401 &&
error.response.data.error === 'invalid_token'
) {
localStorage.removeItem('authToken')
context.dispatch(AUTH_USER_STORE.ACTIONS.CHECK_AUTH_USER)
return
}
const errorMessages = !error
? msg
: error.response