API - init user email update (WIP)

This commit is contained in:
Sam 2022-03-13 08:52:09 +01:00
parent 6c6b4b0ea4
commit d64579bfa3
5 changed files with 240 additions and 49 deletions

View File

@ -0,0 +1,46 @@
"""update User table
Revision ID: 5e3a3a31c432
Revises: e30007d681cb
Create Date: 2022-02-23 11:05:24.223304
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5e3a3a31c432'
down_revision = 'e30007d681cb'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column(
'users', 'username', existing_type=sa.String(length=20),
type_=sa.String(length=255), existing_nullable=False
)
op.alter_column(
'users', 'email', existing_type=sa.String(length=120),
type_=sa.String(length=255), existing_nullable=False
)
op.add_column(
'users',
sa.Column('email_to_confirm', sa.String(length=255), nullable=True))
op.add_column(
'users',
sa.Column('confirmation_token', sa.String(length=255), nullable=True))
def downgrade():
op.drop_column('users', 'confirmation_token')
op.drop_column('users', 'email_to_confirm')
op.alter_column(
'users', 'email', existing_type=sa.String(length=255),
type_=sa.String(length=120), existing_nullable=False
)
op.alter_column(
'users', 'username', existing_type=sa.String(length=255),
type_=sa.String(length=20), existing_nullable=False
)

View File

@ -1,30 +0,0 @@
"""update username length
Revision ID: 5e3a3a31c432
Revises: e30007d681cb
Create Date: 2022-02-23 11:05:24.223304
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5e3a3a31c432'
down_revision = 'e30007d681cb'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column(
'users', 'username', existing_type=sa.String(length=20),
type_=sa.String(length=255), existing_nullable=False
)
def downgrade():
op.alter_column(
'users', 'username', existing_type=sa.String(length=255),
type_=sa.String(length=20), existing_nullable=False
)

View File

@ -593,7 +593,7 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
) )
self.assert_400(response, error_message='current password is missing') self.assert_400(response)
def test_it_returns_error_if_current_password_is_missing( def test_it_returns_error_if_current_password_is_missing(
self, app: Flask, user_1: User self, app: Flask, user_1: User
@ -605,12 +605,38 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
response = client.patch( response = client.patch(
'/api/auth/profile/edit/account', '/api/auth/profile/edit/account',
content_type='application/json', content_type='application/json',
data=json.dumps(dict(new_password=random_string())), data=json.dumps(
dict(
email=user_1.email,
new_password=random_string(),
)
),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
) )
self.assert_400(response, error_message='current password is missing') self.assert_400(response, error_message='current password is missing')
def test_it_returns_error_if_email_is_missing(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.patch(
'/api/auth/profile/edit/account',
content_type='application/json',
data=json.dumps(
dict(
password='12345678',
new_password=random_string(),
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response, 'email is missing')
def test_it_returns_error_if_current_password_is_invalid( def test_it_returns_error_if_current_password_is_invalid(
self, app: Flask, user_1: User self, app: Flask, user_1: User
) -> None: ) -> None:
@ -623,6 +649,7 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
content_type='application/json', content_type='application/json',
data=json.dumps( data=json.dumps(
dict( dict(
email=user_1.email,
password=random_string(), password=random_string(),
new_password=random_string(), new_password=random_string(),
) )
@ -632,6 +659,129 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
self.assert_401(response, error_message='invalid credentials') self.assert_401(response, error_message='invalid credentials')
def test_it_does_not_returns_error_if_no_new_password_provided(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.patch(
'/api/auth/profile/edit/account',
content_type='application/json',
data=json.dumps(
dict(
email=user_1.email,
password='12345678',
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'user account updated'
def test_it_does_not_update_user_account_if_no_new_password_provided(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
current_hashed_password = user_1.password
current_email = user_1.email
response = client.patch(
'/api/auth/profile/edit/account',
content_type='application/json',
data=json.dumps(
dict(
email=user_1.email,
password='12345678',
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
assert current_email == user_1.email
assert current_hashed_password == user_1.password
def test_it_returns_error_if_new_email_is_invalid(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.patch(
'/api/auth/profile/edit/account',
content_type='application/json',
data=json.dumps(
dict(
email=random_string(),
password='12345678',
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response, 'email: valid email must be provided\n')
def test_it_does_not_update_email_if_new_email_provided(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
current_email = user_1.email
new_email = 'new.email@example.com'
response = client.patch(
'/api/auth/profile/edit/account',
content_type='application/json',
data=json.dumps(
dict(
email=new_email,
password='12345678',
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
assert current_email == user_1.email
assert new_email == user_1.email_to_confirm
assert user_1.confirmation_token is not None
def test_it_updates_email_to_confirm_when_new_email_provided(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
previous_confirmation_token = random_string()
user_1.email_to_confirm = 'new.email@example.com'
user_1.confirmation_token = previous_confirmation_token
new_email = 'another.email@example.com'
response = client.patch(
'/api/auth/profile/edit/account',
content_type='application/json',
data=json.dumps(
dict(
email=new_email,
password='12345678',
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
assert new_email == user_1.email_to_confirm
assert user_1.confirmation_token != previous_confirmation_token
def test_it_returns_error_if_controls_fail_on_new_password( def test_it_returns_error_if_controls_fail_on_new_password(
self, app: Flask, user_1: User self, app: Flask, user_1: User
) -> None: ) -> None:
@ -644,6 +794,7 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
content_type='application/json', content_type='application/json',
data=json.dumps( data=json.dumps(
dict( dict(
email=user_1.email,
password='12345678', password='12345678',
new_password=random_string(length=3), new_password=random_string(length=3),
) )
@ -666,6 +817,7 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
content_type='application/json', content_type='application/json',
data=json.dumps( data=json.dumps(
dict( dict(
email=user_1.email,
password='12345678', password='12345678',
new_password=random_string(), new_password=random_string(),
) )
@ -689,6 +841,7 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
content_type='application/json', content_type='application/json',
data=json.dumps( data=json.dumps(
dict( dict(
email=user_1.email,
password='12345678', password='12345678',
new_password=new_password, new_password=new_password,
) )

View File

@ -1,6 +1,7 @@
import datetime import datetime
import os import os
import re import re
import secrets
from typing import Dict, Tuple, Union from typing import Dict, Tuple, Union
import jwt import jwt
@ -27,7 +28,7 @@ from fittrackee.workouts.models import Sport
from .decorators import authenticate from .decorators import authenticate
from .models import User, UserSportPreference from .models import User, UserSportPreference
from .utils.controls import check_password, register_controls from .utils.controls import check_password, is_valid_email, register_controls
from .utils.token import decode_user_token from .utils.token import decode_user_token
auth_blueprint = Blueprint('auth', __name__) auth_blueprint = Blueprint('auth', __name__)
@ -546,7 +547,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]:
@authenticate @authenticate
def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]:
""" """
update authenticated user password update authenticated user email and password
**Example request**: **Example request**:
@ -630,48 +631,67 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]:
"status": "success" "status": "success"
} }
:<json string password: user password :<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 :reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: user account updated :statuscode 200: user account updated
:statuscode 400: :statuscode 400:
- invalid payload - invalid payload
- email is missing
- current password is missing
- email: valid email must be provided
- password: 8 characters required - password: 8 characters required
:statuscode 401: :statuscode 401:
- provide a valid auth token - provide a valid auth token
- signature expired, please log in again - signature expired, please log in again
- invalid token, please log in again - invalid token, please log in again
- invalid credentials
:statuscode 500: error, please try again or contact the administrator :statuscode 500: error, please try again or contact the administrator
""" """
data = request.get_json() data = request.get_json()
if not data or not data.get('password'): if not data:
return InvalidPayloadErrorResponse('current password is missing') return InvalidPayloadErrorResponse()
email_to_confirm = data.get('email')
if not email_to_confirm:
return InvalidPayloadErrorResponse('email is missing')
current_password = data.get('password') current_password = data.get('password')
if not current_password:
return InvalidPayloadErrorResponse('current password is missing')
if not bcrypt.check_password_hash(auth_user.password, current_password): if not bcrypt.check_password_hash(auth_user.password, current_password):
return UnauthorizedErrorResponse('invalid credentials') return UnauthorizedErrorResponse('invalid credentials')
new_password = data.get('new_password') new_password = data.get('new_password')
message = check_password(new_password) error_messages = ''
if message != '': try:
return InvalidPayloadErrorResponse(message) if email_to_confirm != auth_user.email:
if is_valid_email(email_to_confirm):
auth_user.email_to_confirm = email_to_confirm
auth_user.confirmation_token = secrets.token_urlsafe(16)
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 = bcrypt.generate_password_hash( hashed_password = bcrypt.generate_password_hash(
new_password, current_app.config.get('BCRYPT_LOG_ROUNDS') new_password, current_app.config.get('BCRYPT_LOG_ROUNDS')
).decode() ).decode()
try:
auth_user.password = hashed_password auth_user.password = hashed_password
db.session.commit()
if error_messages != '':
return InvalidPayloadErrorResponse(error_messages)
db.session.commit()
return { return {
'status': 'success', 'status': 'success',
'message': 'user account updated', 'message': 'user account updated',
'data': auth_user.serialize(), 'data': auth_user.serialize(),
} }
# handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e: except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db) return handle_error_and_return_response(e, db=db)

View File

@ -20,7 +20,7 @@ class User(BaseModel):
__tablename__ = 'users' __tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(255), unique=True, nullable=False) username = db.Column(db.String(255), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(255), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False) password = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, nullable=False) created_at = db.Column(db.DateTime, nullable=False)
admin = db.Column(db.Boolean, default=False, nullable=False) admin = db.Column(db.Boolean, default=False, nullable=False)
@ -45,6 +45,8 @@ class User(BaseModel):
) )
language = db.Column(db.String(50), nullable=True) language = db.Column(db.String(50), nullable=True)
imperial_units = db.Column(db.Boolean, default=False, nullable=False) imperial_units = db.Column(db.Boolean, default=False, nullable=False)
email_to_confirm = db.Column(db.String(255), nullable=True)
confirmation_token = db.Column(db.String(255), nullable=True)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<User {self.username!r}>' return f'<User {self.username!r}>'