import logging import json from flask import request from flask.ext.login import logout_user from flask.ext.principal import identity_changed, AnonymousIdentity from app import app, billing as stripe from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, InvalidToken, require_scope, format_date, hide_if, show_if) from endpoints.api.subscribe import subscribe from endpoints.common import common_login from data import model from data.billing import get_plan from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission, UserAdminPermission, UserReadPermission, SuperUserPermission) from auth.auth_context import get_authenticated_user from auth import scopes from util.gravatar import compute_hash from util.email import (send_confirmation_email, send_recovery_email, send_change_email) import features 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): return { 'service': login.service.name, 'service_identifier': login.service_ident, } logins = model.list_federated_logins(user) user_response = { '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 def notification_view(notification): return { '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), } @resource('/v1/user/') class User(ApiResource): """ Operations related to users. """ schemas = { 'NewUser': { 'id': 'NewUser', 'type': 'object', 'description': 'Fields which must be specified for a new user.', 'required': [ 'username', 'password', 'email', ], '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) @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(): raise InvalidToken("Requires authentication", payload={'session_required': False}) return user_view(user) @require_user_admin @nickname('changeUserDetails') @internal_only @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: 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') 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) return user_view(user) @nickname('createNewUser') @internal_only @validate_json_request('NewUser') def post(self): """ Create a new user. """ user_data = request.get_json() existing_user = model.get_user(user_data['username']) if existing_user: raise request_error(message='The username already exists') 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) return 'Created', 201 except model.DataModelException as ex: raise request_error(exception=ex) @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.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 = model.verify_user(username_or_email, password) 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') @internal_only 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': [ '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 new admin user is the not user being converted. admin_username = convert_data['adminUser'] if admin_username == user.username: raise request_error(reason='invaliduser', message='The admin user is not valid') # Ensure that the sign in credentials work. admin_password = convert_data['adminPassword'] if not model.verify_user(admin_username, admin_password): raise request_error(reason='invaliduser', 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 # Convert the user to an organization. model.convert_user_to_organization(user, model.get_user(admin_username)) log_action('account_convert', user.username) # And finally login with the admin credentials. return conduct_signin(admin_username, admin_password) @resource('/v1/signin') @internal_only 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', ], '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() username = signin_data['username'] password = signin_data['password'] return conduct_signin(username, password) @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/recovery") @internal_only 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', ], '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 @nickname('listUserNotifications') def get(self): notifications = model.list_notifications(get_authenticated_user()) return { 'notifications': [notification_view(notification) for notification in notifications] } 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/') @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 'Deleted', 204