diff --git a/auth/test/test_basic.py b/auth/test/test_basic.py index a87890108..5b710607f 100644 --- a/auth/test/test_basic.py +++ b/auth/test/test_basic.py @@ -97,9 +97,7 @@ def test_valid_token(app): def test_valid_oauth(app): user = model.user.get_user("devtable") app = model.oauth.list_applications_for_org(model.user.get_user_or_org("buynlarge"))[0] - oauth_token, code = model.oauth.create_access_token_for_testing( - user, app.client_id, "repo:read" - ) + oauth_token, code = model.oauth.create_user_access_token(user, app.client_id, "repo:read") token = _token(OAUTH_TOKEN_USERNAME, code) result = validate_basic_auth(token) assert result == ValidateResult(AuthKind.basic, oauthtoken=oauth_token) diff --git a/auth/test/test_credentials.py b/auth/test/test_credentials.py index ea92f57cb..a22cfdc6d 100644 --- a/auth/test/test_credentials.py +++ b/auth/test/test_credentials.py @@ -48,9 +48,7 @@ def test_valid_token(app): def test_valid_oauth(app): user = model.user.get_user("devtable") app = model.oauth.list_applications_for_org(model.user.get_user_or_org("buynlarge"))[0] - oauth_token, code = model.oauth.create_access_token_for_testing( - user, app.client_id, "repo:read" - ) + oauth_token, code = model.oauth.create_user_access_token(user, app.client_id, "repo:read") result, kind = validate_credentials(OAUTH_TOKEN_USERNAME, code) assert kind == CredentialKind.oauth_token assert result == ValidateResult(AuthKind.oauth, oauthtoken=oauth_token) diff --git a/auth/test/test_oauth.py b/auth/test/test_oauth.py index a8a653d9c..b489e70fb 100644 --- a/auth/test/test_oauth.py +++ b/auth/test/test_oauth.py @@ -28,7 +28,7 @@ def test_valid_oauth(app): user = model.user.get_user("devtable") app = model.oauth.list_applications_for_org(model.user.get_user_or_org("buynlarge"))[0] token_string = "%s%s" % ("a" * 20, "b" * 20) - oauth_token, _ = model.oauth.create_access_token_for_testing( + oauth_token, _ = model.oauth.create_user_access_token( user, app.client_id, "repo:read", access_token=token_string ) result = validate_bearer_auth("bearer " + token_string) @@ -40,7 +40,7 @@ def test_valid_oauth(app): def test_disabled_user_oauth(app): user = model.user.get_user("disabled") token_string = "%s%s" % ("a" * 20, "b" * 20) - oauth_token, _ = model.oauth.create_access_token_for_testing( + oauth_token, _ = model.oauth.create_user_access_token( user, "deadbeef", "repo:admin", access_token=token_string ) @@ -54,7 +54,7 @@ def test_disabled_user_oauth(app): def test_expired_token(app): user = model.user.get_user("devtable") token_string = "%s%s" % ("a" * 20, "b" * 20) - oauth_token, _ = model.oauth.create_access_token_for_testing( + oauth_token, _ = model.oauth.create_user_access_token( user, "deadbeef", "repo:admin", access_token=token_string, expires_in=-1000 ) diff --git a/config.py b/config.py index 7d8dd2485..d97c07f4a 100644 --- a/config.py +++ b/config.py @@ -777,3 +777,6 @@ class DefaultConfig(ImmutableConfig): # Account recovery mode ACCOUNT_RECOVERY_MODE = False + + # Feature Flag: If set to true, the first User account may be created via API /api/v1/user/initialize + FEATURE_USER_INITIALIZE = False diff --git a/data/model/oauth.py b/data/model/oauth.py index 62808ea10..d3f321a7d 100644 --- a/data/model/oauth.py +++ b/data/model/oauth.py @@ -382,7 +382,7 @@ def list_applications_for_org(org): return query -def create_access_token_for_testing(user_obj, client_id, scope, access_token=None, expires_in=9000): +def create_user_access_token(user_obj, client_id, scope, access_token=None, expires_in=9000): access_token = access_token or random_string_generator(length=40)() token_name = access_token[:ACCESS_TOKEN_PREFIX_LENGTH] token_code = access_token[ACCESS_TOKEN_PREFIX_LENGTH:] diff --git a/endpoints/api/test/test_user.py b/endpoints/api/test/test_user.py index 1b4102550..a7862aad1 100644 --- a/endpoints/api/test/test_user.py +++ b/endpoints/api/test/test_user.py @@ -2,9 +2,10 @@ import pytest from mock import patch +from flask import url_for from endpoints.api.test.shared import conduct_api_call from endpoints.api.user import User -from endpoints.test.shared import client_with_identity +from endpoints.test.shared import client_with_identity, conduct_call from features import FeatureNameValue from test.fixtures import * @@ -40,3 +41,96 @@ def test_user_metadata_update(client): # The location field should be unchanged. assert user.get("location") == location + + +@pytest.mark.parametrize( + "user_count, expected_code, feature_mailing, feature_user_initialize, metadata", + [ + # Non-empty database fails + ( + 1, + 400, + True, + True, + { + "username": "nonemptydb", + "password": "password", + "email": "someone@somewhere.com", + }, + ), + # Empty database with mailing succeeds + ( + 0, + 200, + True, + True, + { + "username": "emptydbemail", + "password": "password", + "email": "someone@somewhere.com", + }, + ), + # Empty database without mailing succeeds + ( + 0, + 200, + False, + True, + { + "username": "emptydbnoemail", + "password": "password", + }, + ), + # Empty database with mailing missing email fails + ( + 0, + 400, + True, + True, + { + "username": "emptydbbademail", + "password": "password", + }, + ), + # Empty database with access token + ( + 0, + 200, + False, + True, + {"username": "emptydbtoken", "password": "password", "access_token": "true"}, + ), + ], +) +def test_initialize_user( + user_count, expected_code, feature_mailing, feature_user_initialize, metadata, client +): + with patch("endpoints.web.has_users") as mock_user_count: + with patch("features.MAILING", FeatureNameValue("MAILING", feature_mailing)): + with patch( + "features.USER_INITIALIZE", + FeatureNameValue("USER_INITIALIZE", feature_user_initialize), + ): + mock_user_count.return_value = user_count + user = conduct_call( + client, + "web.user_initialize", + url_for, + "POST", + {}, + body=metadata, + expected_code=expected_code, + headers={"Content-Type": "application/json"}, + ) + + if expected_code == 200: + assert user.json["username"] == metadata["username"] + if feature_mailing: + assert user.json["email"] == metadata["email"] + else: + assert user.json["email"] is None + assert user.json.get("encrypted_password", None) + if metadata.get("access_token"): + assert 40 == len(user.json.get("access_token", "")) + else: + assert not user.json.get("access_token") diff --git a/endpoints/web.py b/endpoints/web.py index de0738161..b1b2d9879 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -32,6 +32,7 @@ from app import ( get_app_url, instance_keys, storage, + authentication, ) from auth import scopes from auth.auth_context import get_authenticated_user @@ -50,7 +51,7 @@ from buildtrigger.bitbuckethandler import BitbucketBuildTrigger from buildtrigger.customhandler import CustomBuildTrigger from buildtrigger.triggerutil import TriggerProviderException from data import model -from data.database import db, RepositoryTag, TagToRepositoryTag +from data.database import db, RepositoryTag, TagToRepositoryTag, random_string_generator, User from endpoints.api.discovery import swagger_route_data from endpoints.common import common_login, render_page_template from endpoints.csrf import csrf_protect, generate_csrf_token, verify_csrf @@ -897,3 +898,82 @@ def redirect_to_namespace(namespace): return redirect(url_for("web.org_view", path=namespace)) else: return redirect(url_for("web.user_view", path=namespace)) + + +def has_users(): + """ + Return false if no users in database yet + """ + return bool(User.select().limit(1)) + + +@web.route("/api/v1/user/initialize", methods=["POST"]) +@route_show_if(features.USER_INITIALIZE) +def user_initialize(): + """ + Create initial user in an empty database + """ + + # Ensure that we are using database auth. + if not features.USER_INITIALIZE: + response = jsonify({"message": "Cannot initialize user, FEATURE_USER_INITIALIZE is False"}) + response.status_code = 400 + return response + + # Ensure that we are using database auth. + if app.config["AUTHENTICATION_TYPE"] != "Database": + response = jsonify({"message": "Cannot initialize user in a non-database auth system"}) + response.status_code = 400 + return response + + if has_users(): + response = jsonify({"message": "Cannot initialize user in a non-empty database"}) + response.status_code = 400 + return response + + user_data = request.get_json() + 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=True, + email_required=features.MAILING, + is_possible_abuser=False, + prompts=prompts, + ) + success, headers = common_login(new_user.uuid) + if not success: + response = jsonify({"message": "Could not login. Failed to initialize user"}) + response.status_code = 403 + return response + + result = { + "username": user_data["username"], + "email": user_data.get("email"), + "encrypted_password": authentication.encrypt_user_password( + user_data["password"] + ).decode("ascii"), + } + + if user_data.get("access_token"): + model.oauth.create_application( + new_user, + "automation", + "", + "", + client_id=user_data["username"], + description="Application token generated via /api/v1/user/initialize", + ) + scope = "org:admin repo:admin repo:create repo:read repo:write super:user user:admin user:read" + created, access_token = model.oauth.create_user_access_token( + new_user, user_data["username"], scope + ) + result["access_token"] = access_token + + return (result, 200, headers) + except model.user.DataModelException as ex: + response = jsonify({"message": "Failed to initialize user: " + str(ex)}) + response.status_code = 400 + return response diff --git a/initdb.py b/initdb.py index 853a45290..af7ceaa05 100644 --- a/initdb.py +++ b/initdb.py @@ -888,7 +888,7 @@ def populate_database(minimal=False): description="This is another test application", ) - model.oauth.create_access_token_for_testing( + model.oauth.create_user_access_token( new_user_1, "deadbeef", "repo:admin", access_token="%s%s" % ("b" * 40, "c" * 40) ) diff --git a/test/test_api_usage.py b/test/test_api_usage.py index 14ab36506..52a191197 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -1265,7 +1265,7 @@ class TestCreateOrganization(ApiTestCase): # Attempt with auth with invalid scope. dt_user = model.user.get_user(ADMIN_ACCESS_USER) - token, code = model.oauth.create_access_token_for_testing(dt_user, "deadbeef", "repo:read") + token, code = model.oauth.create_user_access_token(dt_user, "deadbeef", "repo:read") self.postResponse( OrganizationList, data=dict(name="neworg", email="testorg@example.com"), @@ -1274,7 +1274,7 @@ class TestCreateOrganization(ApiTestCase): ) # Create OAuth token with user:admin scope. - token, code = model.oauth.create_access_token_for_testing( + token, code = model.oauth.create_user_access_token( dt_user, "deadbeef", "user:admin", access_token="d" * 40 ) data = self.postResponse( diff --git a/test/testconfig.py b/test/testconfig.py index a9a1aac71..cc17a92c3 100644 --- a/test/testconfig.py +++ b/test/testconfig.py @@ -112,3 +112,5 @@ class TestConfig(DefaultConfig): FEATURE_REPO_MIRROR = True FEATURE_GENERAL_OCI_SUPPORT = True OCI_NAMESPACE_WHITELIST = [] + + FEATURE_USER_INITIALIZE = True diff --git a/util/config/schema.py b/util/config/schema.py index 4d8c51cc8..b43530080 100644 --- a/util/config/schema.py +++ b/util/config/schema.py @@ -1173,5 +1173,11 @@ CONFIG_SCHEMA = { "description": "Whether new push to a non-existent organization creates it. Defaults to False.", "x-example": False, }, + # Allow first user to be initialized via API + "FEATURE_USER_INITIALIZE": { + "type": "boolean", + "description": "If set to true, the first User account may be created via API /api/v1/user/initialize", + "x-example": False, + }, }, }