[#419] Added ability to view and remove API tokens.

This commit is contained in:
Fabio Manganiello 2024-07-27 01:43:18 +02:00
parent c13623c3f7
commit 179c8265cf
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
8 changed files with 381 additions and 24 deletions

View file

@ -4,8 +4,10 @@ import logging
from flask import Blueprint, request, abort, jsonify from flask import Blueprint, request, abort, jsonify
from platypush.backend.http.app.utils import authenticate
from platypush.backend.http.app.utils.auth import ( from platypush.backend.http.app.utils.auth import (
UserAuthStatus, UserAuthStatus,
current_user,
get_current_user_or_auth_status, get_current_user_or_auth_status,
) )
from platypush.exceptions.user import UserException from platypush.exceptions.user import UserException
@ -223,7 +225,9 @@ def _auth_get():
response = get_current_user_or_auth_status(request) response = get_current_user_or_auth_status(request)
if isinstance(response, User): if isinstance(response, User):
user = response user = response
return jsonify({'status': 'ok', 'user_id': user.id, 'username': user.username}) return jsonify(
{'status': 'ok', 'user_id': user.user_id, 'username': user.username}
)
if response: if response:
status = response status = response
@ -283,6 +287,67 @@ def _auth_delete():
return UserAuthStatus.INVALID_SESSION.to_response() return UserAuthStatus.INVALID_SESSION.to_response()
def _tokens_get():
user = current_user()
if not user:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
tokens = UserManager().get_api_tokens(username=str(user.username))
return jsonify(
{
'tokens': [
{
'id': t.id,
'name': t.name,
'created_at': t.created_at,
'expires_at': t.expires_at,
}
for t in tokens
]
}
)
def _tokens_delete():
args = {}
try:
payload = json.loads(request.get_data(as_text=True))
token = payload.get('token')
if token:
args['token'] = token
else:
token_id = payload.get('token_id')
if token_id:
args['token_id'] = token_id
assert args, 'No token or token_id specified'
except (AssertionError, json.JSONDecodeError):
return UserAuthStatus.INVALID_TOKEN.to_response()
user_manager = UserManager()
user = current_user()
if not user:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
args['username'] = str(user.username)
try:
user_manager.delete_api_token(**args)
return jsonify({'status': 'ok'})
except AssertionError as e:
return (
jsonify({'status': 'error', 'error': 'bad_request', 'message': str(e)}),
400,
)
except UserException:
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
except Exception as e:
log.error('Token deletion error', exc_info=e)
return UserAuthStatus.UNKNOWN_ERROR.to_response()
@auth.route('/auth', methods=['GET', 'POST', 'DELETE']) @auth.route('/auth', methods=['GET', 'POST', 'DELETE'])
def auth_endpoint(): def auth_endpoint():
""" """
@ -326,4 +391,22 @@ def auth_endpoint():
return UserAuthStatus.INVALID_METHOD.to_response() return UserAuthStatus.INVALID_METHOD.to_response()
@auth.route('/tokens', methods=['GET', 'DELETE'])
@authenticate()
def tokens_route():
"""
:return: The list of API tokens created by the logged in user.
Note that this endpoint is only accessible by authenticated users
and it won't return the clear-text token values, as those aren't
stored in the database anyway.
"""
if request.method == 'GET':
return _tokens_get()
if request.method == 'DELETE':
return _tokens_delete()
return UserAuthStatus.INVALID_METHOD.to_response()
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -53,6 +53,9 @@ class UserAuthStatus(Enum):
REGISTRATION_REQUIRED = StatusValue( REGISTRATION_REQUIRED = StatusValue(
412, AuthenticationStatus.REGISTRATION_REQUIRED, 'Please create a user first' 412, AuthenticationStatus.REGISTRATION_REQUIRED, 'Please create a user first'
) )
UNKNOWN_ERROR = StatusValue(
500, AuthenticationStatus.UNKNOWN_ERROR, 'Unknown error'
)
def to_dict(self): def to_dict(self):
return { return {

View file

@ -58,12 +58,24 @@
</div> </div>
</Modal> </Modal>
<Modal title="API Tokens" ref="tokensModal" @close="showTokens = false">
<TokensList v-if="showTokens" />
</Modal>
<div class="body"> <div class="body">
<label class="generate-btn-container"> <div class="buttons">
<button class="btn btn-primary" @click="$refs.tokenParamsModal.show()"> <label>
Generate API Token <button class="btn btn-primary" @click="$refs.tokenParamsModal.show()">
</button> Generate API Token
</label> </button>
</label>
<label>
<button class="btn btn-default" @click="showTokens = true">
Manage Tokens
</button>
</label>
</div>
<p> <p>
<b>API tokens</b> are randomly generated tokens that are stored <b>API tokens</b> are randomly generated tokens that are stored
@ -99,6 +111,7 @@ import Description from "./Description";
import Loading from "@/components/Loading"; import Loading from "@/components/Loading";
import Utils from "@/Utils"; import Utils from "@/Utils";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import TokensList from "./TokensList";
export default { export default {
name: "Token", name: "Token",
@ -107,6 +120,7 @@ export default {
Description, Description,
Loading, Loading,
Modal, Modal,
TokensList,
}, },
props: { props: {
@ -119,6 +133,7 @@ export default {
data() { data() {
return { return {
loading: false, loading: false,
showTokens: false,
token: null, token: null,
} }
}, },
@ -153,10 +168,34 @@ export default {
this.loading = false this.loading = false
} }
}, },
} },
watch: {
showTokens(value) {
if (value) {
this.$refs.tokensModal.show()
} else {
this.$refs.tokensModal.close()
}
},
},
} }
</script> </script>
<style lang="scss"> <style lang="scss">
@import "style.scss"; @import "style.scss";
.buttons {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
label {
width: 50%;
display: flex;
justify-content: center;
cursor: pointer;
}
}
</style> </style>

View file

@ -0,0 +1,182 @@
<template>
<div class="tokens-list-container">
<ConfirmDialog ref="tokenDeleteConfirm"
@input="deleteToken"
@close="tokenToDelete = null">
<p>Are you sure you want to delete this token?</p>
<b>
Any application that uses this token will no longer be able to
authenticate with the Platypush API. This action cannot be undone.
</b>
</ConfirmDialog>
<Loading v-if="loading" />
<NoItems :with-shadow="false" v-else-if="!tokens?.length">
<p>No tokens have been generated yet.</p>
</NoItems>
<div class="main" v-else>
<div class="tokens-list">
<div class="token" v-for="token in tokens" :key="token.id">
<div class="info">
<div class="name"><b>{{ token.name }}</b></div>
<div class="created-at">
Created at: <b>{{ token.created_at }}</b>
</div>
<div class="expires-at">
Expires at: <b>{{ token.expires_at }}</b>
</div>
</div>
<div class="actions">
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h">
<DropdownItem text="Delete"
icon-class="fa fa-trash"
@input="tokenToDelete = token" />
</Dropdown>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import ConfirmDialog from "@/components/elements/ConfirmDialog";
import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem";
import Loading from "@/components/Loading";
import NoItems from "@/components/elements/NoItems";
import Utils from "@/Utils";
export default {
name: "Token",
mixins: [Utils],
components: {
ConfirmDialog,
Dropdown,
DropdownItem,
Loading,
NoItems,
},
data() {
return {
loading: false,
tokens_: [],
tokenToDelete: null,
}
},
computed: {
tokens() {
return this.tokens_.map(token => ({
...token,
created_at: token.created_at ? this.formatDateTime(token.created_at, false, false) : 'N/A',
expires_at: token.expires_at ? this.formatDateTime(token.expires_at, false, false) : 'never',
}))
},
},
methods: {
async refresh() {
this.loading = true
try {
this.tokens_ = (await axios.get('/tokens')).data?.tokens
} catch (e) {
console.error(e.toString())
this.notify({
text: e.response?.data?.message || e.response?.data?.error || e.toString(),
error: true,
})
} finally {
this.loading = false
}
},
async deleteToken() {
if (!this.tokenToDelete) {
return
}
this.loading = true
try {
await axios.delete(
'/tokens',
{
data: {
token_id: this.tokenToDelete.id,
}
}
)
await this.refresh()
} catch (e) {
console.error(e.toString())
this.notify({
text: e.response?.data?.message || e.response?.data?.error || e.toString(),
error: true,
})
} finally {
this.loading = false
}
},
},
watch: {
$route() {
this.refresh()
},
tokenToDelete(value) {
if (value) {
this.$refs.tokenDeleteConfirm.open()
} else {
this.$refs.tokenDeleteConfirm.close()
}
},
},
mounted() {
this.refresh()
},
}
</script>
<style lang="scss">
@import "style.scss";
.tokens-list-container {
position: relative;
.tokens-list {
width: 30em;
max-width: calc(100% + 4em);
margin: -2em;
.token {
width: 100%;
display: flex;
align-items: center;
padding: 0.5em 1em;
box-shadow: $border-shadow-bottom;
cursor: pointer;
&:hover {
background: $hover-bg;
}
.info {
flex: 1;
}
.created-at, .expires-at {
font-size: 0.8em;
opacity: 0.8;
}
}
}
}
</style>

View file

@ -21,10 +21,14 @@ export default {
}, },
formatDateTime(date, year=false, seconds=true, skipTimeIfMidnight=false) { formatDateTime(date, year=false, seconds=true, skipTimeIfMidnight=false) {
const now = new Date()
if (typeof date === 'number') if (typeof date === 'number')
date = new Date(date * 1000) date = new Date(date * 1000)
if (typeof date === 'string') if (typeof date === 'string')
date = new Date(Date.parse(date)) date = new Date(Date.parse(date))
if (now.getFullYear() !== date.getFullYear())
year = true
if (skipTimeIfMidnight && date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0) if (skipTimeIfMidnight && date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0)
return this.formatDate(date, year) return this.formatDate(date, year)

View file

@ -43,6 +43,7 @@ module.exports = {
'^/media/': httpProxy, '^/media/': httpProxy,
'^/otp': httpProxy, '^/otp': httpProxy,
'^/sound/': httpProxy, '^/sound/': httpProxy,
'^/tokens': httpProxy,
'^/ws/events': wsProxy, '^/ws/events': wsProxy,
'^/ws/requests': wsProxy, '^/ws/requests': wsProxy,
'^/ws/shell': wsProxy, '^/ws/shell': wsProxy,

View file

@ -759,38 +759,82 @@ class UserManager:
return user return user
def delete_api_token(self, token: str) -> bool: def delete_api_token(
self,
username: str,
token: Optional[str] = None,
token_id: Optional[int] = None,
):
""" """
Delete an API token. Delete an API token.
Either <token> or <token_id> must be provided.
:param token: Token to delete. :param token: Token to delete.
:param username: User name.
:param token_id: Token ID.
:return: True if the token was successfully deleted, False otherwise. :return: True if the token was successfully deleted, False otherwise.
""" """
assert token or token_id, 'Either token or token_id must be provided'
with self._get_session() as session: with self._get_session() as session:
user = self._user_by_token(session, token) if token:
if not user: user = self._user_by_token(session, token)
return False else:
user = self._get_user(session, username)
encrypted_token = self._encrypt_password( assert user, 'No such user'
token, user.password_salt, user.hmac_iterations # type: ignore
)
user_token = ( if token_id:
session.query(UserToken) user_token = (
.filter_by( session.query(UserToken)
token=encrypted_token, .filter_by(user_id=user.user_id, id=token_id)
user_id=user.user_id, .first()
)
else:
encrypted_token = self._encrypt_password(
token, user.password_salt, user.hmac_iterations # type: ignore
) )
.first()
)
if not user_token: user_token = (
return False session.query(UserToken)
.filter_by(
token=encrypted_token,
user_id=user.user_id,
)
.first()
)
assert user_token, 'No such token'
session.delete(user_token) session.delete(user_token)
session.commit() session.commit()
return True def get_api_tokens(self, username: str) -> List[UserToken]:
"""
Get all the API tokens for a user.
:param username: User name.
:return: List of tokens.
"""
with self._get_session() as session:
user = self._get_user(session, username)
if not user:
return []
return (
session.query(UserToken)
.filter(
and_(
UserToken.user_id == user.user_id,
or_(
UserToken.expires_at.is_(None),
UserToken.expires_at >= utcnow(),
),
)
)
.order_by(UserToken.created_at.desc())
.all()
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -31,6 +31,7 @@ class AuthenticationStatus(enum.Enum):
PASSWORD_MISMATCH = 'password_mismatch' PASSWORD_MISMATCH = 'password_mismatch'
REGISTRATION_DISABLED = 'registration_disabled' REGISTRATION_DISABLED = 'registration_disabled'
REGISTRATION_REQUIRED = 'registration_required' REGISTRATION_REQUIRED = 'registration_required'
UNKNOWN_ERROR = 'unknown_error'
class User(Base): class User(Base):