From 179c8265cf0f6c8ce0c299cedae2cdae6e864eba Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 27 Jul 2024 01:43:18 +0200 Subject: [PATCH] [#419] Added ability to view and remove API tokens. --- platypush/backend/http/app/routes/auth.py | 85 +++++++- .../backend/http/app/utils/auth/status.py | 3 + .../panels/Settings/Tokens/ApiToken.vue | 51 ++++- .../panels/Settings/Tokens/TokensList.vue | 182 ++++++++++++++++++ .../http/webapp/src/utils/DateTime.vue | 4 + platypush/backend/http/webapp/vue.config.js | 1 + platypush/user/__init__.py | 78 ++++++-- platypush/user/_model.py | 1 + 8 files changed, 381 insertions(+), 24 deletions(-) create mode 100644 platypush/backend/http/webapp/src/components/panels/Settings/Tokens/TokensList.vue diff --git a/platypush/backend/http/app/routes/auth.py b/platypush/backend/http/app/routes/auth.py index 8563dc6810..7300feeea3 100644 --- a/platypush/backend/http/app/routes/auth.py +++ b/platypush/backend/http/app/routes/auth.py @@ -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: diff --git a/platypush/backend/http/app/utils/auth/status.py b/platypush/backend/http/app/utils/auth/status.py index fc3a3e0a6f..abf3beecfd 100644 --- a/platypush/backend/http/app/utils/auth/status.py +++ b/platypush/backend/http/app/utils/auth/status.py @@ -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 { diff --git a/platypush/backend/http/webapp/src/components/panels/Settings/Tokens/ApiToken.vue b/platypush/backend/http/webapp/src/components/panels/Settings/Tokens/ApiToken.vue index c4920003d0..0f27303312 100644 --- a/platypush/backend/http/webapp/src/components/panels/Settings/Tokens/ApiToken.vue +++ b/platypush/backend/http/webapp/src/components/panels/Settings/Tokens/ApiToken.vue @@ -58,12 +58,24 @@ + + + +
- +
+ + + +

API tokens are randomly generated tokens that are stored @@ -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() + } + }, + }, } diff --git a/platypush/backend/http/webapp/src/components/panels/Settings/Tokens/TokensList.vue b/platypush/backend/http/webapp/src/components/panels/Settings/Tokens/TokensList.vue new file mode 100644 index 0000000000..ec4a3c37a9 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Settings/Tokens/TokensList.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/platypush/backend/http/webapp/src/utils/DateTime.vue b/platypush/backend/http/webapp/src/utils/DateTime.vue index 36917f2e5d..2284e49545 100644 --- a/platypush/backend/http/webapp/src/utils/DateTime.vue +++ b/platypush/backend/http/webapp/src/utils/DateTime.vue @@ -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) diff --git a/platypush/backend/http/webapp/vue.config.js b/platypush/backend/http/webapp/vue.config.js index e7f0ede603..b21ded879b 100644 --- a/platypush/backend/http/webapp/vue.config.js +++ b/platypush/backend/http/webapp/vue.config.js @@ -43,6 +43,7 @@ module.exports = { '^/media/': httpProxy, '^/otp': httpProxy, '^/sound/': httpProxy, + '^/tokens': httpProxy, '^/ws/events': wsProxy, '^/ws/requests': wsProxy, '^/ws/shell': wsProxy, diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py index e5f6bd9966..c43873f9f3 100644 --- a/platypush/user/__init__.py +++ b/platypush/user/__init__.py @@ -759,38 +759,82 @@ 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 or 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. """ + assert token or token_id, 'Either token or token_id must be provided' + with self._get_session() as session: - user = self._user_by_token(session, token) - if not user: - return False + if token: + user = self._user_by_token(session, token) + else: + user = self._get_user(session, username) - encrypted_token = self._encrypt_password( - token, user.password_salt, user.hmac_iterations # type: ignore - ) + assert user, 'No such user' - user_token = ( - session.query(UserToken) - .filter_by( - token=encrypted_token, - user_id=user.user_id, + 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 ) - .first() - ) - if not user_token: - return False + user_token = ( + 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.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: diff --git a/platypush/user/_model.py b/platypush/user/_model.py index b96f648455..005b21f7a3 100644 --- a/platypush/user/_model.py +++ b/platypush/user/_model.py @@ -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):