FitTrackee/fittrackee/users/auth.py

1903 lines
55 KiB
Python

import datetime
import os
import re
import secrets
from typing import Dict, Tuple, Union
import jwt
from flask import (
Blueprint,
Response,
current_app,
request,
send_from_directory,
)
from sqlalchemy import exc, func
from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.utils import secure_filename
from fittrackee import appLog, db
from fittrackee.emails.tasks import (
account_confirmation_email,
email_updated_to_current_address,
email_updated_to_new_address,
password_change_email,
reset_password_email,
)
from fittrackee.files import get_absolute_file_path
from fittrackee.oauth2.server import require_auth
from fittrackee.responses import (
DataNotFoundErrorResponse,
ForbiddenErrorResponse,
HttpResponse,
InvalidPayloadErrorResponse,
NotFoundErrorResponse,
PayloadTooLargeErrorResponse,
UnauthorizedErrorResponse,
get_error_response_if_file_is_invalid,
handle_error_and_return_response,
)
from fittrackee.utils import get_readable_duration
from fittrackee.workouts.models import Sport
from .exceptions import UserControlsException, UserCreationException
from .models import BlacklistedToken, User, UserDataExport, UserSportPreference
from .tasks import export_data
from .utils.admin import UserManagerService
from .utils.controls import check_password, is_valid_email
from .utils.language import get_language
from .utils.token import decode_user_token
auth_blueprint = Blueprint('auth', __name__)
HEX_COLOR_REGEX = regex = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
NOT_FOUND_MESSAGE = 'the requested URL was not found on the server'
def send_account_confirmation_email(user: User) -> None:
if current_app.config['CAN_SEND_EMAILS']:
ui_url = current_app.config['UI_URL']
email_data = {
'username': user.username,
'fittrackee_url': ui_url,
'operating_system': request.user_agent.platform, # type: ignore # noqa
'browser_name': request.user_agent.browser, # type: ignore
'account_confirmation_url': (
f'{ui_url}/account-confirmation'
f'?token={user.confirmation_token}'
),
}
user_data = {
'language': get_language(user.language),
'email': user.email,
}
account_confirmation_email.send(user_data, email_data)
@auth_blueprint.route('/auth/register', methods=['POST'])
def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
"""
Register a user and send confirmation email.
The newly created account is inactive. The user must confirm his email
to activate it.
**Example request**:
.. sourcecode:: http
POST /api/auth/register HTTP/1.1
Content-Type: application/json
**Example responses**:
- success:
.. sourcecode:: http
HTTP/1.1 200 SUCCESS
Content-Type: application/json
{
"status": "success"
}
- error on registration:
.. sourcecode:: http
HTTP/1.1 400 BAD REQUEST
Content-Type: application/json
{
"message": "Errors: email: valid email must be provided\\n",
"status": "error"
}
:<json string username: username (3 to 30 characters required)
:<json string email: user email
:<json string password: password (8 characters required)
:<json string lang: user language preferences (if not provided or invalid,
fallback to 'en' (english))
:<json boolean accepted_policy: ``true`` if user accepted privacy policy
:statuscode 200: ``success``
:statuscode 400:
- ``invalid payload``
- ``sorry, that username is already taken``
- ``sorry, you must agree privacy policy to register``
- ``username: 3 to 30 characters required``
- ``username: only alphanumeric characters and the underscore
character "_" allowed``
- ``email: valid email must be provided``
- ``password: 8 characters required``
:statuscode 403: ``error, registration is disabled``
:statuscode 500: ``error, please try again or contact the administrator``
"""
if not current_app.config.get('is_registration_enabled'):
return ForbiddenErrorResponse('error, registration is disabled')
post_data = request.get_json()
if (
not post_data
or post_data.get('username') is None
or post_data.get('email') is None
or post_data.get('password') is None
or post_data.get('accepted_policy') is None
):
return InvalidPayloadErrorResponse()
accepted_policy = post_data.get('accepted_policy') is True
if not accepted_policy:
return InvalidPayloadErrorResponse(
'sorry, you must agree privacy policy to register'
)
username = post_data.get('username')
email = post_data.get('email')
password = post_data.get('password')
language = get_language(post_data.get('language'))
try:
user_manager_service = UserManagerService(username=username)
new_user, _ = user_manager_service.create_user(email, password)
# if a user exists with same email address (returned new_user is None),
# no error is returned since a user has to confirm his email to
# activate his account
if new_user:
new_user.language = language
new_user.accepted_policy_date = datetime.datetime.utcnow()
db.session.add(new_user)
db.session.commit()
send_account_confirmation_email(new_user)
return {'status': 'success'}, 200
# handler errors
except (UserControlsException, UserCreationException) as e:
return InvalidPayloadErrorResponse(str(e))
except (
exc.IntegrityError,
exc.OperationalError,
TypeError,
ValueError,
) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/login', methods=['POST'])
def login_user() -> Union[Dict, HttpResponse]:
"""
User login.
Only user with an active account can log in.
**Example request**:
.. sourcecode:: http
POST /api/auth/login HTTP/1.1
Content-Type: application/json
**Example responses**:
- successful login:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"auth_token": "JSON Web Token",
"message": "successfully logged in",
"status": "success"
}
- error on login
.. sourcecode:: http
HTTP/1.1 401 UNAUTHORIZED
Content-Type: application/json
{
"message": "invalid credentials",
"status": "error"
}
:<json string email: user email
:<json string password: password
:statuscode 200: ``successfully logged in``
:statuscode 400: ``invalid payload``
:statuscode 401: ``invalid credentials``
:statuscode 500: ``error, please try again or contact the administrator``
"""
# get post data
post_data = request.get_json()
if not post_data:
return InvalidPayloadErrorResponse()
email = post_data.get('email', '')
password = post_data.get('password')
try:
user = User.query.filter(
func.lower(User.email) == func.lower(email),
User.is_active == True, # noqa
).first()
if user and user.check_password(password):
# generate auth token
auth_token = user.encode_auth_token(user.id)
return {
'status': 'success',
'message': 'successfully logged in',
'auth_token': auth_token,
}
return UnauthorizedErrorResponse('invalid credentials')
# handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile', methods=['GET'])
@require_auth(scopes=['profile:read'])
def get_authenticated_user_profile(
auth_user: User,
) -> Union[Dict, HttpResponse]:
"""
Get authenticated user info (profile, account, preferences).
**Scope**: ``profile:read``
**Example request**:
.. sourcecode:: http
GET /api/auth/profile HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"accepted_privacy_policy": true,
"admin": false,
"bio": null,
"birth_date": null,
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"date_format": "dd/MM/yyyy",
"display_ascent": true,
"email": "sam@example.com",
"email_to_confirm": null,
"first_name": null,
"imperial_units": false,
"is_active": true,
"language": "en",
"last_name": null,
"location": null,
"nb_sports": 3,
"nb_workouts": 6,
"picture": false,
"records": [
{
"id": 9,
"record_type": "AS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 10,
"record_type": "FD",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 13,
"record_type": "HA",
"sport_id": 1,
"user": "Sam",
"value": 43.97,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 11,
"record_type": "LD",
"sport_id": 1,
"user": "sam",
"value": "1:01:00",
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 12,
"record_type": "MS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
}
],
"sports_list": [
1,
4,
6
],
"start_elevation_at_zero": false,
"timezone": "Europe/Paris",
"total_ascent": 720.35,
"total_distance": 67.895,
"total_duration": "6:50:27",
"use_dark_mode": null,
"use_raw_gpx_speed": false,
"username": "sam",
"weekm": false
},
"status": "success"
}
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``success``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
"""
return {'status': 'success', 'data': auth_user.serialize(auth_user)}
@auth_blueprint.route('/auth/profile/edit', methods=['POST'])
@require_auth(scopes=['profile:write'])
def edit_user(auth_user: User) -> Union[Dict, HttpResponse]:
"""
Edit authenticated user profile.
**Scope**: ``profile:write``
**Example request**:
.. sourcecode:: http
POST /api/auth/profile/edit HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"accepted_privacy_policy": true,
"admin": false,
"bio": null,
"birth_date": null,
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"date_format": "dd/MM/yyyy",
"display_ascent": true,
"email": "sam@example.com",
"email_to_confirm": null,
"first_name": null,
"imperial_units": false,
"is_active": true,
"language": "en",
"last_name": null,
"location": null,
"nb_sports": 3,
"nb_workouts": 6,
"picture": false,
"records": [
{
"id": 9,
"record_type": "AS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 10,
"record_type": "FD",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 13,
"record_type": "HA",
"sport_id": 1,
"user": "Sam",
"value": 43.97,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 11,
"record_type": "LD",
"sport_id": 1,
"user": "sam",
"value": "1:01:00",
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 12,
"record_type": "MS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
}
],
"sports_list": [
1,
4,
6
],
"start_elevation_at_zero": false,
"timezone": "Europe/Paris",
"total_ascent": 720.35,
"total_distance": 67.895,
"total_duration": "6:50:27",
"use_dark_mode": null,
"use_raw_gpx_speed": false,
"username": "sam"
"weekm": true,
},
"message": "user profile updated",
"status": "success"
}
:<json string first_name: user first name
:<json string last_name: user last name
:<json string location: user location
:<json string bio: user biography
:<json string birth_date: user birth date (format: ``%Y-%m-%d``)
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``user profile updated``
:statuscode 400: ``invalid payload``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 500: ``error, please try again or contact the administrator``
"""
# get post data
post_data = request.get_json()
user_mandatory_data = {
'first_name',
'last_name',
'bio',
'birth_date',
'location',
}
if not post_data or not post_data.keys() >= user_mandatory_data:
return InvalidPayloadErrorResponse()
first_name = post_data.get('first_name')
last_name = post_data.get('last_name')
bio = post_data.get('bio')
birth_date = post_data.get('birth_date')
location = post_data.get('location')
try:
auth_user.first_name = first_name
auth_user.last_name = last_name
auth_user.bio = bio
auth_user.location = location
auth_user.birth_date = (
datetime.datetime.strptime(birth_date, '%Y-%m-%d')
if birth_date
else None
)
db.session.commit()
return {
'status': 'success',
'message': 'user profile updated',
'data': auth_user.serialize(auth_user),
}
# handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile/edit/account', methods=['PATCH'])
@require_auth(scopes=['profile:write'])
def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]:
"""
Update authenticated user email and password.
It sends emails if sending is enabled:
- Password change
- Email change:
- one to the current address to inform user
- another one to the new address to confirm it.
**Scope**: ``profile:write``
**Example request**:
.. sourcecode:: http
PATCH /api/auth/profile/edit/account HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"accepted_privacy_policy": true,
"admin": false,
"bio": null,
"birth_date": null,
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"date_format": "dd/MM/yyyy",
"display_ascent": true,
"email": "sam@example.com",
"email_to_confirm": null,
"first_name": null,
"imperial_units": false,
"is_active": true,
"language": "en",
"last_name": null,
"location": null,
"nb_sports": 3,
"nb_workouts": 6,
"picture": false,
"records": [
{
"id": 9,
"record_type": "AS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 10,
"record_type": "FD",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 13,
"record_type": "HA",
"sport_id": 1,
"user": "Sam",
"value": 43.97,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 11,
"record_type": "LD",
"sport_id": 1,
"user": "sam",
"value": "1:01:00",
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 12,
"record_type": "MS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
}
],
"sports_list": [
1,
4,
6
],
"start_elevation_at_zero": false,
"timezone": "Europe/Paris",
"total_ascent": 720.35,
"total_distance": 67.895,
"total_duration": "6:50:27",
"use_dark_mode": null,
"use_raw_gpx_speed": false,
"username": "sam"
"weekm": true,
},
"message": "user account updated",
"status": "success"
}
:<json string email: user email
:<json string password: user current password
:<json string new_password: user new password
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``user account updated``
:statuscode 400:
- ``invalid payload``
- ``email is missing``
- ``current password is missing``
- ``email: valid email must be provided``
- ``password: 8 characters required``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
- ``invalid credentials``
:statuscode 500: ``error, please try again or contact the administrator``
"""
data = request.get_json()
if not data:
return InvalidPayloadErrorResponse()
email_to_confirm = data.get('email')
if not email_to_confirm:
return InvalidPayloadErrorResponse('email is missing')
current_password = data.get('password')
if not current_password:
return InvalidPayloadErrorResponse('current password is missing')
if not auth_user.check_password(current_password):
return UnauthorizedErrorResponse('invalid credentials')
new_password = data.get('new_password')
error_messages = ''
try:
if email_to_confirm != auth_user.email:
if is_valid_email(email_to_confirm):
if current_app.config['CAN_SEND_EMAILS']:
auth_user.email_to_confirm = email_to_confirm
auth_user.confirmation_token = secrets.token_urlsafe(30)
else:
auth_user.email = email_to_confirm
auth_user.confirmation_token = None
else:
error_messages = 'email: valid email must be provided\n'
if new_password is not None:
error_messages += check_password(new_password)
if error_messages == '':
hashed_password = auth_user.generate_password_hash(
new_password
)
auth_user.password = hashed_password
if error_messages != '':
return InvalidPayloadErrorResponse(error_messages)
db.session.commit()
if current_app.config['CAN_SEND_EMAILS']:
ui_url = current_app.config['UI_URL']
user_data = {
'language': get_language(auth_user.language),
'email': auth_user.email,
}
data = {
'username': auth_user.username,
'fittrackee_url': ui_url,
'operating_system': request.user_agent.platform,
'browser_name': request.user_agent.browser,
}
if new_password is not None:
password_change_email.send(user_data, data)
if (
auth_user.email_to_confirm is not None
and auth_user.email_to_confirm != auth_user.email
):
email_data = {
**data,
**{'new_email_address': email_to_confirm},
}
email_updated_to_current_address.send(user_data, email_data)
email_data = {
**data,
**{
'email_confirmation_url': (
f'{ui_url}/email-update'
f'?token={auth_user.confirmation_token}'
)
},
}
user_data = {
**user_data,
**{'email': auth_user.email_to_confirm},
}
email_updated_to_new_address.send(user_data, email_data)
return {
'status': 'success',
'message': 'user account updated',
'data': auth_user.serialize(auth_user),
}
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile/edit/preferences', methods=['POST'])
@require_auth(scopes=['profile:write'])
def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]:
"""
Edit authenticated user preferences.
Supported date formats:
- ``MM/dd/yyyy`` (default value)
- ``dd/MM/yyyy``
- ``yyyy-MM-dd``
- ``date_string``, corresponding on client to:
- ``MMM. do, yyyy`` for ``en`` locale
- ``d MMM yyyy`` for ``es``, ``fr``, ``gl``, ``it`` and ``nl`` locales
- ``do MMM yyyy`` for ``de`` and ``nb`` locales
**Scope**: ``profile:write``
**Example request**:
.. sourcecode:: http
POST /api/auth/profile/edit/preferences HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"accepted_privacy_policy": true,
"admin": false,
"bio": null,
"birth_date": null,
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"date_format": "MM/dd/yyyy",
"display_ascent": true,
"email": "sam@example.com",
"email_to_confirm": null,
"first_name": null,
"imperial_units": false,
"is_active": true,
"language": "en",
"last_name": null,
"location": null,
"nb_sports": 3,
"nb_workouts": 6,
"picture": false,
"records": [
{
"id": 9,
"record_type": "AS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 10,
"record_type": "FD",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 13,
"record_type": "HA",
"sport_id": 1,
"user": "Sam",
"value": 43.97,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 11,
"record_type": "LD",
"sport_id": 1,
"user": "sam",
"value": "1:01:00",
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 12,
"record_type": "MS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
}
],
"sports_list": [
1,
4,
6
],
"start_elevation_at_zero": true,
"timezone": "Europe/Paris",
"total_ascent": 720.35,
"total_distance": 67.895,
"total_duration": "6:50:27",
"use_dark_mode": null,
"use_raw_gpx_speed": true,
"username": "sam"
"weekm": true,
},
"message": "user preferences updated",
"status": "success"
}
:<json string date_format: the format used to display dates in the app
:<json boolean display_ascent: display highest ascent records and total
:<json boolean imperial_units: display distance in imperial units
:<json string language: language preferences
:<json boolean start_elevation_at_zero: do elevation plots start at zero?
:<json string timezone: user time zone
:<json boolean use_dark_mode: Display interface with dark mode if true.
If null, it uses browser preferences.
:<json boolean use_raw_gpx_speed: Use unfiltered gpx to calculate speeds
:<json boolean weekm: does week start on Monday?
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``user preferences updated``
:statuscode 400:
- ``invalid payload``
- ``password: password and password confirmation don't match``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 500: ``error, please try again or contact the administrator``
"""
# get post data
post_data = request.get_json()
user_mandatory_data = {
'date_format',
'display_ascent',
'imperial_units',
'language',
'start_elevation_at_zero',
'timezone',
'use_dark_mode',
'use_raw_gpx_speed',
'weekm',
}
if not post_data or not post_data.keys() >= user_mandatory_data:
return InvalidPayloadErrorResponse()
date_format = post_data.get('date_format')
display_ascent = post_data.get('display_ascent')
imperial_units = post_data.get('imperial_units')
language = get_language(post_data.get('language'))
start_elevation_at_zero = post_data.get('start_elevation_at_zero')
use_raw_gpx_speed = post_data.get('use_raw_gpx_speed')
use_dark_mode = post_data.get('use_dark_mode')
timezone = post_data.get('timezone')
weekm = post_data.get('weekm')
try:
auth_user.date_format = date_format
auth_user.display_ascent = display_ascent
auth_user.imperial_units = imperial_units
auth_user.language = language
auth_user.start_elevation_at_zero = start_elevation_at_zero
auth_user.timezone = timezone
auth_user.use_dark_mode = use_dark_mode
auth_user.use_raw_gpx_speed = use_raw_gpx_speed
auth_user.weekm = weekm
db.session.commit()
return {
'status': 'success',
'message': 'user preferences updated',
'data': auth_user.serialize(auth_user),
}
# handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile/edit/sports', methods=['POST'])
@require_auth(scopes=['profile:write'])
def edit_user_sport_preferences(
auth_user: User,
) -> Union[Dict, HttpResponse]:
"""
Edit authenticated user sport preferences.
**Scope**: ``profile:write``
**Example request**:
.. sourcecode:: http
POST /api/auth/profile/edit/sports HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"color": "#000000",
"is_active": true,
"sport_id": 1,
"stopped_speed_threshold": 1,
"user_id": 1
},
"message": "user sport preferences updated",
"status": "success"
}
:<json string color: valid hexadecimal color
:<json boolean is_active: is sport available when adding a workout
:<json float stopped_speed_threshold: stopped speed threshold used by gpxpy
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``user sport preferences updated``
:statuscode 400:
- ``invalid payload``
- ``invalid hexadecimal color``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 404: ``sport does not exist``
:statuscode 500: ``error, please try again or contact the administrator``
"""
post_data = request.get_json()
if (
not post_data
or 'sport_id' not in post_data
or len(post_data.keys()) == 1
):
return InvalidPayloadErrorResponse()
sport_id = post_data.get('sport_id')
sport = Sport.query.filter_by(id=sport_id).first()
if not sport:
return NotFoundErrorResponse('sport does not exist')
color = post_data.get('color')
is_active = post_data.get('is_active')
stopped_speed_threshold = post_data.get('stopped_speed_threshold')
try:
user_sport = UserSportPreference.query.filter_by(
user_id=auth_user.id,
sport_id=sport_id,
).first()
if not user_sport:
user_sport = UserSportPreference(
user_id=auth_user.id,
sport_id=sport_id,
stopped_speed_threshold=sport.stopped_speed_threshold,
)
db.session.add(user_sport)
db.session.flush()
if color:
if re.match(HEX_COLOR_REGEX, color) is None:
return InvalidPayloadErrorResponse('invalid hexadecimal color')
user_sport.color = color
if is_active is not None:
user_sport.is_active = is_active
if stopped_speed_threshold:
user_sport.stopped_speed_threshold = stopped_speed_threshold
db.session.commit()
return {
'status': 'success',
'message': 'user sport preferences updated',
'data': user_sport.serialize(),
}
# handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route(
'/auth/profile/reset/sports/<sport_id>', methods=['DELETE']
)
@require_auth(scopes=['profile:write'])
def reset_user_sport_preferences(
auth_user: User, sport_id: int
) -> Union[Tuple[Dict, int], HttpResponse]:
"""
Reset authenticated user preferences for a given sport.
**Scope**: ``profile:write``
**Example request**:
.. sourcecode:: http
DELETE /api/auth/profile/reset/sports/1 HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 OK
Content-Type: application/json
:param string sport_id: sport id
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 204: user preferences deleted
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 404: ``sport does not exist``
:statuscode 500: ``error, please try again or contact the administrator``
"""
sport = Sport.query.filter_by(id=sport_id).first()
if not sport:
return NotFoundErrorResponse('sport does not exist')
try:
user_sport = UserSportPreference.query.filter_by(
user_id=auth_user.id,
sport_id=sport_id,
).first()
if user_sport:
db.session.delete(user_sport)
db.session.commit()
return {'status': 'no content'}, 204
# handler errors
except (exc.IntegrityError, exc.OperationalError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/picture', methods=['POST'])
@require_auth(scopes=['profile:write'])
def edit_picture(auth_user: User) -> Union[Dict, HttpResponse]:
"""
Update authenticated user picture.
**Scope**: ``profile:write``
**Example request**:
.. sourcecode:: http
POST /api/auth/picture HTTP/1.1
Content-Type: multipart/form-data
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "user picture updated",
"status": "success"
}
:form file: image file (allowed extensions: .jpg, .png, .gif)
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``user picture updated``
:statuscode 400:
- ``invalid payload``
- ``no file part``
- ``no selected file``
- ``file extension not allowed``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 413: ``error during picture update: file size exceeds 1.0MB``
:statuscode 500: ``error during picture update``
"""
try:
response_object = get_error_response_if_file_is_invalid(
'picture', request
)
except RequestEntityTooLarge as e:
appLog.error(e)
return PayloadTooLargeErrorResponse(
file_type='picture',
file_size=request.content_length,
max_size=current_app.config['MAX_CONTENT_LENGTH'],
)
if response_object:
return response_object
file = request.files['file']
filename = secure_filename(file.filename) # type: ignore
dirpath = os.path.join(
current_app.config['UPLOAD_FOLDER'], 'pictures', str(auth_user.id)
)
if not os.path.exists(dirpath):
os.makedirs(dirpath)
absolute_picture_path = os.path.join(dirpath, filename)
relative_picture_path = os.path.join(
'pictures', str(auth_user.id), filename
)
try:
if auth_user.picture is not None:
old_picture_path = get_absolute_file_path(auth_user.picture)
if os.path.isfile(get_absolute_file_path(old_picture_path)):
os.remove(old_picture_path)
file.save(absolute_picture_path)
auth_user.picture = relative_picture_path
db.session.commit()
return {
'status': 'success',
'message': 'user picture updated',
}
except (exc.IntegrityError, ValueError) as e:
return handle_error_and_return_response(
e, message='error during picture update', status='fail', db=db
)
@auth_blueprint.route('/auth/picture', methods=['DELETE'])
@require_auth(scopes=['profile:write'])
def del_picture(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]:
"""
Delete authenticated user picture.
**Scope**: ``profile:write``
**Example request**:
.. sourcecode:: http
DELETE /api/auth/picture HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 NO CONTENT
Content-Type: application/json
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 204: picture deleted
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 500: ``error during picture deletion``
"""
try:
picture_path = get_absolute_file_path(auth_user.picture)
if os.path.isfile(picture_path):
os.remove(picture_path)
auth_user.picture = None
db.session.commit()
return {'status': 'no content'}, 204
except (exc.IntegrityError, ValueError) as e:
return handle_error_and_return_response(
e, message='error during picture deletion', status='fail', db=db
)
@auth_blueprint.route('/auth/password/reset-request', methods=['POST'])
def request_password_reset() -> Union[Dict, HttpResponse]:
"""
Handle password reset request.
If email sending is disabled, this endpoint is not available.
**Example request**:
.. sourcecode:: http
POST /api/auth/password/reset-request HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "password reset request processed",
"status": "success"
}
:<json string email: user email
:statuscode 200: ``password reset request processed``
:statuscode 400: ``invalid payload``
:statuscode 404: ``the requested URL was not found on the server``
"""
if not current_app.config['CAN_SEND_EMAILS']:
return NotFoundErrorResponse(NOT_FOUND_MESSAGE)
post_data = request.get_json()
if not post_data or post_data.get('email') is None:
return InvalidPayloadErrorResponse()
email = post_data.get('email')
user = User.query.filter(User.email == email).first()
if user:
password_reset_token = user.encode_password_reset_token(user.id)
ui_url = current_app.config['UI_URL']
user_language = get_language(user.language)
email_data = {
'expiration_delay': get_readable_duration(
current_app.config['PASSWORD_TOKEN_EXPIRATION_SECONDS'],
user_language,
),
'username': user.username,
'password_reset_url': (
f'{ui_url}/password-reset?token={password_reset_token}' # noqa
),
'fittrackee_url': ui_url,
'operating_system': request.user_agent.platform, # type: ignore
'browser_name': request.user_agent.browser, # type: ignore
}
user_data = {
'language': user_language,
'email': user.email,
}
reset_password_email.send(user_data, email_data)
return {
'status': 'success',
'message': 'password reset request processed',
}
@auth_blueprint.route('/auth/password/update', methods=['POST'])
def update_password() -> Union[Dict, HttpResponse]:
"""
Update user password after password reset request.
It sends emails if sending is enabled.
**Example request**:
.. sourcecode:: http
POST /api/auth/password/update HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "password updated",
"status": "success"
}
:<json string password: password (8 characters required)
:<json string token: password reset token
:statuscode 200: ``password updated``
:statuscode 400: ``invalid payload``
:statuscode 401: ``invalid token, please request a new token``
:statuscode 500: ``error, please try again or contact the administrator``
"""
post_data = request.get_json()
if (
not post_data
or post_data.get('password') is None
or post_data.get('token') is None
):
return InvalidPayloadErrorResponse()
password = post_data.get('password')
token = post_data.get('token')
try:
user_id = decode_user_token(token)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return UnauthorizedErrorResponse()
message = check_password(password)
if message != '':
return InvalidPayloadErrorResponse(message)
user = User.query.filter(User.id == user_id).first()
if not user:
return UnauthorizedErrorResponse()
try:
user.password = user.generate_password_hash(password)
db.session.commit()
if current_app.config['CAN_SEND_EMAILS']:
password_change_email.send(
{
'language': get_language(user.language),
'email': user.email,
},
{
'username': user.username,
'fittrackee_url': current_app.config['UI_URL'],
'operating_system': request.user_agent.platform,
'browser_name': request.user_agent.browser,
},
)
return {
'status': 'success',
'message': 'password updated',
}
except (exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/email/update', methods=['POST'])
def update_email() -> Union[Dict, HttpResponse]:
"""
Update user email after confirmation.
**Example request**:
.. sourcecode:: http
POST /api/auth/email/update HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "email updated",
"status": "success"
}
:<json string token: password reset token
:statuscode 200: ``email updated``
:statuscode 400: ``invalid payload``
:statuscode 500: ``error, please try again or contact the administrator``
"""
post_data = request.get_json()
if not post_data or post_data.get('token') is None:
return InvalidPayloadErrorResponse()
token = post_data.get('token')
try:
user = User.query.filter_by(confirmation_token=token).first()
if not user:
return InvalidPayloadErrorResponse()
user.email = user.email_to_confirm
user.email_to_confirm = None
user.confirmation_token = None
db.session.commit()
response = {
'status': 'success',
'message': 'email updated',
}
return response
except (exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/account/confirm', methods=['POST'])
def confirm_account() -> Union[Dict, HttpResponse]:
"""
Activate user account after registration.
**Example request**:
.. sourcecode:: http
POST /api/auth/account/confirm HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"auth_token": "JSON Web Token",
"message": "account confirmation successful",
"status": "success"
}
:<json string token: confirmation token
:statuscode 200: ``account confirmation successful``
:statuscode 400: ``invalid payload``
:statuscode 500: ``error, please try again or contact the administrator``
"""
post_data = request.get_json()
if not post_data or post_data.get('token') is None:
return InvalidPayloadErrorResponse()
token = post_data.get('token')
try:
user = User.query.filter_by(confirmation_token=token).first()
if not user:
return InvalidPayloadErrorResponse()
user.is_active = True
user.confirmation_token = None
db.session.commit()
# generate auth token
auth_token = user.encode_auth_token(user.id)
response = {
'status': 'success',
'message': 'account confirmation successful',
'auth_token': auth_token,
}
return response
except (exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/account/resend-confirmation', methods=['POST'])
def resend_account_confirmation_email() -> Union[Dict, HttpResponse]:
"""
Resend email with instructions to confirm account.
If email sending is disabled, this endpoint is not available.
**Example request**:
.. sourcecode:: http
POST /api/auth/account/resend-confirmation HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "confirmation email resent",
"status": "success"
}
:<json string email: user email
:statuscode 200: ``confirmation email resent``
:statuscode 400: ``invalid payload``
:statuscode 404: ``the requested URL was not found on the server``
:statuscode 500: ``error, please try again or contact the administrator``
"""
if not current_app.config['CAN_SEND_EMAILS']:
return NotFoundErrorResponse(NOT_FOUND_MESSAGE)
post_data = request.get_json()
if not post_data or post_data.get('email') is None:
return InvalidPayloadErrorResponse()
email = post_data.get('email')
try:
user = User.query.filter_by(email=email, is_active=False).first()
if user:
user.confirmation_token = secrets.token_urlsafe(30)
db.session.commit()
send_account_confirmation_email(user)
response = {
'status': 'success',
'message': 'confirmation email resent',
}
return response
except (exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/logout', methods=['POST'])
@require_auth()
def logout_user(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]:
"""
User logout.
If a valid token is provided, it will be blacklisted.
**Example request**:
.. sourcecode:: http
POST /api/auth/logout HTTP/1.1
Content-Type: application/json
**Example responses**:
- successful logout:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "successfully logged out",
"status": "success"
}
- error on logout:
.. sourcecode:: http
HTTP/1.1 401 UNAUTHORIZED
Content-Type: application/json
{
"message": "provide a valid auth token",
"status": "error"
}
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``successfully logged out``
:statuscode 401:
- ``provide a valid auth token``
- ``The access token provided is expired, revoked, malformed, or invalid
for other reasons.``
:statuscode 500: ``error on token blacklist``
"""
auth_token = request.headers.get('Authorization', '').split(' ')[1]
try:
db.session.add(BlacklistedToken(token=auth_token))
db.session.commit()
except Exception:
return {
'status': 'error',
'message': 'error on token blacklist',
}, 500
return {
'status': 'success',
'message': 'successfully logged out',
}, 200
@auth_blueprint.route('/auth/account/privacy-policy', methods=['POST'])
@require_auth()
def accept_privacy_policy(auth_user: User) -> Union[Dict, HttpResponse]:
"""
The authenticated user accepts the privacy policy.
**Example request**:
.. sourcecode:: http
POST /auth/account/privacy-policy HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success"
}
:<json boolean accepted_policy: ``true`` if user accepted privacy policy
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``success``
:statuscode 400: ``invalid payload``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 500: ``error, please try again or contact the administrator``
"""
post_data = request.get_json()
if not post_data or not post_data.get('accepted_policy'):
return InvalidPayloadErrorResponse()
try:
if post_data.get('accepted_policy') is True:
auth_user.accepted_policy_date = datetime.datetime.utcnow()
db.session.commit()
return {"status": "success"}
else:
return InvalidPayloadErrorResponse()
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/account/export/request', methods=['POST'])
@require_auth()
def request_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]:
"""
Request a data export for authenticated user.
**Example request**:
.. sourcecode:: http
POST /auth/account/export/request HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success",
"request": {
"created_at": "Wed, 01 Mar 2023 12:31:17 GMT",
"status": "in_progress",
"file_name": null,
"file_size": null
}
}
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``success``
:statuscode 400:
- ``ongoing request exists``
- ``completed request already exists``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 500: ``error, please try again or contact the administrator``
"""
existing_export_request = UserDataExport.query.filter_by(
user_id=auth_user.id
).first()
if existing_export_request:
if not existing_export_request.completed:
return InvalidPayloadErrorResponse("ongoing request exists")
export_expiration = current_app.config["DATA_EXPORT_EXPIRATION"]
if existing_export_request.created_at > (
datetime.datetime.utcnow()
- datetime.timedelta(hours=export_expiration)
):
return InvalidPayloadErrorResponse(
"completed request already exists"
)
try:
if existing_export_request:
db.session.delete(existing_export_request)
db.session.flush()
export_request = UserDataExport(user_id=auth_user.id)
db.session.add(export_request)
db.session.commit()
export_data.send(export_request_id=export_request.id)
return {"status": "success", "request": export_request.serialize()}
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/account/export', methods=['GET'])
@require_auth()
def get_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]:
"""
Get a data export info for authenticated user if a request exists.
It returns:
- export creation date
- export status (``in_progress``, ``successful`` and ``errored``)
- file name and size (in bytes) when export is successful
**Example request**:
.. sourcecode:: http
GET /auth/account/export HTTP/1.1
Content-Type: application/json
**Example response**:
- if a request exists:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success",
"request": {
"created_at": "Wed, 01 Mar 2023 12:31:17 GMT",
"status": "successful",
"file_name": "archive_rgjsR3fHt295ywNQr5Yp.zip",
"file_size": 924
}
}
- if no request:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success",
"request": null
}
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``success``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
"""
export_request = UserDataExport.query.filter_by(
user_id=auth_user.id
).first()
return {
"status": "success",
"request": export_request.serialize() if export_request else None,
}
@auth_blueprint.route(
'/auth/account/export/<string:file_name>', methods=['GET']
)
@require_auth()
def download_data_export(
auth_user: User, file_name: str
) -> Union[Response, HttpResponse]:
"""
Download a data export archive
**Example request**:
.. sourcecode:: http
GET /auth/account/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/x-gzip
:param string file_name: filename
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: ``success``
:statuscode 401:
- ``provide a valid auth token``
- ``signature expired, please log in again``
- ``invalid token, please log in again``
:statuscode 404: ``file not found``
"""
export_request = UserDataExport.query.filter_by(
user_id=auth_user.id
).first()
if (
not export_request
or not export_request.completed
or export_request.file_name != file_name
):
return DataNotFoundErrorResponse(
data_type="archive", message="file not found"
)
return send_from_directory(
f"{current_app.config['UPLOAD_FOLDER']}/exports/{auth_user.id}",
export_request.file_name,
mimetype='application/zip',
as_attachment=True,
)