[#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,13 +58,25 @@
</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">
<label>
<button class="btn btn-primary" @click="$refs.tokenParamsModal.show()"> <button class="btn btn-primary" @click="$refs.tokenParamsModal.show()">
Generate API Token Generate API Token
</button> </button>
</label> </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
encrypted on the server, and can be used to authenticate with the encrypted on the server, and can be used to authenticate with the
@ -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,18 +759,39 @@ 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.
""" """
with self._get_session() as session: assert token or token_id, 'Either token or token_id must be provided'
user = self._user_by_token(session, token)
if not user:
return False
with self._get_session() as session:
if token:
user = self._user_by_token(session, token)
else:
user = self._get_user(session, username)
assert user, 'No such user'
if token_id:
user_token = (
session.query(UserToken)
.filter_by(user_id=user.user_id, id=token_id)
.first()
)
else:
encrypted_token = self._encrypt_password( encrypted_token = self._encrypt_password(
token, user.password_salt, user.hmac_iterations # type: ignore token, user.password_salt, user.hmac_iterations # type: ignore
) )
@ -784,13 +805,36 @@ class UserManager:
.first() .first()
) )
if not user_token: assert user_token, 'No such token'
return False
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):