Merge pull request #233 from SamR1/blacklist-token-on-logout
Blacklist token on logout
This commit is contained in:
commit
3c31245bc5
@ -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
|
||||
|
@ -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
|
||||
|
@ -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">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"successfully logged out"</span><span class="p">,</span><span class="w"></span>
|
||||
<span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"success"</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">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"provide a valid auth token"</span><span class="p">,</span><span class="w"></span>
|
||||
<span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"error"</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>
|
||||
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
BIN
docs/objects.inv
BIN
docs/objects.inv
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -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
|
||||
|
@ -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
|
||||
|
2
fittrackee/dist/index.html
vendored
2
fittrackee/dist/index.html
vendored
@ -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>
|
2
fittrackee/dist/service-worker.js
vendored
2
fittrackee/dist/service-worker.js
vendored
File diff suppressed because one or more lines are too long
2
fittrackee/dist/service-worker.js.map
vendored
2
fittrackee/dist/service-worker.js.map
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/app.5ef60870.js.map
vendored
Normal file
1
fittrackee/dist/static/js/app.5ef60870.js.map
vendored
Normal file
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
@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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')
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
||||
|
@ -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
|
||||
|
@ -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}.')
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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>,
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user