73cb7f3228
The user metadata fields are nullable in the database, but were not in the json sechema. This prevented users from updating some of their information on the site if they hadn't set the metadata fields.
1094 lines
35 KiB
Python
1094 lines
35 KiB
Python
""" 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, namespace_gc_queue, ip_resolver, url_scheme_and_hostname)
|
|
|
|
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
|
|
from data.users.shared import can_create_user
|
|
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(),
|
|
'public': o.username in app.config.get('PUBLIC_NAMESPACES', []),
|
|
}
|
|
|
|
if user_admin:
|
|
org_response.update({
|
|
'is_org_admin': admin_org.can(),
|
|
'preferred_namespace': not (o.stripe_id is None),
|
|
})
|
|
|
|
return org_response
|
|
|
|
# Retrieve the organizations for the user.
|
|
organizations = {o.username: o for o in model.organization.get_user_organizations(user.username)}
|
|
|
|
# Add any public namespaces.
|
|
public_namespaces = app.config.get('PUBLIC_NAMESPACES', [])
|
|
if public_namespaces:
|
|
organizations.update({ns: model.user.get_namespace_user(ns) for ns in public_namespaces})
|
|
|
|
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),
|
|
'company': user.company,
|
|
'family_name': user.family_name,
|
|
'given_name': user.given_name,
|
|
'location': user.location,
|
|
})
|
|
|
|
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.values()],
|
|
})
|
|
|
|
|
|
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', 'null'],
|
|
'description': 'The optional entered given name for the user',
|
|
},
|
|
'family_name': {
|
|
'type': ['string', 'null'],
|
|
'description': 'The optional entered family name for the user',
|
|
},
|
|
'company': {
|
|
'type': ['string', 'null'],
|
|
'description': 'The optional entered company for the user',
|
|
},
|
|
'location': {
|
|
'type': ['string', 'null'],
|
|
'description': 'The optional entered location 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.uuid)
|
|
|
|
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 features.USER_METADATA:
|
|
metadata = {}
|
|
|
|
for field in ('given_name', 'family_name', 'company', 'location'):
|
|
if field in user_data:
|
|
metadata[field] = user_data.get(field)
|
|
|
|
if len(metadata) > 0:
|
|
model.user.update_user_metadata(user, metadata)
|
|
|
|
ua_mdata_future = user_analytics.change_metadata(user.email, **metadata)
|
|
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 and features.USERNAME_CONFIRMATION))
|
|
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()
|
|
|
|
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')
|
|
|
|
# Ensure an e-mail address was specified if required.
|
|
if features.MAILING and not user_data.get('email'):
|
|
raise request_error(message='Email address is required')
|
|
|
|
# If invite-only user creation is turned on and no invite code was sent, return an error.
|
|
# Technically, this is handled by the can_create_user call below as well, but it makes
|
|
# a nicer error.
|
|
if features.INVITE_ONLY_USER_CREATION and not invite_code:
|
|
raise request_error(message='Cannot create non-invited user')
|
|
|
|
# Ensure that this user can be created.
|
|
if not can_create_user(user_data.get('email')):
|
|
raise request_error(message='Creation of a user account for this e-mail is disabled; please contact an administrator')
|
|
|
|
# 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
|
|
|
|
is_possible_abuser = ip_resolver.is_ip_possible_threat(request.remote_addr)
|
|
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,
|
|
is_possible_abuser=is_possible_abuser,
|
|
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.uuid)
|
|
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.mark_namespace_for_deletion(get_authenticated_user(), all_queues, namespace_gc_queue)
|
|
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. """
|
|
if not authentication.supports_encrypted_credentials:
|
|
raise NotFound()
|
|
|
|
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.uuid):
|
|
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.uuid)
|
|
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. """
|
|
model.user.invalidate_all_sessions(get_authenticated_user())
|
|
logout_user()
|
|
identity_changed.send(app, identity=AnonymousIdentity())
|
|
return {'success': True}
|
|
|
|
|
|
@resource('/v1/externallogin/<service_id>')
|
|
@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', 'cli'],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
@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 = '' if kind == 'login' else '/' + kind
|
|
|
|
try:
|
|
login_scopes = login_service.get_login_scopes()
|
|
auth_url = login_service.get_auth_url(url_scheme_and_hostname, 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/<service_id>')
|
|
@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',
|
|
},
|
|
'recaptcha_response': {
|
|
'type': 'string',
|
|
'description': 'The (may be disabled) recaptcha response code for verification',
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@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
|
|
|
|
recovery_data = request.get_json()
|
|
|
|
# If recaptcha is enabled, then verify the user is a human.
|
|
if features.RECAPTCHA:
|
|
recaptcha_response = recovery_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
|
|
|
|
email = recovery_data['email']
|
|
user = model.user.find_user_by_email(email)
|
|
if not user:
|
|
return {
|
|
'status': 'sent',
|
|
}
|
|
|
|
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/<uuid>')
|
|
@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/<access_token_uuid>')
|
|
@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/<apirepopath:repository>')
|
|
@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/<username>')
|
|
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)
|