forked from platypush/platypush
[#419] Added ability to view and remove API tokens.
This commit is contained in:
parent
c13623c3f7
commit
179c8265cf
8 changed files with 381 additions and 24 deletions
|
@ -4,8 +4,10 @@ import logging
|
|||
|
||||
from flask import Blueprint, request, abort, jsonify
|
||||
|
||||
from platypush.backend.http.app.utils import authenticate
|
||||
from platypush.backend.http.app.utils.auth import (
|
||||
UserAuthStatus,
|
||||
current_user,
|
||||
get_current_user_or_auth_status,
|
||||
)
|
||||
from platypush.exceptions.user import UserException
|
||||
|
@ -223,7 +225,9 @@ def _auth_get():
|
|||
response = get_current_user_or_auth_status(request)
|
||||
if isinstance(response, User):
|
||||
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:
|
||||
status = response
|
||||
|
@ -283,6 +287,67 @@ def _auth_delete():
|
|||
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'])
|
||||
def auth_endpoint():
|
||||
"""
|
||||
|
@ -326,4 +391,22 @@ def auth_endpoint():
|
|||
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:
|
||||
|
|
|
@ -53,6 +53,9 @@ class UserAuthStatus(Enum):
|
|||
REGISTRATION_REQUIRED = StatusValue(
|
||||
412, AuthenticationStatus.REGISTRATION_REQUIRED, 'Please create a user first'
|
||||
)
|
||||
UNKNOWN_ERROR = StatusValue(
|
||||
500, AuthenticationStatus.UNKNOWN_ERROR, 'Unknown error'
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
|
|
|
@ -58,13 +58,25 @@
|
|||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal title="API Tokens" ref="tokensModal" @close="showTokens = false">
|
||||
<TokensList v-if="showTokens" />
|
||||
</Modal>
|
||||
|
||||
<div class="body">
|
||||
<label class="generate-btn-container">
|
||||
<div class="buttons">
|
||||
<label>
|
||||
<button class="btn btn-primary" @click="$refs.tokenParamsModal.show()">
|
||||
Generate API Token
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<button class="btn btn-default" @click="showTokens = true">
|
||||
Manage Tokens
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<b>API tokens</b> are randomly generated tokens that are stored
|
||||
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 Utils from "@/Utils";
|
||||
import Modal from "@/components/Modal";
|
||||
import TokensList from "./TokensList";
|
||||
|
||||
export default {
|
||||
name: "Token",
|
||||
|
@ -107,6 +120,7 @@ export default {
|
|||
Description,
|
||||
Loading,
|
||||
Modal,
|
||||
TokensList,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
@ -119,6 +133,7 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
showTokens: false,
|
||||
token: null,
|
||||
}
|
||||
},
|
||||
|
@ -153,10 +168,34 @@ export default {
|
|||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
showTokens(value) {
|
||||
if (value) {
|
||||
this.$refs.tokensModal.show()
|
||||
} else {
|
||||
this.$refs.tokensModal.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="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>
|
||||
|
|
|
@ -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>
|
|
@ -21,10 +21,14 @@ export default {
|
|||
},
|
||||
|
||||
formatDateTime(date, year=false, seconds=true, skipTimeIfMidnight=false) {
|
||||
const now = new Date()
|
||||
|
||||
if (typeof date === 'number')
|
||||
date = new Date(date * 1000)
|
||||
if (typeof date === 'string')
|
||||
date = new Date(Date.parse(date))
|
||||
if (now.getFullYear() !== date.getFullYear())
|
||||
year = true
|
||||
|
||||
if (skipTimeIfMidnight && date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0)
|
||||
return this.formatDate(date, year)
|
||||
|
|
|
@ -43,6 +43,7 @@ module.exports = {
|
|||
'^/media/': httpProxy,
|
||||
'^/otp': httpProxy,
|
||||
'^/sound/': httpProxy,
|
||||
'^/tokens': httpProxy,
|
||||
'^/ws/events': wsProxy,
|
||||
'^/ws/requests': wsProxy,
|
||||
'^/ws/shell': wsProxy,
|
||||
|
|
|
@ -759,18 +759,39 @@ class UserManager:
|
|||
|
||||
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.
|
||||
|
||||
Either <token> or <token_id> must be provided.
|
||||
|
||||
:param token: Token to delete.
|
||||
:param username: User name.
|
||||
:param token_id: Token ID.
|
||||
:return: True if the token was successfully deleted, False otherwise.
|
||||
"""
|
||||
with self._get_session() as session:
|
||||
user = self._user_by_token(session, token)
|
||||
if not user:
|
||||
return False
|
||||
assert token or token_id, 'Either token or token_id must be provided'
|
||||
|
||||
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(
|
||||
token, user.password_salt, user.hmac_iterations # type: ignore
|
||||
)
|
||||
|
@ -784,13 +805,36 @@ class UserManager:
|
|||
.first()
|
||||
)
|
||||
|
||||
if not user_token:
|
||||
return False
|
||||
|
||||
assert user_token, 'No such token'
|
||||
session.delete(user_token)
|
||||
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:
|
||||
|
|
|
@ -31,6 +31,7 @@ class AuthenticationStatus(enum.Enum):
|
|||
PASSWORD_MISMATCH = 'password_mismatch'
|
||||
REGISTRATION_DISABLED = 'registration_disabled'
|
||||
REGISTRATION_REQUIRED = 'registration_required'
|
||||
UNKNOWN_ERROR = 'unknown_error'
|
||||
|
||||
|
||||
class User(Base):
|
||||
|
|
Loading…
Reference in a new issue