This repository has been archived on 2020-03-24. You can view files and clone it, but cannot push or open issues or pull requests.
quay/endpoints/api/user.py

575 lines
17 KiB
Python
Raw Normal View History

2014-03-13 19:19:49 +00:00
import logging
import json
2014-03-13 19:19:49 +00:00
from flask import request
from flask.ext.login import logout_user
from flask.ext.principal import identity_changed, AnonymousIdentity
2014-05-09 21:39:43 +00:00
from app import app, billing as stripe, authentication
2014-03-13 19:19:49 +00:00
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, NotFound, require_user_admin, parse_args,
query_param, InvalidToken, require_scope, format_date, hide_if, show_if,
license_error, require_fresh_login)
2014-03-13 19:19:49 +00:00
from endpoints.api.subscribe import subscribe
from endpoints.common import common_login
2014-09-11 19:45:41 +00:00
from endpoints.api.team import try_accept_invite
2014-03-13 19:19:49 +00:00
from data import model
from data.billing import get_plan
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
UserAdminPermission, UserReadPermission, SuperUserPermission)
2014-03-13 19:19:49 +00:00
from auth.auth_context import get_authenticated_user
2014-03-19 17:57:36 +00:00
from auth import scopes
2014-03-13 19:19:49 +00:00
from util.gravatar import compute_hash
from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email)
2014-09-11 19:45:41 +00:00
from util.names import parse_single_urn
2014-03-13 19:19:49 +00:00
import features
2014-03-13 19:19:49 +00:00
logger = logging.getLogger(__name__)
def user_view(user):
def org_view(o):
admin_org = AdministerOrganizationPermission(o.username)
return {
'name': o.username,
'gravatar': compute_hash(o.email),
'is_org_admin': admin_org.can(),
'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(),
'preferred_namespace': not (o.stripe_id is None)
}
organizations = model.get_user_organizations(user.username)
def login_view(login):
try:
metadata = json.loads(login.metadata_json)
except:
metadata = {}
2014-03-13 19:19:49 +00:00
return {
'service': login.service.name,
'service_identifier': login.service_ident,
'metadata': metadata
2014-03-13 19:19:49 +00:00
}
logins = model.list_federated_logins(user)
user_response = {
2014-03-13 19:19:49 +00:00
'verified': user.verified,
'anonymous': False,
'username': user.username,
'email': user.email,
'gravatar': compute_hash(user.email),
}
user_admin = UserAdminPermission(user.username)
if user_admin.can():
user_response.update({
'organizations': [org_view(o) for o in organizations],
'logins': [login_view(login) for login in logins],
'can_create_repo': True,
'invoice_email': user.invoice_email,
'preferred_namespace': not (user.stripe_id is None),
})
if features.SUPER_USERS:
user_response.update({
'super_user': user and user == get_authenticated_user() and SuperUserPermission().can()
})
return user_response
2014-03-13 19:19:49 +00:00
def notification_view(notification):
return {
2014-07-28 22:23:46 +00:00
'id': notification.uuid,
'organization': notification.target.username if notification.target.organization else None,
'kind': notification.kind.name,
'created': format_date(notification.created),
'metadata': json.loads(notification.metadata_json),
2014-07-28 22:23:46 +00:00
'dismissed': notification.dismissed
}
2014-03-13 19:19:49 +00:00
@resource('/v1/user/')
class User(ApiResource):
""" Operations related to users. """
schemas = {
'NewUser': {
2014-03-13 19:19:49 +00:00
'id': 'NewUser',
'type': 'object',
'description': 'Fields which must be specified for a new user.',
'required': [
'username',
'password',
'email',
],
2014-03-13 19:19:49 +00:00
'properties': {
'username': {
'type': 'string',
'description': 'The user\'s username',
},
'password': {
'type': 'string',
'description': 'The user\'s password',
},
'email': {
'type': 'string',
'description': 'The user\'s email address',
},
}
},
'UpdateUser': {
'id': 'UpdateUser',
'type': 'object',
'description': 'Fields which can be updated in a user.',
'properties': {
'password': {
'type': 'string',
'description': 'The user\'s password',
},
'invoice_email': {
'type': 'boolean',
'description': 'Whether the user desires to receive an invoice email.',
},
'email': {
'type': 'string',
'description': 'The user\'s email address',
},
},
},
}
@require_scope(scopes.READ_USER)
2014-03-13 19:19:49 +00:00
@nickname('getLoggedInUser')
def get(self):
""" Get user information for the authenticated user. """
user = get_authenticated_user()
if user is None or user.organization or not UserReadPermission(user.username).can():
2014-03-19 17:57:36 +00:00
raise InvalidToken("Requires authentication", payload={'session_required': False})
2014-03-13 19:19:49 +00:00
return user_view(user)
@require_user_admin
@require_fresh_login
2014-03-13 19:19:49 +00:00
@nickname('changeUserDetails')
2014-03-14 22:07:03 +00:00
@internal_only
2014-03-13 19:19:49 +00:00
@validate_json_request('UpdateUser')
def put(self):
""" Update a users details such as password or email. """
user = get_authenticated_user()
user_data = request.get_json()
try:
2014-03-13 19:19:49 +00:00
if 'password' in user_data:
logger.debug('Changing password for user: %s', user.username)
log_action('account_change_password', user.username)
model.change_password(user, user_data['password'])
if 'invoice_email' in user_data:
logger.debug('Changing invoice_email for user: %s', user.username)
model.change_invoice_email(user, user_data['invoice_email'])
if 'email' in user_data and user_data['email'] != user.email:
new_email = user_data['email']
if model.find_user_by_email(new_email):
# Email already used.
raise request_error(message='E-mail address already used')
2014-03-13 19:19:49 +00:00
logger.debug('Sending email to change email address for user: %s',
user.username)
code = model.create_confirm_email_code(user, new_email=new_email)
send_change_email(user.username, user_data['email'], code.code)
except model.InvalidPasswordException, ex:
raise request_error(exception=ex)
2014-03-13 19:19:49 +00:00
return user_view(user)
@nickname('createNewUser')
2014-09-11 19:45:41 +00:00
@parse_args
@query_param('inviteCode', 'Invitation code given for creating the user.', type=str,
default='')
2014-03-14 22:07:03 +00:00
@internal_only
2014-03-13 19:19:49 +00:00
@validate_json_request('NewUser')
2014-09-11 19:45:41 +00:00
def post(self, args):
""" Create a new user. """
2014-03-13 19:19:49 +00:00
user_data = request.get_json()
2014-09-11 19:45:41 +00:00
invite_code = args['inviteCode']
2014-03-13 19:19:49 +00:00
existing_user = model.get_user(user_data['username'])
if existing_user:
raise request_error(message='The username already exists')
2014-03-13 19:19:49 +00:00
try:
new_user = model.create_user(user_data['username'], user_data['password'],
user_data['email'])
code = model.create_confirm_email_code(new_user)
send_confirmation_email(new_user.username, new_user.email, code.code)
2014-09-11 19:45:41 +00:00
# Handle any invite codes.
parsed_invite = parse_single_urn(invite_code)
if parsed_invite is not None:
if parsed_invite[0] == 'teaminvite':
# Add the user to the team.
try_accept_invite(invite_code, new_user)
2014-03-13 19:19:49 +00:00
return 'Created', 201
except model.TooManyUsersException as ex:
raise license_error(exception=ex)
2014-03-13 19:19:49 +00:00
except model.DataModelException as ex:
raise request_error(exception=ex)
2014-03-13 19:19:49 +00:00
@resource('/v1/user/private')
@internal_only
@show_if(features.BILLING)
2014-03-13 19:19:49 +00:00
class PrivateRepositories(ApiResource):
""" Operations dealing with the available count of private repositories. """
@require_user_admin
2014-03-13 19:19:49 +00:00
@nickname('getUserPrivateAllowed')
def get(self):
""" Get the number of private repos this user has, and whether they are allowed to create more.
"""
user = get_authenticated_user()
private_repos = model.get_private_repo_count(user.username)
repos_allowed = 0
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus.subscription:
plan = get_plan(cus.subscription.plan.id)
if plan:
repos_allowed = plan['privateRepos']
return {
'privateCount': private_repos,
'privateAllowed': (private_repos < repos_allowed)
}
def conduct_signin(username_or_email, password):
needs_email_verification = False
invalid_credentials = False
verified = None
try:
verified = authentication.verify_user(username_or_email, password)
except model.TooManyUsersException as ex:
raise license_error(exception=ex)
2014-03-13 19:19:49 +00:00
if verified:
if common_login(verified):
return {'success': True}
else:
needs_email_verification = True
else:
invalid_credentials = True
return {
'needsEmailVerification': needs_email_verification,
'invalidCredentials': invalid_credentials,
}, 403
@resource('/v1/user/convert')
2014-03-14 22:07:03 +00:00
@internal_only
@show_if(app.config['AUTHENTICATION_TYPE'] == 'Database')
2014-03-13 19:19:49 +00:00
class ConvertToOrganization(ApiResource):
""" Operations for converting a user to an organization. """
schemas = {
'ConvertUser': {
'id': 'ConvertUser',
'type': 'object',
'description': 'Information required to convert a user to an organization.',
'required': [
2014-03-17 18:52:52 +00:00
'adminUser',
'adminPassword'
],
2014-03-13 19:19:49 +00:00
'properties': {
2014-03-17 18:52:52 +00:00
'adminUser': {
2014-03-13 19:19:49 +00:00
'type': 'string',
2014-03-17 18:52:52 +00:00
'description': 'The user who will become an org admin\'s username',
2014-03-13 19:19:49 +00:00
},
2014-03-17 18:52:52 +00:00
'adminPassword': {
2014-03-13 19:19:49 +00:00
'type': 'string',
2014-03-17 18:52:52 +00:00
'description': 'The user who will become an org admin\'s password',
2014-03-13 19:19:49 +00:00
},
2014-03-17 18:52:52 +00:00
'plan': {
2014-03-13 19:19:49 +00:00
'type': 'string',
'description': 'The plan to which the organization should be subscribed',
2014-03-13 19:19:49 +00:00
},
},
},
}
@require_user_admin
2014-03-13 19:19:49 +00:00
@nickname('convertUserToOrganization')
@validate_json_request('ConvertUser')
def post(self):
""" Convert the user to an organization. """
user = get_authenticated_user()
convert_data = request.get_json()
# Ensure that the new admin user is the not user being converted.
admin_username = convert_data['adminUser']
if admin_username == user.username:
raise request_error(reason='invaliduser',
2014-03-13 19:19:49 +00:00
message='The admin user is not valid')
# Ensure that the sign in credentials work.
admin_password = convert_data['adminPassword']
admin_user = authentication.verify_user(admin_username, admin_password)
if not admin_user:
raise request_error(reason='invaliduser',
2014-03-13 19:19:49 +00:00
message='The admin user credentials are not valid')
# Subscribe the organization to the new plan.
if features.BILLING:
plan = convert_data.get('plan', 'free')
subscribe(user, plan, None, True) # Require business plans
2014-03-13 19:19:49 +00:00
# Convert the user to an organization.
model.convert_user_to_organization(user, admin_user)
2014-03-13 19:19:49 +00:00
log_action('account_convert', user.username)
# And finally login with the admin credentials.
return conduct_signin(admin_username, admin_password)
@resource('/v1/signin')
2014-03-14 22:07:03 +00:00
@internal_only
2014-03-13 19:19:49 +00:00
class Signin(ApiResource):
""" Operations for signing in the user. """
schemas = {
'SigninUser': {
'id': 'SigninUser',
'type': 'object',
'description': 'Information required to sign in a user.',
'required': [
'username',
'password',
],
2014-03-13 19:19:49 +00:00
'properties': {
'username': {
'type': 'string',
'description': 'The user\'s username',
},
'password': {
'type': 'string',
'description': 'The user\'s password',
},
},
},
}
@nickname('signinUser')
@validate_json_request('SigninUser')
def post(self):
""" Sign in the user with the specified credentials. """
signin_data = request.get_json()
if not signin_data:
raise NotFound()
2014-03-13 19:19:49 +00:00
username = signin_data['username']
password = signin_data['password']
return conduct_signin(username, password)
@resource('/v1/signin/verify')
@internal_only
class VerifyUser(ApiResource):
""" Operations for verifying the existing user. """
schemas = {
'VerifyUser': {
'id': 'VerifyUser',
'type': 'object',
'description': 'Information required to verify the signed in user.',
'required': [
'password',
],
'properties': {
'password': {
'type': 'string',
'description': 'The user\'s password',
},
},
},
}
@require_user_admin
@nickname('verifyUser')
@validate_json_request('VerifyUser')
def post(self):
""" Verifies the signed in the user with the specified credentials. """
signin_data = request.get_json()
password = signin_data['password']
return conduct_signin(get_authenticated_user().username, password)
2014-03-13 19:19:49 +00:00
@resource('/v1/signout')
2014-03-14 22:07:03 +00:00
@internal_only
2014-03-13 19:19:49 +00:00
class Signout(ApiResource):
""" Resource for signing out users. """
@nickname('logout')
def post(self):
""" Request that the current user be signed out. """
logout_user()
identity_changed.send(app, identity=AnonymousIdentity())
return {'success': True}
@resource("/v1/recovery")
2014-03-14 22:07:03 +00:00
@internal_only
2014-03-13 19:19:49 +00:00
class Recovery(ApiResource):
""" Resource for requesting a password recovery email. """
schemas = {
'RequestRecovery': {
'id': 'RequestRecovery',
'type': 'object',
'description': 'Information required to sign in a user.',
'required': [
'email',
],
2014-03-13 19:19:49 +00:00
'properties': {
'email': {
'type': 'string',
'description': 'The user\'s email address',
},
},
},
}
@nickname('requestRecoveryEmail')
@validate_json_request('RequestRecovery')
def post(self):
""" Request a password recovery email."""
email = request.get_json()['email']
code = model.create_reset_password_email_code(email)
send_recovery_email(email, code.code)
return 'Created', 201
@resource('/v1/user/notifications')
@internal_only
class UserNotificationList(ApiResource):
@require_user_admin
@parse_args
@query_param('page', 'Offset page number. (int)', type=int, default=0)
@query_param('limit', 'Limit on the number of results (int)', type=int, default=5)
@nickname('listUserNotifications')
def get(self, args):
page = args['page']
limit = args['limit']
notifications = list(model.list_notifications(get_authenticated_user(), page=page, limit=limit + 1))
has_more = False
if len(notifications) > limit:
has_more = True
notifications = notifications[0:limit]
return {
'notifications': [notification_view(notification) for notification in notifications],
'additional': has_more
}
2014-07-28 22:23:46 +00:00
@resource('/v1/user/notifications/<uuid>')
@internal_only
class UserNotification(ApiResource):
schemas = {
'UpdateNotification': {
'id': 'UpdateNotification',
'type': 'object',
'description': 'Information for updating a notification',
'properties': {
'dismissed': {
'type': 'boolean',
'description': 'Whether the notification is dismissed by the user',
},
},
},
}
@require_user_admin
@nickname('getUserNotification')
def get(self, uuid):
notification = model.lookup_notification(get_authenticated_user(), uuid)
if not notification:
raise NotFound()
return notification_view(notification)
@require_user_admin
@nickname('updateUserNotification')
@validate_json_request('UpdateNotification')
def put(self, uuid):
notification = model.lookup_notification(get_authenticated_user(), uuid)
if not notification:
raise NotFound()
notification.dismissed = request.get_json().get('dismissed', False)
notification.save()
return notification_view(notification)
def authorization_view(access_token):
oauth_app = access_token.application
return {
'application': {
'name': oauth_app.name,
'description': oauth_app.description,
'url': oauth_app.application_uri,
'gravatar': compute_hash(oauth_app.gravatar_email or oauth_app.organization.email),
'organization': {
'name': oauth_app.organization.username,
'gravatar': compute_hash(oauth_app.organization.email)
}
},
'scopes': scopes.get_scope_information(access_token.scope),
'uuid': access_token.uuid
}
@resource('/v1/user/authorizations')
@internal_only
class UserAuthorizationList(ApiResource):
@require_user_admin
@nickname('listUserAuthorizations')
def get(self):
access_tokens = model.oauth.list_access_tokens_for_user(get_authenticated_user())
return {
'authorizations': [authorization_view(token) for token in access_tokens]
}
@resource('/v1/user/authorizations/<access_token_uuid>')
@internal_only
class UserAuthorization(ApiResource):
@require_user_admin
@nickname('getUserAuthorization')
def get(self, access_token_uuid):
2014-03-25 18:32:02 +00:00
access_token = model.oauth.lookup_access_token_for_user(get_authenticated_user(),
access_token_uuid)
if not access_token:
raise NotFound()
return authorization_view(access_token)
@require_user_admin
@nickname('deleteUserAuthorization')
def delete(self, access_token_uuid):
2014-03-25 18:32:02 +00:00
access_token = model.oauth.lookup_access_token_for_user(get_authenticated_user(),
access_token_uuid)
if not access_token:
raise NotFound()
access_token.delete_instance(recursive=True, delete_nullable=True)
return 'Deleted', 204