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 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:
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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) {
|
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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
Loading…
Reference in a new issue