""" Manage the current user. """ import logging import json import recaptcha2 from flask import request, abort from flask_login import logout_user from flask_principal import identity_changed, AnonymousIdentity from peewee import IntegrityError import features from app import (app, billing as stripe, authentication, avatar, user_analytics, all_queues, oauth_login) from auth import scopes from auth.auth_context import get_authenticated_user from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission, UserAdminPermission, UserReadPermission, SuperUserPermission) from data import model from data.billing import get_plan from data.database import Repository as RepositoryTable, UserPromptTypes from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, require_user_admin, parse_args, query_param, require_scope, format_date, show_if, require_fresh_login, path_param, define_json_response, RepositoryParamResource, page_support) from endpoints.exception import NotFound, InvalidToken, InvalidRequest, DownstreamIssue from endpoints.api.subscribe import subscribe from endpoints.common import common_login from endpoints.csrf import generate_csrf_token, OAUTH_CSRF_TOKEN_NAME from endpoints.decorators import anon_allowed from oauth.oidc import DiscoveryFailureException from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email, send_password_changed, send_org_recovery_email) from util.names import parse_single_urn from util.saas.useranalytics import build_error_callback REPOS_PER_PAGE = 100 logger = logging.getLogger(__name__) def handle_invite_code(invite_code, user): """ Checks that the given invite code matches the specified user's e-mail address. If so, the user is marked as having a verified e-mail address and this method returns True. """ parsed_invite = parse_single_urn(invite_code) if parsed_invite is None: return False if parsed_invite[0] != 'teaminvite': return False # Check to see if the team invite is valid. If so, then we know the user has # a possible matching email address. try: found = model.team.find_matching_team_invite(invite_code, user) except model.DataModelException: return False # Since we sent the invite code via email, mark the user as having a verified # email address. if found.email != user.email: return False user.verified = True user.save() return True def user_view(user, previous_username=None): def org_view(o, user_admin=True): admin_org = AdministerOrganizationPermission(o.username) org_response = { 'name': o.username, 'avatar': avatar.get_data_for_org(o), 'can_create_repo': CreateRepositoryPermission(o.username).can(), } if user_admin: org_response.update({ 'is_org_admin': admin_org.can(), 'preferred_namespace': not (o.stripe_id is None), }) return org_response organizations = model.organization.get_user_organizations(user.username) def login_view(login): try: metadata = json.loads(login.metadata_json) except: metadata = {} return { 'service': login.service.name, 'service_identifier': login.service_ident, 'metadata': metadata } logins = model.user.list_federated_logins(user) user_response = { 'anonymous': False, 'username': user.username, 'avatar': avatar.get_data_for_user(user), } user_admin = UserAdminPermission(previous_username if previous_username else user.username) if user_admin.can(): user_response.update({ 'can_create_repo': True, 'is_me': True, 'verified': user.verified, 'email': user.email, 'logins': [login_view(login) for login in logins], 'invoice_email': user.invoice_email, 'invoice_email_address': user.invoice_email_address, 'preferred_namespace': not (user.stripe_id is None), 'tag_expiration_s': user.removed_tag_expiration_s, 'prompts': model.user.get_user_prompts(user), }) analytics_metadata = user_analytics.get_user_analytics_metadata(user) # This is a sync call, but goes through the async wrapper interface and # returns a Future. By calling with timeout 0 immediately after the method # call, we ensure that if it ever accidentally becomes async it will raise # a TimeoutError. user_response.update(analytics_metadata.result(timeout=0)) user_view_perm = UserReadPermission(user.username) if user_view_perm.can(): user_response.update({ 'organizations': [org_view(o, user_admin=user_admin.can()) for o in organizations], }) if features.SUPER_USERS and SuperUserPermission().can(): user_response.update({ 'super_user': user and user == get_authenticated_user() and SuperUserPermission().can() }) return user_response def notification_view(note): return { 'id': note.uuid, 'organization': note.target.username if note.target.organization else None, 'kind': note.kind.name, 'created': format_date(note.created), 'metadata': json.loads(note.metadata_json), 'dismissed': note.dismissed } @resource('/v1/user/') class User(ApiResource): """ Operations related to users. """ schemas = { 'NewUser': { 'type': 'object', 'description': 'Fields which must be specified for a new user.', 'required': [ 'username', 'password', ], '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', }, 'invite_code': { 'type': 'string', 'description': 'The optional invite code', }, 'recaptcha_response': { 'type': 'string', 'description': 'The (may be disabled) recaptcha response code for verification', }, } }, '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', }, 'tag_expiration_s': { 'type': 'integer', 'minimum': 0, 'description': 'The number of seconds for tag expiration', }, 'username': { 'type': 'string', 'description': 'The user\'s username', }, 'invoice_email_address': { 'type': ['string', 'null'], 'description': 'Custom email address for receiving invoices', }, 'given_name': { 'type': 'string', 'description': 'The optional entered given name for the user', }, 'family_name': { 'type': 'string', 'description': 'The optional entered family name for the user', }, 'company': { 'type': 'string', 'description': 'The optional entered company for the user', }, }, }, 'UserView': { 'type': 'object', 'description': 'Describes a user', 'required': ['anonymous', 'avatar'], 'properties': { 'verified': { 'type': 'boolean', 'description': 'Whether the user\'s email address has been verified' }, 'anonymous': { 'type': 'boolean', 'description': 'true if this user data represents a guest user' }, 'email': { 'type': 'string', 'description': 'The user\'s email address', }, 'avatar': { 'type': 'object', 'description': 'Avatar data representing the user\'s icon' }, 'organizations': { 'type': 'array', 'description': 'Information about the organizations in which the user is a member', 'items': { 'type': 'object' } }, 'logins': { 'type': 'array', 'description': 'The list of external login providers against which the user has authenticated', 'items': { 'type': 'object' } }, 'can_create_repo': { 'type': 'boolean', 'description': 'Whether the user has permission to create repositories' }, 'preferred_namespace': { 'type': 'boolean', 'description': 'If true, the user\'s namespace is the preferred namespace to display' } } }, } @require_scope(scopes.READ_USER) @nickname('getLoggedInUser') @define_json_response('UserView') @anon_allowed 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(): raise InvalidToken("Requires authentication", payload={'session_required': False}) return user_view(user) @require_user_admin @require_fresh_login @nickname('changeUserDetails') @internal_only @validate_json_request('UpdateUser') @define_json_response('UserView') def put(self): """ Update a users details such as password or email. """ user = get_authenticated_user() user_data = request.get_json() previous_username = None try: if 'password' in user_data: logger.debug('Changing password for user: %s', user.username) log_action('account_change_password', user.username) # Change the user's password. model.user.change_password(user, user_data['password']) # Login again to reset their session cookie. common_login(user) if features.MAILING: send_password_changed(user.username, user.email) if 'invoice_email' in user_data: logger.debug('Changing invoice_email for user: %s', user.username) model.user.change_send_invoice_email(user, user_data['invoice_email']) if features.CHANGE_TAG_EXPIRATION and 'tag_expiration_s' in user_data: logger.debug('Changing user tag expiration to: %ss', user_data['tag_expiration_s']) model.user.change_user_tag_expiration(user, user_data['tag_expiration_s']) if ('invoice_email_address' in user_data and user_data['invoice_email_address'] != user.invoice_email_address): model.user.change_invoice_email_address(user, user_data['invoice_email_address']) if 'email' in user_data and user_data['email'] != user.email: new_email = user_data['email'] if model.user.find_user_by_email(new_email): # Email already used. raise request_error(message='E-mail address already used') if features.MAILING: logger.debug('Sending email to change email address for user: %s', user.username) code = model.user.create_confirm_email_code(user, new_email=new_email) send_change_email(user.username, user_data['email'], code.code) else: ua_future = user_analytics.change_email(user.email, new_email) ua_future.add_done_callback(build_error_callback('Change email failed')) model.user.update_email(user, new_email, auto_verify=not features.MAILING) if 'given_name' in user_data or 'family_name' in user_data or 'company' in user_data: model.user.update_user_metadata(user, user_data.get('given_name'), user_data.get('family_name'), user_data.get('company')) ua_mdata_future = user_analytics.change_metadata( user.email, user_data.get('given_name'), user_data.get('family_name'), user_data.get('company'), ) ua_mdata_future.add_done_callback(build_error_callback('Change metadata failed')) # Check for username rename. A username can be renamed if the feature is enabled OR the user # currently has a confirm_username prompt. if 'username' in user_data: confirm_username = model.user.has_user_prompt(user, 'confirm_username') new_username = user_data.get('username') previous_username = user.username rename_allowed = features.USER_RENAME or confirm_username username_changing = new_username and new_username != previous_username if rename_allowed and username_changing: if model.user.get_user_or_org(new_username) is not None: # Username already used. raise request_error(message='Username is already in use') user = model.user.change_username(user.id, new_username) username_future = user_analytics.change_username(user.email, new_username) username_future.add_done_callback(build_error_callback('Change username failed')) elif confirm_username: model.user.remove_user_prompt(user, 'confirm_username') except model.user.InvalidPasswordException, ex: raise request_error(exception=ex) return user_view(user, previous_username=previous_username) @show_if(features.USER_CREATION) @show_if(features.DIRECT_LOGIN) @nickname('createNewUser') @internal_only @validate_json_request('NewUser') def post(self): """ Create a new user. """ if app.config['AUTHENTICATION_TYPE'] != 'Database': abort(404) user_data = request.get_json() # If recaptcha is enabled, then verify the user is a human. if features.RECAPTCHA: recaptcha_response = user_data.get('recaptcha_response', '') result = recaptcha2.verify(app.config['RECAPTCHA_SECRET_KEY'], recaptcha_response, request.remote_addr) if not result['success']: return { 'message': 'Are you a bot? If not, please revalidate the captcha.' }, 400 invite_code = user_data.get('invite_code', '') existing_user = model.user.get_nonrobot_user(user_data['username']) if existing_user: raise request_error(message='The username already exists') if features.MAILING and not user_data.get('email'): raise request_error(message='Email address is required') try: prompts = model.user.get_default_user_prompts(features) new_user = model.user.create_user(user_data['username'], user_data['password'], user_data.get('email'), auto_verify=not features.MAILING, email_required=features.MAILING, prompts=prompts) email_address_confirmed = handle_invite_code(invite_code, new_user) if features.MAILING and not email_address_confirmed: code = model.user.create_confirm_email_code(new_user) send_confirmation_email(new_user.username, new_user.email, code.code) return { 'awaiting_verification': True } else: common_login(new_user) return user_view(new_user) except model.user.DataModelException as ex: raise request_error(exception=ex) @require_user_admin @require_fresh_login @nickname('deleteCurrentUser') @internal_only def delete(self): """ Deletes the current user. """ if app.config['AUTHENTICATION_TYPE'] != 'Database': abort(404) model.user.delete_user(get_authenticated_user(), all_queues) return '', 204 @resource('/v1/user/private') @internal_only @show_if(features.BILLING) class PrivateRepositories(ApiResource): """ Operations dealing with the available count of private repositories. """ @require_user_admin @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.user.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) } @resource('/v1/user/clientkey') @internal_only class ClientKey(ApiResource): """ Operations for returning an encrypted key which can be used in place of a password for the Docker client. """ schemas = { 'GenerateClientKey': { 'type': 'object', 'required': [ 'password', ], 'properties': { 'password': { 'type': 'string', 'description': 'The user\'s password', }, } } } @require_user_admin @nickname('generateUserClientKey') @validate_json_request('GenerateClientKey') def post(self): """ Return's the user's private client key. """ username = get_authenticated_user().username password = request.get_json()['password'] (result, error_message) = authentication.confirm_existing_user(username, password) if not result: raise request_error(message=error_message) return { 'key': authentication.encrypt_user_password(password) } def conduct_signin(username_or_email, password, invite_code=None): needs_email_verification = False invalid_credentials = False (found_user, error_message) = authentication.verify_and_link_user(username_or_email, password) if found_user: # If there is an attached invitation code, handle it here. This will mark the # user as verified if the code is valid. if invite_code: handle_invite_code(invite_code, found_user) if common_login(found_user): return {'success': True} else: needs_email_verification = True else: invalid_credentials = True return { 'needsEmailVerification': needs_email_verification, 'invalidCredentials': invalid_credentials, 'message': error_message }, 403 @resource('/v1/user/convert') @internal_only @show_if(app.config['AUTHENTICATION_TYPE'] == 'Database') class ConvertToOrganization(ApiResource): """ Operations for converting a user to an organization. """ schemas = { 'ConvertUser': { 'type': 'object', 'description': 'Information required to convert a user to an organization.', 'required': [ 'adminUser', 'adminPassword' ], 'properties': { 'adminUser': { 'type': 'string', 'description': 'The user who will become an org admin\'s username', }, 'adminPassword': { 'type': 'string', 'description': 'The user who will become an org admin\'s password', }, 'plan': { 'type': 'string', 'description': 'The plan to which the organization should be subscribed', }, }, }, } @require_user_admin @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 sign in credentials work. admin_username = convert_data['adminUser'] admin_password = convert_data['adminPassword'] (admin_user, _) = authentication.verify_and_link_user(admin_username, admin_password) if not admin_user: raise request_error(reason='invaliduser', message='The admin user credentials are not valid') # Ensure that the new admin user is the not user being converted. if admin_user.id == user.id: raise request_error(reason='invaliduser', message='The admin user is 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 # Convert the user to an organization. model.organization.convert_user_to_organization(user, admin_user) log_action('account_convert', user.username) # And finally login with the admin credentials. return conduct_signin(admin_username, admin_password) @resource('/v1/signin') @show_if(features.DIRECT_LOGIN) @internal_only class Signin(ApiResource): """ Operations for signing in the user. """ schemas = { 'SigninUser': { 'type': 'object', 'description': 'Information required to sign in a user.', 'required': [ 'username', 'password', ], 'properties': { 'username': { 'type': 'string', 'description': 'The user\'s username', }, 'password': { 'type': 'string', 'description': 'The user\'s password', }, 'invite_code': { 'type': 'string', 'description': 'The optional invite code' }, }, }, } @nickname('signinUser') @validate_json_request('SigninUser') @anon_allowed def post(self): """ Sign in the user with the specified credentials. """ signin_data = request.get_json() if not signin_data: raise NotFound() username = signin_data['username'] password = signin_data['password'] invite_code = signin_data.get('invite_code', '') return conduct_signin(username, password, invite_code=invite_code) @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'] username = get_authenticated_user().username (result, error_message) = authentication.confirm_existing_user(username, password) if not result: return { 'message': error_message, 'invalidCredentials': True, }, 403 common_login(result) return {'success': True} @resource('/v1/signout') @internal_only 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/externallogin/') @internal_only class ExternalLoginInformation(ApiResource): """ Resource for both setting a token for external login and returning its authorization url. """ schemas = { 'GetLogin': { 'type': 'object', 'description': 'Information required to an retrieve external login URL.', 'required': [ 'kind', ], 'properties': { 'kind': { 'type': 'string', 'description': 'The kind of URL', 'enum': ['login', 'attach'], }, }, }, } @nickname('retrieveExternalLoginAuthorizationUrl') @anon_allowed @validate_json_request('GetLogin') def post(self, service_id): """ Generates the auth URL and CSRF token explicitly for OIDC/OAuth-associated login. """ login_service = oauth_login.get_service(service_id) if login_service is None: raise InvalidRequest() csrf_token = generate_csrf_token(OAUTH_CSRF_TOKEN_NAME) kind = request.get_json()['kind'] redirect_suffix = '/attach' if kind == 'attach' else '' try: login_scopes = login_service.get_login_scopes() auth_url = login_service.get_auth_url(app.config, redirect_suffix, csrf_token, login_scopes) return {'auth_url': auth_url} except DiscoveryFailureException as dfe: logger.exception('Could not discovery OAuth endpoint information') raise DownstreamIssue(dfe.message) @resource('/v1/detachexternal/') @show_if(features.DIRECT_LOGIN) @internal_only class DetachExternal(ApiResource): """ Resource for detaching an external login. """ @require_user_admin @nickname('detachExternalLogin') def post(self, service_id): """ Request that the current user be detached from the external login service. """ model.user.detach_external_login(get_authenticated_user(), service_id) return {'success': True} @resource("/v1/recovery") @show_if(features.MAILING) @internal_only class Recovery(ApiResource): """ Resource for requesting a password recovery email. """ schemas = { 'RequestRecovery': { 'type': 'object', 'description': 'Information required to sign in a user.', 'required': [ 'email', ], 'properties': { 'email': { 'type': 'string', 'description': 'The user\'s email address', }, }, }, } @nickname('requestRecoveryEmail') @anon_allowed @validate_json_request('RequestRecovery') def post(self): """ Request a password recovery email.""" def redact(value): threshold = max((len(value) / 3) - 1, 1) v = '' for i in range(0, len(value)): if i < threshold or i >= len(value) - threshold: v = v + value[i] else: v = v + u'\u2022' return v email = request.get_json()['email'] user = model.user.find_user_by_email(email) if not user: raise model.InvalidEmailAddressException('Email address was not found.') if user.organization: send_org_recovery_email(user, model.organization.get_admin_users(user)) return { 'status': 'org', 'orgemail': email, 'orgname': redact(user.username), } code = model.user.create_reset_password_email_code(email) send_recovery_email(email, code.code) return { 'status': 'sent', } @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, parsed_args): page = parsed_args['page'] limit = parsed_args['limit'] notifications = list(model.notification.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(note) for note in notifications], 'additional': has_more } @resource('/v1/user/notifications/') @path_param('uuid', 'The uuid of the user notification') @internal_only class UserNotification(ApiResource): schemas = { '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): note = model.notification.lookup_notification(get_authenticated_user(), uuid) if not note: raise NotFound() return notification_view(note) @require_user_admin @nickname('updateUserNotification') @validate_json_request('UpdateNotification') def put(self, uuid): note = model.notification.lookup_notification(get_authenticated_user(), uuid) if not note: raise NotFound() note.dismissed = request.get_json().get('dismissed', False) note.save() return notification_view(note) def authorization_view(access_token): oauth_app = access_token.application app_email = oauth_app.avatar_email or oauth_app.organization.email return { 'application': { 'name': oauth_app.name, 'description': oauth_app.description, 'url': oauth_app.application_uri, 'avatar': avatar.get_data(oauth_app.name, app_email, 'app'), 'organization': { 'name': oauth_app.organization.username, 'avatar': avatar.get_data_for_org(oauth_app.organization) } }, '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/') @path_param('access_token_uuid', 'The uuid of the access token') @internal_only class UserAuthorization(ApiResource): @require_user_admin @nickname('getUserAuthorization') def get(self, access_token_uuid): 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): 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 '', 204 @resource('/v1/user/starred') class StarredRepositoryList(ApiResource): """ Operations for creating and listing starred repositories. """ schemas = { 'NewStarredRepository': { 'type': 'object', 'required': [ 'namespace', 'repository', ], 'properties': { 'namespace': { 'type': 'string', 'description': 'Namespace in which the repository belongs', }, 'repository': { 'type': 'string', 'description': 'Repository name' } } } } @nickname('listStarredRepos') @parse_args() @require_user_admin @page_support() def get(self, page_token, parsed_args): """ List all starred repositories. """ repo_query = model.repository.get_user_starred_repositories(get_authenticated_user()) repos, next_page_token = model.modelutil.paginate(repo_query, RepositoryTable, page_token=page_token, limit=REPOS_PER_PAGE) def repo_view(repo_obj): return { 'namespace': repo_obj.namespace_user.username, 'name': repo_obj.name, 'description': repo_obj.description, 'is_public': repo_obj.visibility.name == 'public', } return {'repositories': [repo_view(repo) for repo in repos]}, next_page_token @require_scope(scopes.READ_REPO) @nickname('createStar') @validate_json_request('NewStarredRepository') @require_user_admin def post(self): """ Star a repository. """ user = get_authenticated_user() req = request.get_json() namespace = req['namespace'] repository = req['repository'] repo = model.repository.get_repository(namespace, repository) if repo: try: model.repository.star_repository(user, repo) except IntegrityError: pass return { 'namespace': namespace, 'repository': repository, }, 201 @resource('/v1/user/starred/') @path_param('repository', 'The full path of the repository. e.g. namespace/name') class StarredRepository(RepositoryParamResource): """ Operations for managing a specific starred repository. """ @nickname('deleteStar') @require_user_admin def delete(self, namespace, repository): """ Removes a star from a repository. """ user = get_authenticated_user() repo = model.repository.get_repository(namespace, repository) if repo: model.repository.unstar_repository(user, repo) return '', 204 @resource('/v1/users/') class Users(ApiResource): """ Operations related to retrieving information about other users. """ @nickname('getUserInformation') def get(self, username): """ Get user information for the specified user. """ user = model.user.get_nonrobot_user(username) if user is None: abort(404) return user_view(user)